From d3fb51b8f115b77cf37b5916ca40313c3f66f258 Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 05:54:57 -0700 Subject: [PATCH 01/28] Clean copies from the Markdown preview; stop rendering from corrupting code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying from the reading view carried the theme: Chromium's native copy inlined every computed style into text/html (navy background, near-white text, gold border), so Word, Outlook, Teams and mail pasted dark boxes or white text on white. And the TeX "repair" pass ran over the whole document, so a tab before `auth`/`ext…` became a literal `\t` and `\ ` became `\\ ` inside code blocks and inline code, which is what a copy then put on the clipboard. - Code blocks get a Copy button: exact source, no trailing newline, CRLF on Windows. Selections inside code copy plain text only; other selections copy clean, unstyled HTML; KaTeX copies as TeX source. - TeX repairs run only inside maths (output byte-identical on 16 maths cases); a leading BOM no longer hides the first heading. - Right-click menu (Cut/Copy/Paste/Select All, Copy Link Address); Ctrl+A in the reading view selects the document, not the window. - Memoize dangerouslySetInnerHTML objects: React 19 compares them by identity and rewrote every preview on each re-render, wiping Mermaid diagrams and the user's selection on window focus. Co-Authored-By: Claude Opus 5.5 --- electron/main.cjs | 32 ++++- src/App.css | 70 +++++++++- src/App.jsx | 19 ++- src/components/MarkdownView.jsx | 20 ++- src/markdown.js | 91 ++++++++++--- src/previewClipboard.js | 232 ++++++++++++++++++++++++++++++++ 6 files changed, 439 insertions(+), 25 deletions(-) create mode 100644 src/previewClipboard.js diff --git a/electron/main.cjs b/electron/main.cjs index 1c3cf37..e2d26c7 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain, shell, protocol, dialog, net } = require('electron'); +const { app, BrowserWindow, ipcMain, shell, protocol, dialog, net, Menu, clipboard } = require('electron'); const path = require('path'); const fs = require('fs'); const { execFile } = require('child_process'); @@ -1091,6 +1091,36 @@ function createWindow() { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.close(); }); + /* + * Right-click menu. Electron ships none, so right-clicking did nothing anywhere in FATE: no Copy + * in the reading view, no Cut or Paste in the editor. The roles run Chromium's own editing + * commands, so Copy fires the same 'copy' event as Ctrl+C (the preview's clean copy still + * applies; see src/previewClipboard.js), and the edit flags grey out whatever doesn't apply where + * the click landed. No Undo/Redo: CodeMirror keeps its own history, which those would bypass. + */ + mainWindow.webContents.on('context-menu', (_event, params) => { + const { editFlags, isEditable, selectionText, linkURL } = params; + const groups = []; + if (/^https?:/i.test(linkURL)) { + groups.push([{ label: 'Copy Link Address', click: () => clipboard.writeText(linkURL) }]); + } + if (isEditable) { + groups.push( + [ + { role: 'cut', enabled: editFlags.canCut }, + { role: 'copy', enabled: editFlags.canCopy }, + { role: 'paste', enabled: editFlags.canPaste } + ], + [{ role: 'selectAll', enabled: editFlags.canSelectAll }] + ); + } else if (selectionText.trim()) { + groups.push([{ role: 'copy' }]); + } + if (groups.length === 0) return; + const template = groups.flatMap((group, i) => (i ? [{ type: 'separator' }, ...group] : group)); + Menu.buildFromTemplate(template).popup({ window: mainWindow }); + }); + // SECURITY: Prevent inner navigation and force external links to open in default browser mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('http')) { diff --git a/src/App.css b/src/App.css index e179ec8..ddafe0f 100644 --- a/src/App.css +++ b/src/App.css @@ -1212,6 +1212,73 @@ kbd { border: 0; } +/* ── Code block Copy button ────────────────────────────────────────────────────────────────── + Added to every block by renderMarkdown, handled by previewClipboard.js. The button is EMPTY and + its icon is a CSS mask, so it contributes no text to a selection that runs across the block. It + sits on the wrapper rather than inside the
, so it stays put while a wide block scrolls
+   sideways under it. Shown on hover and keyboard focus, and held while it reports a result. */
+.markdown-body .code-block {
+  position: relative;
+}
+
+.markdown-body .code-copy {
+  position: absolute;
+  top: 0.45rem;
+  right: 0.45rem;
+  width: 30px;
+  height: 30px;
+  padding: 0;
+  border: 1px solid var(--border-default);
+  border-radius: var(--radius-sm);
+  background: var(--surface-raised);
+  color: var(--text-muted);
+  cursor: pointer;
+  user-select: none;
+  opacity: 0;
+  transition: opacity 0.18s var(--ease), color 0.18s var(--ease), border-color 0.18s var(--ease);
+}
+
+.markdown-body .code-copy::before {
+  content: '';
+  position: absolute;
+  inset: 0;
+  margin: auto;
+  width: 16px;
+  height: 16px;
+  background-color: currentColor;
+  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath d='M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z'/%3E%3C/svg%3E") center / contain no-repeat;
+}
+
+.markdown-body .code-block:hover .code-copy,
+.markdown-body .code-copy:focus-visible,
+.markdown-body .code-copy[data-state] {
+  opacity: 1;
+}
+
+.markdown-body .code-copy:hover {
+  color: var(--accent-default);
+  border-color: var(--border-emphasis);
+}
+
+.markdown-body .code-copy:focus-visible {
+  outline: 2px solid var(--border-emphasis);
+  outline-offset: 2px;
+}
+
+.markdown-body .code-copy[data-state='copied'] {
+  color: var(--status-success);
+  border-color: var(--status-success);
+}
+
+.markdown-body .code-copy[data-state='copied']::before {
+  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath d='M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E");
+}
+
+.markdown-body .code-copy[data-state='failed'] {
+  color: var(--status-danger);
+  border-color: var(--status-danger);
+}
+
 .markdown-body table {
   border-spacing: 0;
   border-collapse: collapse;
@@ -2851,7 +2918,8 @@ kbd {
   .palette-backdrop,
   .split-bar,
   .diff-view,
-  .md-edit-split {
+  .md-edit-split,
+  .markdown-body .code-copy {
     display: none !important;
   }
 
diff --git a/src/App.jsx b/src/App.jsx
index 4f7af74..fc0b24c 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
 import {
   UploadSimple, FileText, FileCode, CircleNotch, Gear, X, Plus, House, FilePlus,
   Printer, FilePdf, FloppyDisk, FolderOpen, ClockCounterClockwise, CheckCircle, Trash,
@@ -13,6 +13,7 @@ import SettingsModal from './components/SettingsModal.jsx';
 import CommandPalette from './components/CommandPalette.jsx';
 import DiffView from './components/DiffView.jsx';
 import { renderMarkdown } from './markdown.js';
+import { installPreviewClipboard, selectPreviewDocument } from './previewClipboard.js';
 import { detectLanguage } from './languageDetect.js';
 import { fileKindForName, looksBinary } from './fileKinds.js';
 import { resolveFonts, applyFonts, editorFontFor, DEFAULT_FONTS } from './fonts.js';
@@ -73,6 +74,8 @@ function kbdChips(binding) {
 function MarkdownEditView({ doc, isActive, tabSize, cursorLabelRef, onDirtyChange, onSave, registerEditor }) {
   const editorRef = useRef(null);
   const [previewHtml, setPreviewHtml] = useState(doc.html);
+  // Stable object, or React 19 rewrites the preview on every render (see MarkdownView).
+  const previewMarkup = useMemo(() => ({ __html: previewHtml }), [previewHtml]);
   const timerRef = useRef(null);
 
   const onDocChanged = useCallback(() => {
@@ -106,7 +109,7 @@ function MarkdownEditView({ doc, isActive, tabSize, cursorLabelRef, onDirtyChang
         />
       
       
-
+
); @@ -907,6 +910,15 @@ function App() { return; } } + + // Ctrl+A outside any editor or field selects the document being read, not the whole window. + // Last, so a shortcut rebound to Ctrl+A still wins. + const t = e.target; + const editable = t?.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t?.tagName); + if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.key.toLowerCase() === 'a' && + !editable && !showSettings && !showPalette && selectPreviewDocument()) { + e.preventDefault(); + } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); @@ -974,6 +986,9 @@ function App() { const [isDragActive, setIsDragActive] = useState(false); + /* Copy buttons on code blocks, and clean copies out of every markdown preview. */ + useEffect(() => installPreviewClipboard(), []); + useEffect(() => { // Depth counter because dragenter/dragleave fire for every child element crossed. let depth = 0; diff --git a/src/components/MarkdownView.jsx b/src/components/MarkdownView.jsx index cb99346..b58ff51 100644 --- a/src/components/MarkdownView.jsx +++ b/src/components/MarkdownView.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { List } from '@phosphor-icons/react'; /** @@ -23,6 +23,15 @@ function MarkdownView({ doc, isActive, sidebarWidth, onSidebarWidthChange, progr const [isSidebarOpen, setIsSidebarOpen] = useState(doc.toc.length > 0); const [activeHeading, setActiveHeading] = useState(''); + /* + * Stable markup objects. React 19 compares `dangerouslySetInnerHTML` by object identity and + * rewrites innerHTML whenever it changes, even to the same string, so a fresh `{ __html }` per + * render rebuilt the whole document on every App re-render (window focus, tab switches, status + * messages): mermaid diagrams vanished and a selection about to be copied was lost. + */ + const bodyMarkup = useMemo(() => ({ __html: doc.html }), [doc.html]); + const tocMarkup = useMemo(() => doc.toc.map((item) => ({ __html: item.html })), [doc.toc]); + const contentRef = useRef(null); const headingsRef = useRef([]); const activeHeadingRef = useRef(''); @@ -184,7 +193,8 @@ function MarkdownView({ doc, isActive, sidebarWidth, onSidebarWidthChange, progr holder.className = 'mermaid-diagram'; holder.innerHTML = svg; code.setAttribute('data-mermaid-done', '1'); - code.closest('pre')?.replaceWith(holder); + // The diagram replaces the fence's Copy-button wrapper too (see renderMarkdown). + (code.closest('.code-block') ?? code.closest('pre'))?.replaceWith(holder); } catch (err) { // Invalid diagram source: keep the fence as highlighted text, don't retry it forever. code.setAttribute('data-mermaid-failed', '1'); @@ -216,12 +226,12 @@ function MarkdownView({ doc, isActive, sidebarWidth, onSidebarWidthChange, progr
    - {doc.toc.map((item) => ( + {doc.toc.map((item, i) => (
  • scrollToHeading(item.id)} - dangerouslySetInnerHTML={{ __html: item.html }} + dangerouslySetInnerHTML={tocMarkup[i]} /> ))}
@@ -248,7 +258,7 @@ function MarkdownView({ doc, isActive, sidebarWidth, onSidebarWidthChange, progr )}
-
+
diff --git a/src/markdown.js b/src/markdown.js index 2cd9c3d..a8695f6 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -43,7 +43,54 @@ marked.use( }) ); -marked.use(markedKatex({ throwOnError: false, nonStandard: true })); +/* + * ── Corrupted TeX is repaired in maths ONLY ────────────────────────────────────────────────── + * Some generators write "\theta" into a string without escaping the backslash, so the file holds + * a real TAB followed by "heta". TEX_REPAIRS turns those back into TeX. Up to 1.13.4 they ran over + * the WHOLE document, code included: a tab-indented `auth := x` in a ```go block rendered as the + * literal text `\tauth := x`, `cd My\ Documents` in a ```bash block became `My\\ Documents`, and + * copying the block put the damage on the clipboard. Now each KaTeX tokenizer finds maths in the + * source exactly as written, then re-reads the repaired span to get the formula. `raw` keeps the + * original text, because the lexer advances by its length. + * + * The two carriage-return repairs live in renderMarkdown instead: marked turns a lone CR into a + * line break before any tokenizer sees it. + */ +/* eslint-disable no-control-regex */ +const TEX_REPAIRS = [ + [/\x09heta/g, '\\theta'], + [/\x09ext/g, '\\text'], + [/\x09imes/g, '\\times'], + [/\x09au/g, '\\tau'], + [/\x0Crac/g, '\\frac'], + [/\x08eta/g, '\\beta'], + [/\x08egin/g, '\\begin'], + [/\x07pprox/g, '\\approx'], + [/\x07lpha/g, '\\alpha'], + [/\x0B/g, '\\v'], + [/\\ /g, '\\\\ '] +]; +/* eslint-enable no-control-regex */ + +const repairTex = (tex) => TEX_REPAIRS.reduce((s, [pattern, fix]) => s.replace(pattern, fix), tex); + +function withTexRepair(extension) { + const tokenize = extension.tokenizer; + return { + ...extension, + tokenizer(src, tokens) { + const token = tokenize.call(this, src, tokens); + if (!token) return token; + const repairedRaw = repairTex(token.raw); + if (repairedRaw === token.raw) return token; + const repaired = tokenize.call(this, repairedRaw, tokens); + return repaired?.raw === repairedRaw ? { ...token, text: repaired.text } : token; + } + }; +} + +const katexExtension = markedKatex({ throwOnError: false, nonStandard: true }); +marked.use({ extensions: katexExtension.extensions.map(withTexRepair) }); /** * Render markdown source to sanitized HTML plus a table of contents. @@ -52,24 +99,18 @@ marked.use(markedKatex({ throwOnError: false, nonStandard: true })); * protocol so the main process can serve them from disk. */ export function renderMarkdown(content, fPath) { - // Repair mathematically corrupted control-characters from unescaped markdown generators. - // The literal control characters are intentional; generators emit a real \t byte where they - // meant to emit a backslash-t escape, so matching them is the entire point of this pass. + /* + * A leading byte-order mark (Windows tools still write UTF-8 with one) hid the first block from + * marked: `\uFEFF# Title` rendered as a paragraph and was missing from the contents. + * + * `\right` and `\rho` written with a real CR are the only TeX repairs that cannot wait for the + * maths tokenizer (see TEX_REPAIRS). They can only match a lone CR, never a CRLF line ending. + */ /* eslint-disable no-control-regex */ const repairedContent = content - .replace(/\x09heta/g, '\\theta') - .replace(/\x09ext/g, '\\text') - .replace(/\x09imes/g, '\\times') - .replace(/\x09au/g, '\\tau') - .replace(/\x0Crac/g, '\\frac') + .replace(/^\uFEFF/, '') .replace(/\x0Dight/g, '\\right') - .replace(/\x08eta/g, '\\beta') - .replace(/\x08egin/g, '\\begin') - .replace(/\x07pprox/g, '\\approx') - .replace(/\x07lpha/g, '\\alpha') - .replace(/\x0Dho/g, '\\rho') - .replace(/\x0B/g, '\\v') - .replace(/\\ /g, '\\\\ '); + .replace(/\x0Dho/g, '\\rho'); /* eslint-enable no-control-regex */ const rawHtml = marked.parse(repairedContent); @@ -105,6 +146,24 @@ export function renderMarkdown(content, fPath) { }); } + /* + * Every code block gets a wrapper and a Copy button (previewClipboard.js handles the click, and + * what any selection in the preview copies). The button is EMPTY, with its icon drawn by CSS, so + * it adds no text to a selection that runs across the block. Mermaid fences get one too, and the + * diagram pass replaces the whole wrapper once the SVG lands. + */ + for (const pre of tempDiv.querySelectorAll('pre')) { + const block = document.createElement('div'); + block.className = 'code-block'; + const copy = document.createElement('button'); + copy.type = 'button'; + copy.className = 'code-copy'; + copy.title = 'Copy code'; + copy.setAttribute('aria-label', 'Copy code'); + pre.replaceWith(block); + block.append(copy, pre); + } + // Heading ids for the TOC. Do not strip markup from `html`; headings can contain KaTeX, and the // sidebar renders it (see AI_CONTEXT.md §2). const headings = Array.from(tempDiv.querySelectorAll('h1, h2, h3')); diff --git a/src/previewClipboard.js b/src/previewClipboard.js new file mode 100644 index 0000000..15bdc82 --- /dev/null +++ b/src/previewClipboard.js @@ -0,0 +1,232 @@ +/** + * previewClipboard.js: what copying out of rendered markdown puts on the clipboard (the reading + * view, and the live preview beside the editor). + * + * ── Why FATE handles this itself ────────────────────────────────────────────────────────────── + * Left to Chromium, a copy from the preview carried the THEME: text/html with every computed style + * inlined (navy backgrounds, near-white text, gold borders, the app's fonts). Anything that pastes + * HTML (Word, Outlook, Teams, OneNote, mail) got a dark box, or white text on a white page, and a + * command copied out of a ```powershell block arrived with all of it attached. CodeMirror never + * had the problem because it writes plain text only; this module holds the preview to that: + * + * - Each code block's Copy button copies its exact text, minus the newline marked ends every + * block with, so a pasted command waits at the prompt instead of running. + * - A selection inside code (a fenced block, or inline `code`) copies as plain text only. + * - Any other selection copies as plain text plus clean HTML: structure survives (headings, + * lists, links, tables, code blocks), styles, classes and ids do not, so the receiving app + * formats it its own way. + * - Maths copies as its TeX source ($…$, $$…$$). Chromium's copy of a KaTeX formula put every + * glyph on its own line, twice over (the MathML layer, then the visual one). KaTeX's own + * contrib/copy-tex does the same substitution, but it installs a second global listener and + * loses paragraph breaks. + * - On Windows, plain text gets CRLF line breaks, as Chromium's own copies do there. + * + * Everything is delegated from `document`, so every preview (tabs, split panes, edit mode) is + * covered with no per-component wiring and the HTML from renderMarkdown stays a static string. + */ + +const IS_WINDOWS = window.electronAPI?.platform === 'win32'; + +/** Line breaks the way the platform's clipboard expects them. */ +function toClipboardText(text) { + const lf = text.replace(/\r\n?/g, '\n'); + return IS_WINDOWS ? lf.replace(/\n/g, '\r\n') : lf; +} + +function elementOf(node) { + if (!node) return null; + return node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; +} + +/* ── Copy buttons ──────────────────────────────────────────────────────────────────────────── */ + +const BUTTON_LABEL = 'Copy code'; +const resetTimers = new WeakMap(); + +/** Brief "Copied" / "Copy failed" feedback on the button itself (icon via CSS, label for AT). */ +function flashButton(button, state, label) { + clearTimeout(resetTimers.get(button)); + button.dataset.state = state; + button.title = label; + button.setAttribute('aria-label', label); + resetTimers.set( + button, + setTimeout(() => { + delete button.dataset.state; + button.title = BUTTON_LABEL; + button.setAttribute('aria-label', BUTTON_LABEL); + }, 1600) + ); +} + +function onClick(event) { + const button = event.target instanceof Element ? event.target.closest('.code-copy') : null; + const pre = button?.closest('.code-block')?.querySelector('pre'); + if (!pre) return; + const code = pre.textContent.replace(/\n$/, ''); + navigator.clipboard.writeText(toClipboardText(code)).then( + () => flashButton(button, 'copied', 'Copied'), + () => flashButton(button, 'failed', 'Copy failed') + ); +} + +/* ── Selections ────────────────────────────────────────────────────────────────────────────── */ + +/** True when nothing in `pre` follows the end of `range`. */ +function reachesEnd(range, pre) { + const rest = document.createRange(); + rest.setStart(range.endContainer, range.endOffset); + rest.setEnd(pre, pre.childNodes.length); // collapses to the start if `range` ends after `pre` + return rest.toString() === ''; +} + +/** Swap each KaTeX formula for its TeX source, delimited the way it was written. */ +function replaceMathWithTex(fragment, insideDisplay) { + for (const visual of fragment.querySelectorAll('.katex-mathml + .katex-html')) visual.remove(); + for (const mathml of fragment.querySelectorAll('.katex-mathml')) { + const tex = mathml.querySelector('annotation')?.textContent ?? ''; + const display = insideDisplay || !!mathml.closest('.katex-display'); + mathml.replaceWith(display ? `$$${tex}$$` : `$${tex}$`); + } +} + +/** The only attributes that survive into copied HTML: the ones that carry meaning, not looks. */ +const KEPT_ATTRIBUTES = { + A: ['href'], + IMG: ['src', 'alt'], + OL: ['start'], + TH: ['align', 'colspan', 'rowspan'], + TD: ['align', 'colspan', 'rowspan'], + INPUT: ['type', 'checked', 'disabled'] +}; + +function stripPresentation(el) { + const kept = KEPT_ATTRIBUTES[el.tagName] ?? []; + for (const { name } of [...el.attributes]) { + if (!kept.includes(name)) el.removeAttribute(name); + } +} + +/* + * Ancestors the copied nodes need around them. Inline formatting that encloses the whole selection + * (a partly selected link must stay a link), then the structure a fragment is invalid without + * (list items need their list, rows and cells their table). Block containers are left out on + * purpose: a phrase from inside a paragraph pastes inline, not as a new paragraph. + */ +const INLINE_CONTEXT = new Set(['A', 'STRONG', 'B', 'EM', 'I', 'DEL', 'S', 'U', 'MARK', 'SUB', 'SUP']); +const STRUCTURE_CONTEXT = new Set(['UL', 'OL', 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR']); + +function wrapInContext(holder, start, root) { + let content = holder; + const wrap = (ancestor) => { + const shell = ancestor.cloneNode(false); + stripPresentation(shell); + shell.append(...content.childNodes); + content = document.createElement('div'); + content.append(shell); + }; + let el = start; + for (; el && el !== root && INLINE_CONTEXT.has(el.tagName); el = el.parentElement) wrap(el); + for (; el && el !== root && STRUCTURE_CONTEXT.has(el.tagName); el = el.parentElement) wrap(el); + return content; +} + +/* + * Plain text of copied nodes as a reader sees them. innerText applies CSS (block breaks,
+ * whitespace, tabs between table cells) but only to RENDERED nodes, so they are laid out off-screen
+ * inside a .markdown-body for one synchronous read. Consumes `content`.
+ */
+function renderedText(content, root) {
+  const scratch = document.createElement('div');
+  scratch.className = 'markdown-body';
+  scratch.setAttribute('aria-hidden', 'true');
+  scratch.style.cssText = `position:fixed;left:-100000px;top:0;width:${root.clientWidth}px`;
+  scratch.append(...content.childNodes);
+  document.body.append(scratch);
+  try {
+    return scratch.innerText;
+  } finally {
+    scratch.remove();
+  }
+}
+
+function onCopy(event) {
+  const selection = window.getSelection();
+  if (event.defaultPrevented || !event.clipboardData) return;
+  if (!selection || selection.isCollapsed || !selection.rangeCount) return;
+
+  const range = selection.getRangeAt(0).cloneRange();
+  const common = elementOf(range.commonAncestorContainer);
+  const root = common?.closest('.markdown-body');
+  if (!root) return; // the editors and the rest of the app keep their own copy behaviour
+
+  // Code: exactly the selected characters, and no HTML for a rich editor to restyle.
+  const code = common.closest('pre, code, .code-block');
+  if (code) {
+    let text = selection.toString();
+    // The block's final newline is marked's, not the author's; copied, it runs the command on paste.
+    const pre = code.closest('.code-block')?.querySelector('pre') ?? code.closest('pre');
+    if (pre && text.endsWith('\n') && reachesEnd(range, pre)) text = text.slice(0, -1);
+    event.preventDefault();
+    event.clipboardData.setData('text/plain', toClipboardText(text));
+    return;
+  }
+
+  // A formula is copied whole or not at all; its source cannot be split mid-glyph.
+  const startMath = elementOf(range.startContainer)?.closest('.katex');
+  if (startMath) range.setStartBefore(startMath);
+  const endMath = elementOf(range.endContainer)?.closest('.katex');
+  if (endMath) range.setEndAfter(endMath);
+  const context = elementOf(range.commonAncestorContainer);
+
+  const holder = document.createElement('div');
+  holder.append(range.cloneContents());
+  const hasMath = !!holder.querySelector('.katex-mathml');
+  if (hasMath) replaceMathWithTex(holder, !!context.closest('.katex-display'));
+
+  for (const button of holder.querySelectorAll('.code-copy')) button.remove();
+  for (const img of holder.querySelectorAll('img')) {
+    // fate-local:// images only resolve inside FATE; anywhere else they paste as a broken box.
+    if (!/^(https?|data):/i.test(img.getAttribute('src') ?? '')) img.replaceWith(img.alt ?? '');
+  }
+  // SVG (mermaid diagrams) is left whole: without its attributes it is not a picture any more.
+  for (const el of holder.querySelectorAll('*')) {
+    if (!el.closest('svg')) stripPresentation(el);
+  }
+
+  const content = wrapInContext(holder, context, root);
+  const html = content.innerHTML;
+  // Chromium's own plain text for everything but maths (it is what users already get today).
+  const text = hasMath ? renderedText(content, root) : selection.toString();
+
+  event.preventDefault();
+  event.clipboardData.setData('text/plain', toClipboardText(text));
+  event.clipboardData.setData('text/html', html);
+}
+
+/*
+ * Ctrl+A in a preview selects the DOCUMENT. The browser default selects the whole window (tab
+ * strip, toolbar, contents sidebar, status bar), so select-all-then-copy gave you the app's
+ * chrome along with the text. Prefers the preview the current selection is in (split panes),
+ * else the active tab's. A selection can outlive its tab (hidden panes stay mounted), so only a
+ * VISIBLE preview counts. Returns false when there is none to select.
+ */
+export function selectPreviewDocument() {
+  const selection = window.getSelection();
+  if (!selection) return false;
+  const anchored = elementOf(selection.anchorNode)?.closest('.markdown-body');
+  const body = anchored?.checkVisibility() ? anchored : document.querySelector('.doc-pane-active .markdown-body');
+  if (!body?.checkVisibility()) return false;
+  selection.selectAllChildren(body);
+  return true;
+}
+
+/** Install the document-level handlers; returns the uninstaller (App's mount effect). */
+export function installPreviewClipboard() {
+  document.addEventListener('copy', onCopy);
+  document.addEventListener('click', onClick);
+  return () => {
+    document.removeEventListener('copy', onCopy);
+    document.removeEventListener('click', onClick);
+  };
+}

From 8558b21db9a3f722382a4c97a2120e00236624fa Mon Sep 17 00:00:00 2001
From: Dustin Szabo 
Date: Sun, 4 Oct 2026 06:29:12 -0700
Subject: [PATCH 02/28] Release 1.14.0 groundwork: shared renderer/main
 contracts

The API surface the 1.14.0 work builds against, fixed up front so the
main-process, renderer and preview changes can land in parallel:

- preload: file formats (encoding/BOM/EOL owned by main), onFileDeleted,
  reopenWithEncoding, hot-exit backups, openExternal, showItemInFolder,
  showTabContextMenu, onMenuCommand/updateMenu, setSpellcheck.
- CodeEditor exposes getView(); MarkdownEditView moves to its own module
  and passes the last-saved text (savedContent) as the editor baseline.
- MarkdownView receives isVisible (active tab or split pane).

Co-Authored-By: Claude Opus 5.5 
---
 electron/preload.cjs                | 73 ++++++++++++++++++++++++++---
 src/App.jsx                         | 53 ++-------------------
 src/components/CodeEditor.jsx       |  3 ++
 src/components/MarkdownEditView.jsx | 59 +++++++++++++++++++++++
 4 files changed, 132 insertions(+), 56 deletions(-)
 create mode 100644 src/components/MarkdownEditView.jsx

diff --git a/electron/preload.cjs b/electron/preload.cjs
index 8d5d180..8d5ae7c 100644
--- a/electron/preload.cjs
+++ b/electron/preload.cjs
@@ -17,19 +17,40 @@ contextBridge.exposeInMainWorld('electronAPI', {
       return null;
     }
   },
+  /*
+   * ── File formats ─────────────────────────────────────────────────────────────────────────────
+   * The main process owns how a file is stored on disk. The renderer only ever sees clean text:
+   * LF line breaks, no byte-order mark, already decoded. Each open carries the file's FORMAT,
+   *   { encoding: 'utf8' | 'utf16le' | 'utf16be' | 'windows1252', bom: boolean, eol: '\n' | '\r\n' | '\r' }
+   * and a save writes the text back in a format: the one passed, or else the one the file was
+   * opened with. So a CRLF file stays CRLF and a UTF-16 file stays UTF-16 without the renderer
+   * doing anything.
+   */
+
   // `meta.fromRestore` marks a tab being reinstated from last session rather than one the user
   // just asked for, so restoring tabs cannot steal focus from the file that launched the app.
+  // `meta.format` is the file's on-disk format (see above).
   onOpenFile: (callback) => {
     ipcRenderer.removeAllListeners('open-file');
     ipcRenderer.on('open-file', (_event, content, name, path, meta) => callback(content, name, path, meta || {}));
   },
   // The path rides along so the renderer can route the update to the right TAB, since any number of
-  // files can be watched at once since 1.10.0.
+  // files can be watched at once since 1.10.0. `meta.format` as for onOpenFile.
   onFileChanged: (callback) => {
     ipcRenderer.removeAllListeners('file-changed');
-    ipcRenderer.on('file-changed', (_event, content, path) => callback(content, path));
+    ipcRenderer.on('file-changed', (_event, content, path, meta) => callback(content, path, meta || {}));
+  },
+  /** A watched file is gone from disk and did not come back (deleted, or moved away). */
+  onFileDeleted: (callback) => {
+    ipcRenderer.removeAllListeners('file-deleted');
+    ipcRenderer.on('file-deleted', (_event, path) => callback(path));
   },
   openFileDialog: () => ipcRenderer.invoke('open-file-dialog'),
+  /**
+   * Re-read a file decoding it as `encoding` (for a file whose encoding was guessed wrong).
+   * Resolves { ok, content, format } or { ok: false, error }; the renderer swaps the buffer itself.
+   */
+  reopenWithEncoding: (filePath, encoding) => ipcRenderer.invoke('reopen-with-encoding', filePath, encoding),
 
   /** A tab closed: the main process stops watching its file. */
   closeFile: (filePath) => ipcRenderer.send('close-file', filePath),
@@ -45,9 +66,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
    * path WITHOUT re-sending 'open-file'. The response carries { filePath, name } and the renderer
    * updates its own state, keeping cursor and scroll position intact.
    */
-  saveFile: (filePath, content) => ipcRenderer.invoke('save-file', filePath, content),
-  saveFileAs: (suggestedName, content, oldPath) =>
-    ipcRenderer.invoke('save-file-as', suggestedName, content, oldPath),
+  saveFile: (filePath, content, format) => ipcRenderer.invoke('save-file', filePath, content, format),
+  saveFileAs: (suggestedName, content, oldPath, format) =>
+    ipcRenderer.invoke('save-file-as', suggestedName, content, oldPath, format),
+
+  /**
+   * Hot exit: unsaved buffers (Untitled ones included) are copied to the app's data folder a moment
+   * after each change, so a crash or a reload loses nothing. `id` is the renderer's own stable key
+   * for a tab; `data` is { kind, name, path, content, format, savedContent, untitled }.
+   * `list()` resolves the backups left over from a session that did not end cleanly.
+   */
+  backups: {
+    write: (id, data) => ipcRenderer.invoke('backup-write', id, data),
+    remove: (id) => ipcRenderer.invoke('backup-remove', id),
+    list: () => ipcRenderer.invoke('backup-list'),
+    clear: () => ipcRenderer.invoke('backup-clear')
+  },
 
   /**
    * Mirror the editor's dirty flag into the main process on every transition. This is what arms
@@ -135,5 +169,32 @@ contextBridge.exposeInMainWorld('electronAPI', {
   onUpdateMessage: (callback) => {
     ipcRenderer.removeAllListeners('update-message');
     ipcRenderer.on('update-message', (_event, message, action) => callback(message, action));
-  }
+  },
+
+  /**
+   * Open a link outside FATE: http(s) after the same confirmation an in-page navigation gets,
+   * mailto: directly. Anything else is refused. Resolves { ok }.
+   */
+  openExternal: (url) => ipcRenderer.invoke('open-external', url),
+  /** Show a file in the system file manager (only for files FATE has open). */
+  showItemInFolder: (filePath) => ipcRenderer.invoke('show-item-in-folder', filePath),
+  /**
+   * Native menu for a tab's right-click. `info` is { path, name }; resolves the chosen action,
+   * 'copyPath' | 'reveal' | 'close' | 'closeOthers' | 'closeRight', or null when dismissed.
+   * The renderer carries the action out.
+   */
+  showTabContextMenu: (info) => ipcRenderer.invoke('show-tab-context-menu', info),
+  /**
+   * The application menu (Alt shows it) sends commands here: 'newFile', 'openFile', 'save',
+   * 'saveAs', 'print', 'exportPdf', 'closeTab', 'toggleEdit', 'toggleSplit', 'focusMode',
+   * 'palette', 'settings', 'shortcuts', 'about', 'find', 'gotoLine', 'gotoSymbol', 'undo', 'redo'.
+   */
+  onMenuCommand: (callback) => {
+    ipcRenderer.removeAllListeners('menu-command');
+    ipcRenderer.on('menu-command', (_event, command, arg) => callback(command, arg));
+  },
+  /** Tell the menu the user's current shortcut bindings (shown as labels; the renderer handles the keys). */
+  updateMenu: (state) => ipcRenderer.send('update-menu', state),
+  /** Turn the spellchecker on or off. Off by default on Linux, where it downloads dictionaries. */
+  setSpellcheck: (enabled) => ipcRenderer.invoke('set-spellcheck', enabled)
 });
diff --git a/src/App.jsx b/src/App.jsx
index fc0b24c..8080f4d 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
 import {
   UploadSimple, FileText, FileCode, CircleNotch, Gear, X, Plus, House, FilePlus,
   Printer, FilePdf, FloppyDisk, FolderOpen, ClockCounterClockwise, CheckCircle, Trash,
@@ -9,6 +9,7 @@ import fateLogo from './assets/FATE-Square-Icon.png';
 import Starfield from './components/Starfield.jsx';
 import CodeEditor from './components/CodeEditor.jsx';
 import MarkdownView from './components/MarkdownView.jsx';
+import MarkdownEditView from './components/MarkdownEditView.jsx';
 import SettingsModal from './components/SettingsModal.jsx';
 import CommandPalette from './components/CommandPalette.jsx';
 import DiffView from './components/DiffView.jsx';
@@ -66,55 +67,6 @@ function kbdChips(binding) {
   return (binding || '').split('+').map((k) => (k === 'Control' ? 'Ctrl' : k));
 }
 
-/**
- * MarkdownEditView is a markdown tab's EDIT mode: CodeMirror source on the left, live preview on
- * the right, re-rendered ~a third of a second after typing pauses. Top-level component (never
- * defined inside App, which would remount it every render).
- */
-function MarkdownEditView({ doc, isActive, tabSize, cursorLabelRef, onDirtyChange, onSave, registerEditor }) {
-  const editorRef = useRef(null);
-  const [previewHtml, setPreviewHtml] = useState(doc.html);
-  // Stable object, or React 19 rewrites the preview on every render (see MarkdownView).
-  const previewMarkup = useMemo(() => ({ __html: previewHtml }), [previewHtml]);
-  const timerRef = useRef(null);
-
-  const onDocChanged = useCallback(() => {
-    clearTimeout(timerRef.current);
-    timerRef.current = setTimeout(() => {
-      const text = editorRef.current?.getContent() ?? '';
-      setPreviewHtml(renderMarkdown(text, doc.path).html);
-    }, 350);
-  }, [doc.path]);
-
-  useEffect(() => () => clearTimeout(timerRef.current), []);
-
-  return (
-    
-
- { - editorRef.current = el; - registerEditor(el); - }} - fileName={doc.name} - initialContent={doc.source} - lint={false /* markdown has no syntax errors; skip the empty lint gutter */} - wrap={true /* prose: unwrapped markdown source is unreadable */} - tabSize={tabSize} - isActive={isActive} - onDirtyChange={onDirtyChange} - onSave={onSave} - onDocChanged={onDocChanged} - cursorLabelRef={cursorLabelRef} - /> -
-
-
-
-
- ); -} - function App() { /* * ── Tabs ──────────────────────────────────────────────────────────────────────────────────── @@ -1145,6 +1097,7 @@ function App() { key="mdview" doc={d} isActive={active} + isVisible={visible} sidebarWidth={sidebarWidth} onSidebarWidthChange={setSidebarWidth} progressBarRef={progressBarRef} diff --git a/src/components/CodeEditor.jsx b/src/components/CodeEditor.jsx index 20f73f1..eb9a53b 100644 --- a/src/components/CodeEditor.jsx +++ b/src/components/CodeEditor.jsx @@ -263,6 +263,9 @@ const CodeEditor = forwardRef(function CodeEditor( useImperativeHandle( ref, () => ({ + /** The live EditorView (for scroll sync, go-to-line and the like), or null once destroyed. */ + getView: () => viewRef.current, + /** Current buffer contents: what Save writes to disk. */ getContent: () => viewRef.current?.state.doc.toString() ?? '', diff --git a/src/components/MarkdownEditView.jsx b/src/components/MarkdownEditView.jsx new file mode 100644 index 0000000..f058903 --- /dev/null +++ b/src/components/MarkdownEditView.jsx @@ -0,0 +1,59 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import CodeEditor from './CodeEditor.jsx'; +import { renderMarkdown } from '../markdown.js'; + +/** + * MarkdownEditView is a markdown tab's EDIT mode: CodeMirror source on the left, live preview on + * the right, re-rendered ~a third of a second after typing pauses. Its own module (it used to live + * in App.jsx) so it is never defined inside App, which would remount it every render. + * + * `savedContent` is the text as last saved to disk, which can differ from `doc.source` when the + * tab left Edit mode with unsaved changes. The editor measures "dirty" against it, so coming back + * into Edit mode never passes unsaved text off as saved. + */ +function MarkdownEditView({ doc, isActive, tabSize, cursorLabelRef, onDirtyChange, onSave, registerEditor }) { + const editorRef = useRef(null); + const [previewHtml, setPreviewHtml] = useState(doc.html); + // Stable object, or React 19 rewrites the preview on every render (see MarkdownView). + const previewMarkup = useMemo(() => ({ __html: previewHtml }), [previewHtml]); + const timerRef = useRef(null); + + const onDocChanged = useCallback(() => { + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + const text = editorRef.current?.getContent() ?? ''; + setPreviewHtml(renderMarkdown(text, doc.path).html); + }, 350); + }, [doc.path]); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + return ( +
+
+ { + editorRef.current = el; + registerEditor(el); + }} + fileName={doc.name} + initialContent={doc.source} + savedContent={doc.savedSource ?? doc.source} + lint={false /* markdown has no syntax errors; skip the empty lint gutter */} + wrap={true /* prose: unwrapped markdown source is unreadable */} + tabSize={tabSize} + isActive={isActive} + onDirtyChange={onDirtyChange} + onSave={onSave} + onDocChanged={onDocChanged} + cursorLabelRef={cursorLabelRef} + /> +
+
+
+
+
+ ); +} + +export default MarkdownEditView; From 02f4250bb61b4ffe6312df235ce7b3cf974f5012 Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 06:39:30 -0700 Subject: [PATCH 03/28] PRIVACY: remote images, spell check and unsaved-change backups (1.14.0) Co-Authored-By: Claude Opus 5.5 --- PRIVACY.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 5ea9507..3cd8ebb 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy for FATE - Formatted Article & Text Editor -**Effective Date:** September 15, 2026 (supersedes the August 5, 2026 policy) +**Effective Date:** October 4, 2026 (supersedes the September 15, 2026 policy) FATE is a local, offline desktop application for reading and editing documents. Your files and your data belong to you. This policy describes, in plain language, everything FATE does that could @@ -14,6 +14,12 @@ conceivably touch data. There is very little. editing happens entirely on your machine. Typefaces are bundled with the app; nothing is fetched from a CDN or webfont service. * **No accounts.** FATE has no sign-in, no registration, and never asks for personal information. +* **Images from the internet stay off.** A Markdown document can point at images on other websites. + FATE does not load them unless you choose to, for one document or always (Settings), because + loading them tells those servers your IP address and that the document was opened. +* **Spell check** is optional. On Windows it uses Windows' own spellchecker, entirely on your + machine. On Linux it is off by default: turning it on makes FATE download a dictionary for your + language from Google's servers, once, the way Chromium-based apps do. ## 2. File Access @@ -21,7 +27,7 @@ FATE requests local file system access ("Full Trust") solely to open, display, e files **you explicitly choose**: through the open dialog, drag & drop, the recent-files list, a file association, or the "Edit in FATE" context-menu entry. While a file is open, FATE watches it for external changes so the view can refresh. FATE does not scan, index, upload, or share your files. -They never leave your device. +They never leave your device. Links in a document open in your web browser only after you confirm. ## 3. Data Stored Locally @@ -30,6 +36,10 @@ fonts, keyboard shortcuts, and similar), the paths of recently opened files, and restore is enabled) the paths of the tabs you had open. This file stays on your device, is never transmitted anywhere, and is removed if you delete the app's data folder. +While a document has unsaved changes, FATE keeps a copy of them in a backups folder inside the same +data folder, so a crash or power cut doesn't lose your work. A copy is deleted as soon as you save, +or close the document without saving. + On Windows, FATE also writes standard registry entries so that it appears in "Open with" menus and on its page in Windows Settings → Default apps. These entries are local system configuration (they contain no personal data), only change further when you turn on a setting that asks for it From 46a8336639cf686f81e324bd8927c5fec78180a0 Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 07:19:39 -0700 Subject: [PATCH 04/28] Add file-format, atomic-save and live-reload modules with tests Three Electron-free modules for the main process, each with node --test coverage in test/ (run: node --test test/): - fileFormat.cjs: decode/encode with the file's format { encoding: utf8|utf16le|utf16be|windows1252, bom, eol }. BOM sniffing, BOM-less UTF-16 by its NUL pattern (conservative), strict UTF-8, else Windows-1252; line breaks normalised to \n with the dominant EOL recorded. The binary sniff now runs after UTF-16 detection, so PowerShell's UTF-16 output is no longer refused as binary. Round trips are byte-exact (unpaired surrogates and the five undefined 1252 bytes included); Windows-1252 refuses unencodable text with UNENCODABLE, naming the character and line. (H1, H4) - textFiles.cjs: async readTextFile (size cap, NOT_FILE/TOO_LARGE/BINARY) and writeFileAtomic: temp file in the same folder, fsync, rename over the target. Symlinks resolved (the link stays a link), mode bits and owner kept, the Windows mark of the web carried over, read-only files still refused; in-place fallback for hard links, refused renames and folders that won't take a temp file; never leaves the temp file. (H5) - fileWatch.cjs: per-path watchers that coalesce bursts (one reload per atomic save by another editor), follow replaced files, retry deleted ones with backoff and report them once if they stay gone, survive 'error' events, deliver only the newest read, and hold reads while FATE's own save is writing. (M10, low: three change events) Co-Authored-By: Claude Opus 5.5 --- electron/fileFormat.cjs | 403 +++++++++++++++++++++++++++++++++++++++ electron/fileWatch.cjs | 298 +++++++++++++++++++++++++++++ electron/textFiles.cjs | 229 ++++++++++++++++++++++ test/fileFormat.test.cjs | 207 ++++++++++++++++++++ test/fileWatch.test.cjs | 215 +++++++++++++++++++++ test/textFiles.test.cjs | 198 +++++++++++++++++++ 6 files changed, 1550 insertions(+) create mode 100644 electron/fileFormat.cjs create mode 100644 electron/fileWatch.cjs create mode 100644 electron/textFiles.cjs create mode 100644 test/fileFormat.test.cjs create mode 100644 test/fileWatch.test.cjs create mode 100644 test/textFiles.test.cjs diff --git a/electron/fileFormat.cjs b/electron/fileFormat.cjs new file mode 100644 index 0000000..4e4d21e --- /dev/null +++ b/electron/fileFormat.cjs @@ -0,0 +1,403 @@ +'use strict'; +/** + * fileFormat.cjs: how a text file is stored on disk, and the conversions in and out of it. + * + * The renderer only ever holds clean text: decoded, no byte-order mark, `\n` line breaks. + * Everything else about the bytes on disk is the file's FORMAT, + * + * { encoding: 'utf8' | 'utf16le' | 'utf16be' | 'windows1252', bom: boolean, eol: '\n' | '\r\n' | '\r' } + * + * which `decode` reports on open and `encode` applies on save, so a file goes back to disk the + * way it came off it. Up to 1.13.4 FATE read every file as UTF-8 and wrote `\n`-joined UTF-8 + * back: one edit to a CRLF .bat or .ps1 rewrote every line ending (git showed the whole file + * changed), Windows-1252 text came back with é turned into U+FFFD for good, and UTF-16 (what + * PowerShell 5.1's `>` and Out-File write) was refused as binary because of its NUL bytes. + * + * The rule throughout is that a round trip never changes a byte FATE did not have to change: + * - Detection never picks a lossy decoding. Bytes that are not valid UTF-8 are read as + * Windows-1252, which maps every byte to its own character, rather than as UTF-8 with + * replacement characters that would be written back as U+FFFD. + * - UTF-16 is decoded code unit for code unit (Buffer's utf16le), so even unpaired surrogates, + * which Windows allows in file and registry text, survive. + * - Windows-1252 refuses to save text it cannot represent (UNENCODABLE) instead of writing '?'. + * + * Pure functions, free of Electron and fs on purpose: they run under plain node for the tests in + * test/fileFormat.test.cjs. + */ + +const ENCODINGS = ['utf8', 'utf16le', 'utf16be', 'windows1252']; +const EOLS = ['\n', '\r\n', '\r']; + +/** Names for messages and status bars. */ +const ENCODING_LABELS = { + utf8: 'UTF-8', + utf16le: 'UTF-16 LE', + utf16be: 'UTF-16 BE', + windows1252: 'Windows-1252' +}; + +/** Byte-order marks, longest first is irrelevant here: no BOM is a prefix of another. */ +const BOMS = [ + { encoding: 'utf8', bytes: Buffer.from([0xef, 0xbb, 0xbf]) }, + { encoding: 'utf16le', bytes: Buffer.from([0xff, 0xfe]) }, + { encoding: 'utf16be', bytes: Buffer.from([0xfe, 0xff]) } +]; + +/** + * How much of a file the binary and UTF-16 sniffs look at. 8 KB is the same window the binary + * sniff has always used: enough to see an executable's or image's header, cheap on a 25 MB file. + */ +const SNIFF_BYTES = 8192; + +/** + * The format for a file FATE has never read: a new file from Save As, or a path main has no + * record of. UTF-8 without a BOM everywhere; line breaks follow the platform, because a new file + * on Windows is expected to be CRLF (Notepad, PowerShell and cmd all assume it). + */ +function defaultFormat(platform = process.platform) { + return { encoding: 'utf8', bom: false, eol: platformEol(platform) }; +} + +function platformEol(platform = process.platform) { + return platform === 'win32' ? '\r\n' : '\n'; +} + +/** An Error carrying a machine-readable `code`, so callers can choose the message. */ +function codedError(code, message, extra) { + const err = new Error(message); + err.code = code; + return Object.assign(err, extra); +} + +function toBuffer(input) { + if (Buffer.isBuffer(input)) return input; + if (input instanceof Uint8Array) return Buffer.from(input.buffer, input.byteOffset, input.byteLength); + throw codedError('BAD_ARGS', 'Expected the file contents as bytes'); +} + +/** The BOM at the start of `buf`, as { encoding, length }, or null. */ +function sniffBom(buf) { + for (const { encoding, bytes } of BOMS) { + if (buf.length >= bytes.length && buf.subarray(0, bytes.length).equals(bytes)) { + return { encoding, length: bytes.length }; + } + } + return null; +} + +/** C0 controls a text file has no business containing: everything below 0x20 except tab, LF, + * VT, FF, CR and ESC (terminal colour codes in logs). */ +function isStrayControl(byte) { + return byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0b && byte !== 0x0c && byte !== 0x0d && byte !== 0x1b; +} + +/** + * UTF-16 without a BOM, recognised by where its NUL bytes fall. Text that is mostly ASCII or + * Latin-1 has a zero high byte in most code units: the odd bytes for little-endian, the even + * bytes for big-endian, while the other half is almost never zero. + * + * Deliberately conservative; a miss just leaves the file to the binary sniff, as before 1.14.0. + * Every one of these must hold, over the first 8 KB: + * - the file length is even (UTF-16 is two bytes per unit); + * - no code unit is 0x0000: no text contains NUL, and binary formats are full of it; + * - at least half the units have a zero byte on one side, and under 5% on the other; + * - at most 1% of the ASCII-range units are stray control characters, which rules out arrays + * of small 16-bit integers (zero high bytes, but low bytes like 0x01 and 0x05). + * So text in non-Latin scripts without a BOM (Cyrillic, CJK) is not detected; Windows tools write + * a BOM for those, and the BOM path handles them. + * + * Returns 'utf16le', 'utf16be' or null. + */ +function sniffUtf16(buf) { + if (buf.length < 2 || buf.length % 2 !== 0) return null; + const end = Math.min(buf.length, SNIFF_BYTES); + const units = end >> 1; + let evenNul = 0; + let oddNul = 0; + let leControls = 0; + let beControls = 0; + for (let i = 0; i < end; i += 2) { + const even = buf[i]; + const odd = buf[i + 1]; + if (even === 0 && odd === 0) return null; + if (even === 0) { + evenNul++; + if (isStrayControl(odd)) beControls++; + } else if (odd === 0) { + oddNul++; + if (isStrayControl(even)) leControls++; + } + } + if (oddNul * 2 >= units && evenNul * 20 < units && leControls * 100 <= units) return 'utf16le'; + if (evenNul * 2 >= units && oddNul * 20 < units && beControls * 100 <= units) return 'utf16be'; + return null; +} + +/** + * Cheap binary sniff, per encoding. For the 8-bit encodings it is the check FATE always made: a + * NUL byte in the first 8 KB. Text never contains NUL; executables, images and archives contain + * it almost immediately. UTF-16 text is FULL of NUL bytes (every ASCII character has one), so for + * UTF-16 the same idea moves up a level: a NUL code unit, two zero bytes on a unit boundary. + * + * Must run AFTER UTF-16 detection; with the 8-bit rule applied first, every UTF-16 file was + * refused as binary up to 1.13.4. A guard against "Open with FATE" on the wrong file, not a + * general-purpose detector. + */ +function isProbablyBinary(input, encoding = 'utf8') { + const buf = toBuffer(input); + const head = buf.subarray(0, SNIFF_BYTES); + if (encoding === 'utf16le' || encoding === 'utf16be') { + for (let i = 0; i + 1 < head.length; i += 2) { + if (head[i] === 0 && head[i + 1] === 0) return true; + } + return false; + } + return head.includes(0); +} + +/* + * Decoders are created once. `ignoreBOM: true` because the BOM is stripped by hand below, and a + * SECOND U+FEFF after it is content that must survive the round trip. + */ +const strictUtf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); +let cp1252Decoder = null; +function cp1252() { + if (!cp1252Decoder) cp1252Decoder = new TextDecoder('windows-1252'); + return cp1252Decoder; +} + +/** + * UTF-16 through Buffer rather than TextDecoder: Buffer's utf16le maps each code unit straight + * to a JS string unit and back, unpaired surrogates included, so decode → encode is exact. + * TextDecoder would replace those with U+FFFD. An odd trailing byte (a truncated file) cannot be + * a character; it becomes U+FFFD, the one lossy case, and only for a file already damaged. + */ +function decodeUtf16(body, bigEndian) { + const evenLength = body.length - (body.length % 2); + let units = body.subarray(0, evenLength); + if (bigEndian) units = Buffer.from(units).swap16(); // swap a copy: swap16 works in place + const text = units.toString('utf16le'); + return evenLength === body.length ? text : `${text}�`; +} + +/** + * Count line breaks and normalise them to `\n`. The format's eol is the most common style, so a + * mixed file is written back consistently in its majority style. A file with no line break at + * all takes `defaultEol`. Ties go to `defaultEol`, then CRLF, then LF. + */ +function normalizeEol(text, defaultEol) { + if (text.indexOf('\r') === -1) { + return { text, eol: text.indexOf('\n') === -1 ? defaultEol : '\n' }; + } + let crlf = 0; + let cr = 0; + let lf = 0; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c === 13) { + if (text.charCodeAt(i + 1) === 10) { + crlf++; + i++; + } else { + cr++; + } + } else if (c === 10) { + lf++; + } + } + const counts = { '\r\n': crlf, '\n': lf, '\r': cr }; + let eol = defaultEol; + for (const candidate of [defaultEol, '\r\n', '\n', '\r']) { + if (counts[candidate] > counts[eol]) eol = candidate; + } + return { text: text.replace(/\r\n?/g, '\n'), eol }; +} + +/** `\r\n` and lone `\r` become `\n`. What the renderer holds, and what comparisons use. */ +function normalizeText(text) { + return text.indexOf('\r') === -1 ? text : text.replace(/\r\n?/g, '\n'); +} + +/** + * Decode a file's bytes. + * + * Without `forcedEncoding`: a BOM decides; else UTF-16 by its NUL pattern; else strict UTF-8; + * else Windows-1252 (the Windows "ANSI" code page, which is what a non-UTF-8 text file on a + * Western Windows machine almost always is). A file that starts with a UTF-8 BOM but is not + * valid UTF-8 is read as Windows-1252 too, BOM bytes included as text (): wrong-looking but + * lossless, where UTF-8 with replacement characters would destroy the bad bytes on save. + * + * With `forcedEncoding` (Reopen with encoding): that encoding, and a BOM is only stripped when it + * belongs to it. A forced decoding that could not be saved back unchanged is refused rather than + * opened lossy: UTF-8 over invalid bytes, UTF-16 over an odd number of bytes (INVALID). + * + * Throws coded errors: BINARY (looks like a binary file in that encoding), INVALID, BAD_ENCODING. + * Their messages are fragments ("it contains …") for the caller to put after the file name. + * + * @param {Buffer|Uint8Array} input + * @param {string|null} [forcedEncoding] + * @param {{ defaultEol?: string, platform?: string }} [opts] eol for files without line breaks + * @returns {{ text: string, format: { encoding: string, bom: boolean, eol: string } }} + */ +function decode(input, forcedEncoding = null, opts = {}) { + const buf = toBuffer(input); + const defaultEol = EOLS.includes(opts.defaultEol) ? opts.defaultEol : platformEol(opts.platform); + if (forcedEncoding != null && !ENCODINGS.includes(forcedEncoding)) { + throw codedError('BAD_ENCODING', `Unknown encoding: ${forcedEncoding}`); + } + + const bom = sniffBom(buf); + let encoding; + let bomLength = 0; + if (forcedEncoding) { + encoding = forcedEncoding; + if (bom && bom.encoding === forcedEncoding) bomLength = bom.length; + } else if (bom) { + encoding = bom.encoding; + bomLength = bom.length; + } else { + encoding = sniffUtf16(buf) || 'utf8'; + } + + if (isProbablyBinary(buf, encoding)) { + throw codedError('BINARY', `it doesn't look like ${ENCODING_LABELS[encoding]} text`); + } + + const body = buf.subarray(bomLength); + let text; + if (encoding === 'utf8') { + try { + text = strictUtf8.decode(body); + } catch { + if (forcedEncoding) { + throw codedError('INVALID', "it contains bytes that aren't valid UTF-8"); + } + encoding = 'windows1252'; + bomLength = 0; + text = cp1252().decode(buf); + } + } else if (encoding === 'utf16le' || encoding === 'utf16be') { + if (forcedEncoding && body.length % 2 !== 0) { + throw codedError('INVALID', "it has an odd number of bytes, so it can't be UTF-16"); + } + text = decodeUtf16(body, encoding === 'utf16be'); + } else { + text = cp1252().decode(body); + } + + const normalized = normalizeEol(text, defaultEol); + return { text: normalized.text, format: { encoding, bom: bomLength > 0, eol: normalized.eol } }; +} + +/* + * Windows-1252 reverse table, built from the platform's own decoder over all 256 bytes rather + * than typed in. The WHATWG table maps the five bytes Microsoft left undefined (0x81, 0x8D, 0x8F, + * 0x90, 0x9D) to the matching C1 controls, so every byte has a character and every one of those + * 256 characters has exactly one byte: decode → encode is exact for any file. Int16 so -1 can + * mark "no byte for this character". Built on first use (128 KB). + */ +let cp1252Reverse = null; +function cp1252Table() { + if (!cp1252Reverse) { + const chars = cp1252().decode(Uint8Array.from({ length: 256 }, (_, i) => i)); + cp1252Reverse = new Int16Array(65536).fill(-1); + for (let byte = 0; byte < chars.length; byte++) cp1252Reverse[chars.charCodeAt(byte)] = byte; + } + return cp1252Reverse; +} + +/** The first character Windows-1252 cannot hold, as a coded UNENCODABLE error, or null. */ +function findUnencodable(text) { + const table = cp1252Table(); + for (let i = 0; i < text.length; i++) { + if (table[text.charCodeAt(i)] >= 0) continue; + const codePoint = text.codePointAt(i); + const char = String.fromCodePoint(codePoint); + let line = 1; + for (let j = text.indexOf('\n'); j !== -1 && j < i; j = text.indexOf('\n', j + 1)) line++; + const hex = codePoint.toString(16).toUpperCase().padStart(4, '0'); + return codedError( + 'UNENCODABLE', + `“${char}” (U+${hex}) on line ${line} can't be saved in Windows-1252. Save the file as UTF-8 instead.`, + { char, codePoint, index: i, line } + ); + } + return null; +} + +function encodeCp1252(text) { + const table = cp1252Table(); + const out = Buffer.allocUnsafe(text.length); + for (let i = 0; i < text.length; i++) out[i] = table[text.charCodeAt(i)]; + return out; +} + +/** + * A complete, valid format: `candidate`'s fields over `base`'s. Unknown keys are dropped (the + * renderer may carry more per-tab state than main needs). Windows-1252 has no BOM, so `bom` is + * forced off for it. Throws BAD_FORMAT for anything else that is wrong; a save in a format + * nobody asked for is worse than a save that fails and says why. + */ +function resolveFormat(candidate, base) { + if (candidate != null && (typeof candidate !== 'object' || Array.isArray(candidate))) { + throw codedError('BAD_FORMAT', 'The file format to save in is not valid'); + } + const fallback = base || defaultFormat(); + const pick = (key) => (candidate && candidate[key] !== undefined ? candidate[key] : fallback[key]); + const format = { encoding: pick('encoding'), bom: pick('bom'), eol: pick('eol') }; + if (!ENCODINGS.includes(format.encoding)) { + throw codedError('BAD_FORMAT', `Unknown encoding: ${String(format.encoding)}`); + } + if (typeof format.bom !== 'boolean') throw codedError('BAD_FORMAT', 'The byte-order mark setting is not valid'); + if (!EOLS.includes(format.eol)) throw codedError('BAD_FORMAT', 'The line-ending setting is not valid'); + if (format.encoding === 'windows1252') format.bom = false; + return format; +} + +function sameFormat(a, b) { + return !!a && !!b && a.encoding === b.encoding && a.bom === b.bom && a.eol === b.eol; +} + +/** + * Encode renderer text for disk: `\n` → format.eol, then the encoding, then the BOM. Any `\r` + * already in the text is normalised first, so stray CRs cannot turn into `\r\r\n`. + * Throws UNENCODABLE (Windows-1252 only, naming the first offending character and its line) and + * BAD_FORMAT. + * + * @returns {Buffer} + */ +function encode(text, format) { + if (typeof text !== 'string') throw codedError('BAD_ARGS', 'Expected the document text as a string'); + const f = resolveFormat(format); + const lf = normalizeText(text); + if (f.encoding === 'windows1252') { + const err = findUnencodable(lf); + if (err) throw err; + } + const body = f.eol === '\n' ? lf : lf.replace(/\n/g, f.eol); + + let data; + if (f.encoding === 'utf8') data = Buffer.from(body, 'utf8'); + else if (f.encoding === 'utf16le') data = Buffer.from(body, 'utf16le'); + else if (f.encoding === 'utf16be') data = Buffer.from(body, 'utf16le').swap16(); + else data = encodeCp1252(body); + + if (!f.bom) return data; + const bom = BOMS.find((b) => b.encoding === f.encoding); + return Buffer.concat([bom.bytes, data]); +} + +module.exports = { + ENCODINGS, + EOLS, + ENCODING_LABELS, + defaultFormat, + platformEol, + decode, + encode, + resolveFormat, + sameFormat, + normalizeText, + isProbablyBinary, + sniffUtf16 +}; diff --git a/electron/fileWatch.cjs b/electron/fileWatch.cjs new file mode 100644 index 0000000..5230e18 --- /dev/null +++ b/electron/fileWatch.cjs @@ -0,0 +1,298 @@ +'use strict'; +/** + * fileWatch.cjs: live reload for open files, one watcher per path. + * + * What fs.watch hands over is noisy and fragile, and up to 1.13.4 FATE took it at face value: + * + * - BURSTS. An atomic save by another editor (write a temp file, rename it over the original) + * fires two or three events, and each one re-read the file and reloaded the tab. Events are + * now coalesced per path: a read happens once the path has been quiet for `debounceMs`, or + * `maxWaitMs` after the first event at the latest, so a file that is written continuously + * (a log) still updates instead of waiting for a pause that never comes. + * + * - REPLACED FILES. On Linux the watch is bound to the file's inode, so once a rename puts a new + * file at the path the old watcher hears nothing more. After a 'rename' the watcher re-attaches + * to whatever is at the path now. + * + * - DELETED FILES. If the file was missing when the rename timer fired (a branch switch, an + * editor that deletes before writing), the old code returned without re-arming: live reload + * was dead for that tab and the watcher leaked. Now the dead watcher is closed and the path is + * retried with backoff (`retryDelays`); if the file comes back it is watched and read again, + * and if it never does, `onDeleted` reports it once. + * + * - ERRORS. A watcher can emit 'error' (Windows does when the folder goes away); with no + * listener that is an uncaught exception in the main process. It is now treated as a rename. + * + * The reads themselves belong to the caller (`read`, async), and so does what a read means + * (`onRead`). This module guarantees that only the newest read is delivered: a read that was + * overtaken by a newer one, or by a `hold`, is dropped. + * + * `hold(path)` is for FATE's own saves. While a save is writing, events for that path are parked + * and any read already in flight is discarded (it may have caught the file mid-replace); when + * the hold is released, one read runs. Without it, a reload that raced a save could hand the + * renderer the pre-save text as an "external change". + * + * Free of Electron imports; test/fileWatch.test.cjs drives it against real files. + */ +const fs = require('fs'); + +const fsp = fs.promises; + +const MISSING = new Set(['ENOENT', 'ENOTDIR']); + +/** + * @param {object} options + * @param {(filePath: string) => Promise} options.read read the file (after a burst settles) + * @param {(filePath: string, result: any) => void} options.onRead the newest read's result + * @param {(filePath: string) => void} [options.onDeleted] gone, and stayed gone through every retry + * @param {(filePath: string, err: Error) => void} [options.onError] a read or watch failed for another reason + * @param {(filePath: string) => string} [options.keyOf] map key for a path (case folding on Windows) + * @param {number} [options.debounceMs] + * @param {number} [options.maxWaitMs] + * @param {number[]} [options.retryDelays] + */ +function createFileWatcher(options) { + const { + read, + onRead, + onDeleted = () => {}, + onError = () => {}, + keyOf = (p) => p, + debounceMs = 150, + maxWaitMs = 1000, + retryDelays = [100, 300, 1000, 2000, 5000] + } = options; + + /** key → entry: { path, handle, timer, retryTimer, burstStart, renamed, attempt, holds, pending, seq, closed } */ + const entries = new Map(); + + function closeHandle(entry) { + const handle = entry.handle; + entry.handle = null; + if (!handle) return; + try { + handle.close(); + } catch { + /* already dead */ + } + } + + /** Attach a fresh watcher to whatever is at the path now. Returns null, or the error. */ + function arm(entry) { + closeHandle(entry); + let handle; + try { + handle = fs.watch(entry.path, (eventType) => { + if (entry.handle === handle) onEvent(entry, eventType); + }); + } catch (err) { + return err; + } + handle.on('error', () => { + if (entry.handle === handle) onEvent(entry, 'rename'); + }); + entry.handle = handle; + entry.renamed = false; + return null; + } + + function onEvent(entry, eventType) { + if (entry.closed) return; + if (eventType === 'rename') entry.renamed = true; + schedule(entry); + } + + function schedule(entry) { + if (entry.closed) return; + if (entry.holds > 0) { + entry.pending = true; + return; + } + const now = Date.now(); + if (!entry.burstStart) entry.burstStart = now; + clearTimeout(entry.timer); + const wait = Math.max(0, Math.min(debounceMs, entry.burstStart + maxWaitMs - now)); + entry.timer = setTimeout(() => { + entry.timer = null; + entry.burstStart = 0; + settle(entry); + }, wait); + } + + /** A burst is over: decide what is at the path now. */ + async function settle(entry) { + if (entry.closed) return; + if (entry.holds > 0) { + entry.pending = true; + return; + } + let stat = null; + try { + stat = await fsp.stat(entry.path); + } catch (err) { + if (!MISSING.has(err.code)) { + onError(entry.path, err); + return; + } + } + if (entry.closed) return; + if (entry.holds > 0) { + // A save began while we were looking; whatever we saw may already be stale. + entry.pending = true; + return; + } + if (!stat || !stat.isFile()) { + lost(entry); + return; + } + if (entry.renamed || !entry.handle) { + const err = arm(entry); + if (err && MISSING.has(err.code)) { + lost(entry); + return; + } + if (err) onError(entry.path, err); + } + readNow(entry); + } + + /** The file is not at its path: drop the dead watcher and start looking for it again. */ + function lost(entry) { + closeHandle(entry); + entry.renamed = false; + entry.attempt = 0; + retry(entry); + } + + function retry(entry) { + clearTimeout(entry.retryTimer); + if (entry.attempt >= retryDelays.length) { + entry.retryTimer = null; + onDeleted(entry.path); + return; + } + entry.retryTimer = setTimeout(async () => { + entry.retryTimer = null; + if (entry.closed) return; + if (entry.holds > 0) { + // One of FATE's own saves is (re)creating it; the save re-arms the watcher itself. + entry.pending = true; + return; + } + let stat = null; + try { + stat = await fsp.stat(entry.path); + } catch { + /* still missing */ + } + if (entry.closed || entry.retryTimer || entry.handle) return; // re-armed meanwhile + if (entry.holds > 0) { + entry.pending = true; + return; + } + if (stat && stat.isFile()) { + const err = arm(entry); + if (!err || !MISSING.has(err.code)) { + // Back. (If it can't be watched, e.g. the inotify watch limit, still show what's there.) + if (err) onError(entry.path, err); + entry.attempt = 0; + readNow(entry); + return; + } + } + entry.attempt++; + retry(entry); + }, retryDelays[entry.attempt]); + } + + async function readNow(entry) { + if (entry.holds > 0) { + entry.pending = true; + return; + } + const seq = ++entry.seq; + let result; + try { + result = await read(entry.path); + } catch (err) { + if (entry.closed || seq !== entry.seq) return; + if (MISSING.has(err.code)) lost(entry); + else onError(entry.path, err); + return; + } + // Superseded by a newer read, by one of FATE's own saves, or by the tab closing. + if (entry.closed || seq !== entry.seq) return; + onRead(entry.path, result); + } + + /** + * Watch a path (no-op if it is already watched). `rearm` re-attaches even so: after FATE's + * own atomic save the path holds a new file, which a Linux watcher on the old one won't see. + * A path in the missing state is re-armed on any call. + */ + function watch(filePath, { rearm = false } = {}) { + const key = keyOf(filePath); + let entry = entries.get(key); + if (entry && entry.handle && !rearm) return; + if (!entry) { + entry = { path: filePath, handle: null, timer: null, retryTimer: null, burstStart: 0, renamed: false, attempt: 0, holds: 0, pending: false, seq: 0, closed: false }; + entries.set(key, entry); + } + clearTimeout(entry.retryTimer); + entry.retryTimer = null; + entry.attempt = 0; + const err = arm(entry); + if (err) { + if (MISSING.has(err.code)) lost(entry); + else onError(filePath, err); + } + } + + function unwatch(filePath) { + const key = keyOf(filePath); + const entry = entries.get(key); + if (!entry) return; + entry.closed = true; + clearTimeout(entry.timer); + clearTimeout(entry.retryTimer); + closeHandle(entry); + entries.delete(key); + } + + /** Park events for a path while FATE itself writes it. Returns the release function. */ + function hold(filePath) { + const entry = entries.get(keyOf(filePath)); + if (!entry) return () => {}; + entry.holds++; + entry.seq++; // a read already in flight may have seen the file mid-save: drop it + if (entry.timer) { + clearTimeout(entry.timer); + entry.timer = null; + entry.burstStart = 0; + entry.pending = true; + } + let released = false; + return () => { + if (released) return; + released = true; + entry.holds--; + if (entry.holds === 0 && entry.pending && !entry.closed) { + entry.pending = false; + schedule(entry); + } + }; + } + + function isWatching(filePath) { + const entry = entries.get(keyOf(filePath)); + return !!entry && !!entry.handle; + } + + function closeAll() { + for (const entry of [...entries.values()]) unwatch(entry.path); + } + + return { watch, unwatch, hold, isWatching, closeAll }; +} + +module.exports = { createFileWatcher }; diff --git a/electron/textFiles.cjs b/electron/textFiles.cjs new file mode 100644 index 0000000..74d5369 --- /dev/null +++ b/electron/textFiles.cjs @@ -0,0 +1,229 @@ +'use strict'; +/** + * textFiles.cjs: documents on disk, read and written without blocking the main thread. + * + * Up to 1.13.4 every open, live reload and save was a *Sync call on Electron's main thread, so a + * file on a slow or disconnected network share froze the whole window for the network timeout. + * Everything here is fs.promises. + * + * Saves are ATOMIC. The old writeFileSync truncated the file first and then wrote it, so a full + * disk, a dropped network share or a crash part-way through left the user with a damaged file + * and no way back. writeFileAtomic writes a temporary file beside the target, flushes it to + * disk, and renames it over the original: at every instant the path holds either the old file or + * the complete new one. + * + * Free of Electron imports on purpose; test/textFiles.test.cjs runs it under plain node. + */ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { promisify } = require('util'); +const { decode } = require('./fileFormat.cjs'); + +const fsp = fs.promises; + +/* + * The JS realpath, not fs.promises.realpath (the native one). On Windows the native call turns a + * mapped drive letter into its \\server\share path and has failed outright on some virtual drives; + * this one walks the path with lstat/readlink and leaves drive letters alone. + */ +const realpath = promisify(fs.realpath); + +function codedError(code, message, extra) { + const err = new Error(message); + err.code = code; + return Object.assign(err, extra); +} + +/** + * Read and decode a text file: `{ text, format, bytes }` (see fileFormat.cjs for `format`; + * `bytes` is the raw Buffer, for callers that need to compare it with an encoding of the text). + * + * Throws the fs error as is (ENOENT, EACCES, …) or a coded one: NOT_FILE (a folder, a device, + * a pipe: anything readFile could hang on or that isn't a document), TOO_LARGE (with `size`), + * and decode's BINARY / INVALID. + * + * @param {string} filePath + * @param {{ maxBytes?: number, forcedEncoding?: string|null, defaultEol?: string }} [opts] + */ +async function readTextFile(filePath, { maxBytes = Infinity, forcedEncoding = null, defaultEol } = {}) { + const stat = await fsp.stat(filePath); + if (!stat.isFile()) throw codedError('NOT_FILE', `${path.basename(filePath)} is not a file`); + if (stat.size > maxBytes) throw codedError('TOO_LARGE', `${path.basename(filePath)} is too large`, { size: stat.size }); + const buffer = await fsp.readFile(filePath); + // It may have grown between the stat and the read (a log being written, say). + if (buffer.length > maxBytes) throw codedError('TOO_LARGE', `${path.basename(filePath)} is too large`, { size: buffer.length }); + const { text, format } = decode(buffer, forcedEncoding, { defaultEol }); + return { text, format, bytes: buffer }; +} + +/** + * Errors that mean "this folder or this file will not take a rename", not "the disk is broken". + * Windows refuses to replace a file another program holds open (EBUSY/EPERM) or one marked + * read-only; a folder the user can write files in but not create files in gives EACCES for the + * temporary file; a bind-mounted single file (Docker, Flatpak portals) or a FUSE mount without + * rename support gives EBUSY, EXDEV or ENOTSUP. For all of those the in-place write FATE always + * did is still worth trying. NOT on the list: ENOSPC and EDQUOT, a full disk, where an in-place + * write would truncate the original and then fail half way, which is the very damage the + * atomic path exists to prevent. + */ +const FALLBACK_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EXDEV', 'ENOTSUP', 'ENOSYS']); + +/** + * Write `data` into an existing file, keeping the file itself: same inode, hard links, owner, + * permissions and (on Windows) attributes, ACLs and alternate streams. + * + * 'r+' rather than 'w' on purpose: 'w' opens with CREATE_ALWAYS on Windows, which fails with + * EPERM on a hidden or system file. The length is set after writing, so a write that dies part + * way leaves the old tail rather than an empty file. + */ +async function writeInPlace(filePath, data) { + let handle; + try { + handle = await fsp.open(filePath, 'r+'); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + handle = await fsp.open(filePath, 'w'); // a dangling symlink: create the file it points at + } + try { + await handle.writeFile(data); + await handle.truncate(data.length); + await handle.sync(); + } finally { + await handle.close(); + } +} + +/** + * Where a save should land and what is there now: symlinks resolved, so saving through a link + * updates the file it points at and the link stays a link (renaming over the link itself would + * replace it with a regular file). + */ +async function resolveTarget(filePath) { + let real = filePath; + try { + real = await realpath(filePath); + } catch (err) { + if (err.code === 'ENOENT') { + let link = null; + try { + link = await fsp.lstat(filePath); + } catch { + /* nothing there at all: a new file */ + } + return { path: filePath, stat: null, danglingLink: !!link && link.isSymbolicLink() }; + } + // Anything else is the resolver's problem, not the save's; write to the path as given. + } + const stat = await fsp.stat(real); + if (!stat.isFile()) throw codedError('EISDIR', `${path.basename(filePath)} is not a file`); + return { path: real, stat, danglingLink: false }; +} + +/** + * Save `data` (a Buffer) to `filePath` atomically: temporary file in the same folder, fsync, + * rename over the target. Same folder because a rename is only atomic within one file system. + * + * Kept from the original file: its permission bits and, when FATE can set it, its owner; on + * Windows its mark of the web (the Zone.Identifier stream that makes PowerShell's RemoteSigned + * policy refuse a downloaded script; losing it on save would quietly lift that protection). + * + * Falls back to an in-place write (see writeInPlace) instead when the atomic route would change + * what the file IS or cannot work: + * - the file has other hard links (a rename would split this name off from them); + * - its owner cannot be carried over (a shared file in someone else's name would become ours); + * - the temporary file or the rename is refused (FALLBACK_CODES); + * - the path is a dangling symlink (writing through it creates the target, link intact). + * The temporary file never outlives the call. + * + * @param {string} filePath + * @param {Buffer} data + * @param {{ mode?: number }} [opts] permissions for a NEW file (default 0o666, less the umask) + * @returns {Promise<{ path: string, method: 'atomic' | 'in-place' }>} + */ +async function writeFileAtomic(filePath, data, { mode = 0o666 } = {}) { + const target = await resolveTarget(filePath); + const inPlace = async () => { + await writeInPlace(target.path, data); + return { path: target.path, method: 'in-place' }; + }; + if (target.danglingLink) return inPlace(); + /* + * A rename only needs permission on the FOLDER, so without this check a read-only file in a + * writable folder would be silently replaced, where the old in-place write was refused. A file + * marked read-only (or a git-annex locked one) must stay protected: refuse with EACCES/EPERM. + */ + if (target.stat) await fsp.access(target.path, fs.constants.W_OK); + if (target.stat && target.stat.nlink > 1) return inPlace(); + + const dir = path.dirname(target.path); + // Dot-prefixed so file managers and `ls` don't flash it up during the save. + const tmp = path.join(dir, `.${path.basename(target.path)}.${process.pid}-${crypto.randomBytes(4).toString('hex')}.fate-tmp`); + const posix = process.platform !== 'win32'; + let handle = null; + let tmpExists = false; + try { + try { + handle = await fsp.open(tmp, 'wx', target.stat ? target.stat.mode & 0o777 : mode); + tmpExists = true; + } catch (err) { + if (target.stat && FALLBACK_CODES.has(err.code)) return await inPlace(); + throw err; + } + // Before the write, so a refusal costs nothing. + if (target.stat && posix && (target.stat.uid !== process.getuid() || target.stat.gid !== process.getgid())) { + try { + await handle.chown(target.stat.uid, target.stat.gid); + } catch { + return await inPlace(); + } + } + await handle.writeFile(data); + await handle.sync(); + if (target.stat && posix) { + // open()'s mode is filtered through the umask; set the original bits exactly (after the + // chown, which clears set-id bits). A file system without Unix permissions (FAT, exFAT, + // some network mounts) refuses, and that's fine. + await handle.chmod(target.stat.mode & 0o7777).catch(() => {}); + } + await handle.close(); + handle = null; + + if (target.stat && !posix) { + try { + const zone = await fsp.readFile(`${target.path}:Zone.Identifier`); + await fsp.writeFile(`${tmp}:Zone.Identifier`, zone); + } catch { + /* no mark of the web, or a drive without alternate data streams */ + } + } + + try { + await fsp.rename(tmp, target.path); + tmpExists = false; + } catch (err) { + if (target.stat && FALLBACK_CODES.has(err.code)) return await inPlace(); + throw err; + } + + // Make the rename itself durable (the folder entry), where the platform allows it. + if (posix) { + try { + const dirHandle = await fsp.open(dir, 'r'); + try { + await dirHandle.sync(); + } finally { + await dirHandle.close(); + } + } catch { + /* best effort */ + } + } + return { path: target.path, method: 'atomic' }; + } finally { + if (handle) await handle.close().catch(() => {}); + if (tmpExists) await fsp.unlink(tmp).catch(() => {}); + } +} + +module.exports = { readTextFile, writeFileAtomic }; diff --git a/test/fileFormat.test.cjs b/test/fileFormat.test.cjs new file mode 100644 index 0000000..6ac9b0e --- /dev/null +++ b/test/fileFormat.test.cjs @@ -0,0 +1,207 @@ +'use strict'; +/* Round trips and detection for electron/fileFormat.cjs. Run: node --test test/ */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const ff = require('../electron/fileFormat.cjs'); + +const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]); +const LE_BOM = Buffer.from([0xff, 0xfe]); +const BE_BOM = Buffer.from([0xfe, 0xff]); +const le = (s) => Buffer.from(s, 'utf16le'); +const be = (s) => Buffer.from(s, 'utf16le').swap16(); + +/** decode → encode with the detected format must give back the exact bytes. */ +function assertRoundTrip(bytes, expected) { + const { text, format } = ff.decode(bytes, null, { defaultEol: '\n' }); + if (expected) { + if ('text' in expected) assert.equal(text, expected.text); + for (const key of ['encoding', 'bom', 'eol']) { + if (key in expected) assert.equal(format[key], expected[key], `format.${key}`); + } + } + assert.ok(!text.includes('\r'), 'renderer text never contains \\r'); + const back = ff.encode(text, format); + assert.ok(back.equals(bytes), `round trip changed the bytes:\n in: ${bytes.toString('hex')}\n out: ${back.toString('hex')}`); + return { text, format }; +} + +test('UTF-8 without a BOM, LF', () => { + assertRoundTrip(Buffer.from('héllo\nwörld €\n', 'utf8'), { text: 'héllo\nwörld €\n', encoding: 'utf8', bom: false, eol: '\n' }); +}); + +test('UTF-8 with a BOM, CRLF: BOM stripped from the text and recorded', () => { + const bytes = Buffer.concat([UTF8_BOM, Buffer.from('# Title\r\nbody\r\n', 'utf8')]); + assertRoundTrip(bytes, { text: '# Title\nbody\n', encoding: 'utf8', bom: true, eol: '\r\n' }); +}); + +test('a second BOM after the first is content and survives', () => { + const bytes = Buffer.concat([UTF8_BOM, UTF8_BOM, Buffer.from('x', 'utf8')]); + assertRoundTrip(bytes, { text: 'x', bom: true }); +}); + +test('UTF-16 LE with a BOM (PowerShell 5.1 Out-File), CRLF', () => { + const bytes = Buffer.concat([LE_BOM, le('Get-Process | Out-File x.txt\r\nWrite-Host "é€"\r\n')]); + assertRoundTrip(bytes, { text: 'Get-Process | Out-File x.txt\nWrite-Host "é€"\n', encoding: 'utf16le', bom: true, eol: '\r\n' }); +}); + +test('UTF-16 BE with a BOM', () => { + const bytes = Buffer.concat([BE_BOM, be('line one\nline two ✓\n')]); + assertRoundTrip(bytes, { text: 'line one\nline two ✓\n', encoding: 'utf16be', bom: true, eol: '\n' }); +}); + +test('UTF-16 LE and BE without a BOM are detected by their NUL pattern', () => { + assertRoundTrip(le('[Settings]\r\nName=Value\r\n'), { text: '[Settings]\nName=Value\n', encoding: 'utf16le', bom: false, eol: '\r\n' }); + assertRoundTrip(be('[Settings]\r\nName=Value\r\n'), { text: '[Settings]\nName=Value\n', encoding: 'utf16be', bom: false, eol: '\r\n' }); +}); + +test('UTF-16 with a BOM in a non-Latin script (no NUL pattern needed)', () => { + assertRoundTrip(Buffer.concat([LE_BOM, le('Привет, мир\r\n日本語\r\n')]), { encoding: 'utf16le', bom: true, eol: '\r\n' }); +}); + +test('UTF-16 keeps unpaired surrogates exactly', () => { + const bytes = Buffer.concat([LE_BOM, Buffer.from([0x41, 0x00, 0x00, 0xd8, 0x42, 0x00])]); + const { text } = assertRoundTrip(bytes, { encoding: 'utf16le' }); + assert.equal(text.charCodeAt(1), 0xd800); +}); + +test('Windows-1252: é, €, smart quotes, and the bytes Microsoft left undefined', () => { + // "café €5 “quoted” ‘single’ – dash" in Windows-1252, CRLF. + const bytes = Buffer.from([ + 0x63, 0x61, 0x66, 0xe9, 0x20, 0x80, 0x35, 0x20, 0x93, 0x71, 0x94, 0x20, 0x91, 0x73, 0x92, 0x20, 0x96, 0x0d, 0x0a, + 0x81, 0x8d, 0x8f, 0x90, 0x9d, 0x0d, 0x0a + ]); + const { text } = assertRoundTrip(bytes, { encoding: 'windows1252', bom: false, eol: '\r\n' }); + assert.equal(text.split('\n')[0], 'café €5 “q” ‘s’ –'); +}); + +test('all 256 Windows-1252 bytes (minus NUL) round-trip', () => { + const bytes = Buffer.from(Array.from({ length: 255 }, (_, i) => i + 1).filter((b) => b !== 0x0a && b !== 0x0d)); + assertRoundTrip(bytes, { encoding: 'windows1252' }); +}); + +test('a UTF-8 BOM over bytes that are not UTF-8 is read losslessly as Windows-1252', () => { + const bytes = Buffer.concat([UTF8_BOM, Buffer.from([0x63, 0x61, 0x66, 0xe9])]); + assertRoundTrip(bytes, { text: 'café', encoding: 'windows1252', bom: false }); +}); + +test('line endings: CRLF, LF, CR and mixed', () => { + assertRoundTrip(Buffer.from('a\r\nb\r\nc'), { text: 'a\nb\nc', eol: '\r\n' }); + assertRoundTrip(Buffer.from('a\nb\nc\n'), { text: 'a\nb\nc\n', eol: '\n' }); + assertRoundTrip(Buffer.from('a\rb\rc\r'), { text: 'a\nb\nc\n', eol: '\r' }); + + // Mixed: the majority style wins, and the save writes it consistently. + const mixed = ff.decode(Buffer.from('a\r\nb\r\nc\nd\r\n'), null, { defaultEol: '\n' }); + assert.equal(mixed.text, 'a\nb\nc\nd\n'); + assert.equal(mixed.format.eol, '\r\n'); + assert.equal(ff.encode(mixed.text, mixed.format).toString(), 'a\r\nb\r\nc\r\nd\r\n'); + assert.equal(ff.decode(Buffer.from('a\nb\nc\r\n'), null, { defaultEol: '\r\n' }).format.eol, '\n'); +}); + +test('ties and files without line breaks fall back to the default', () => { + assert.equal(ff.decode(Buffer.from('single line'), null, { defaultEol: '\r\n' }).format.eol, '\r\n'); + assert.equal(ff.decode(Buffer.from('single line'), null, { defaultEol: '\n' }).format.eol, '\n'); + assert.equal(ff.decode(Buffer.from('a\r\nb\nc'), null, { defaultEol: '\n' }).format.eol, '\n'); + assert.equal(ff.decode(Buffer.from('a\r\nb\nc'), null, { defaultEol: '\r\n' }).format.eol, '\r\n'); + assert.equal(ff.decode(Buffer.alloc(0), null, { defaultEol: '\r\n' }).format.eol, '\r\n'); +}); + +test('defaultFormat follows the platform', () => { + assert.deepEqual(ff.defaultFormat('win32'), { encoding: 'utf8', bom: false, eol: '\r\n' }); + assert.deepEqual(ff.defaultFormat('linux'), { encoding: 'utf8', bom: false, eol: '\n' }); +}); + +test('encode converts between formats', () => { + const text = 'é\nü\n'; + assert.deepEqual([...ff.encode(text, { encoding: 'utf8', bom: true, eol: '\r\n' })], [0xef, 0xbb, 0xbf, 0xc3, 0xa9, 0x0d, 0x0a, 0xc3, 0xbc, 0x0d, 0x0a]); + assert.deepEqual([...ff.encode(text, { encoding: 'utf16le', bom: true, eol: '\n' })], [0xff, 0xfe, 0xe9, 0x00, 0x0a, 0x00, 0xfc, 0x00, 0x0a, 0x00]); + assert.deepEqual([...ff.encode(text, { encoding: 'utf16be', bom: false, eol: '\r' })], [0x00, 0xe9, 0x00, 0x0d, 0x00, 0xfc, 0x00, 0x0d]); + assert.deepEqual([...ff.encode(text, { encoding: 'windows1252', bom: true, eol: '\r\n' })], [0xe9, 0x0d, 0x0a, 0xfc, 0x0d, 0x0a]); + // Stray CRs in the input are normalised, not doubled. + assert.equal(ff.encode('a\r\nb\rc', { encoding: 'utf8', bom: false, eol: '\r\n' }).toString(), 'a\r\nb\r\nc'); +}); + +test('Windows-1252 refuses characters it cannot hold, naming the first one and its line', () => { + assert.throws( + () => ff.encode('fine é\nan arrow → here\nand 😀', { encoding: 'windows1252', bom: false, eol: '\r\n' }), + (err) => { + assert.equal(err.code, 'UNENCODABLE'); + assert.equal(err.char, '→'); + assert.equal(err.codePoint, 0x2192); + assert.equal(err.line, 2); + assert.match(err.message, /U\+2192/); + assert.match(err.message, /UTF-8/); + return true; + } + ); + assert.throws(() => ff.encode('😀', { encoding: 'windows1252', bom: false, eol: '\n' }), (err) => err.code === 'UNENCODABLE' && err.char === '😀'); +}); + +test('resolveFormat fills gaps from the base, ignores extra keys, rejects nonsense', () => { + const base = { encoding: 'utf16le', bom: true, eol: '\r\n' }; + assert.deepEqual(ff.resolveFormat({ eol: '\n', indent: 'tabs' }, base), { encoding: 'utf16le', bom: true, eol: '\n' }); + assert.deepEqual(ff.resolveFormat(undefined, base), base); + assert.deepEqual(ff.resolveFormat(null, null), ff.defaultFormat()); + assert.deepEqual(ff.resolveFormat({ encoding: 'windows1252', bom: true }, base), { encoding: 'windows1252', bom: false, eol: '\r\n' }); + for (const bad of ['utf8', [], { encoding: 'latin2' }, { bom: 'yes' }, { eol: '\n\r' }]) { + assert.throws(() => ff.resolveFormat(bad, base), (err) => err.code === 'BAD_FORMAT', JSON.stringify(bad)); + } +}); + +test('binary files are refused; UTF-16 is not mistaken for binary', () => { + assert.throws(() => ff.decode(Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00])), (err) => err.code === 'BINARY'); + assert.throws(() => ff.decode(Buffer.from('\x89PNG\r\n\x1a\n\0\0\0\rIHDR', 'latin1')), (err) => err.code === 'BINARY'); + assert.equal(ff.isProbablyBinary(Buffer.concat([LE_BOM, le('text')]), 'utf16le'), false); + assert.equal(ff.isProbablyBinary(Buffer.concat([LE_BOM, le('text')]), 'utf8'), true); + // UTF-32 LE starts with FF FE 00 00: a UTF-16 BOM followed by a NUL unit, so binary. + assert.throws(() => ff.decode(Buffer.from([0xff, 0xfe, 0x00, 0x00, 0x41, 0x00, 0x00, 0x00])), (err) => err.code === 'BINARY'); +}); + +test('random binary is never taken for UTF-16', () => { + for (let i = 0; i < 200; i++) { + const bytes = crypto.randomBytes(64 + i * 37); + assert.equal(ff.sniffUtf16(bytes), null, `sample ${i}`); + } + // Even with NULs sprinkled into every other byte (a structure, not text), the zero units and + // control characters give it away. + const ints = Buffer.alloc(4096); + for (let i = 0; i < ints.length; i += 2) ints[i] = (i / 2) % 7; // little-endian uint16 0..6 + assert.equal(ff.sniffUtf16(ints), null); + const smallInts = Buffer.alloc(4096); + for (let i = 0; i < smallInts.length; i += 2) smallInts[i] = 1 + ((i / 2) % 9); + assert.equal(ff.sniffUtf16(smallInts), null); + // Odd length cannot be UTF-16 without a BOM. + assert.equal(ff.sniffUtf16(Buffer.concat([le('hello'), Buffer.from([0x41])])), null); +}); + +test('reopen with a forced encoding', () => { + const utf8Bytes = Buffer.from('été', 'latin1'); // valid UTF-8 for "été" + assert.equal(ff.decode(utf8Bytes).text, 'été'); + const forced = ff.decode(utf8Bytes, 'windows1252'); + assert.equal(forced.text, 'été'); + assert.equal(forced.format.encoding, 'windows1252'); + assert.ok(ff.encode(forced.text, forced.format).equals(utf8Bytes)); + + // A BOM is only stripped when it belongs to the forced encoding. + const bomFile = Buffer.concat([UTF8_BOM, Buffer.from('x')]); + assert.deepEqual(ff.decode(bomFile, 'utf8').format.bom, true); + assert.equal(ff.decode(bomFile, 'windows1252').text, 'x'); + + // Lossy forced decodes are refused instead of opened. + assert.throws(() => ff.decode(Buffer.from([0x63, 0xe9]), 'utf8'), (err) => err.code === 'INVALID'); + assert.throws(() => ff.decode(Buffer.from([0x63, 0x64, 0x65]), 'utf16le'), (err) => err.code === 'INVALID'); + assert.throws(() => ff.decode(le('abc'), 'utf8'), (err) => err.code === 'BINARY'); + assert.throws(() => ff.decode(Buffer.from('x'), 'ebcdic'), (err) => err.code === 'BAD_ENCODING'); +}); + +test('a truncated UTF-16 file with a BOM still opens', () => { + const { text } = ff.decode(Buffer.concat([LE_BOM, le('ok'), Buffer.from([0x41])])); + assert.equal(text, 'ok�'); +}); + +test('sameFormat and normalizeText', () => { + assert.ok(ff.sameFormat({ encoding: 'utf8', bom: false, eol: '\n' }, { encoding: 'utf8', bom: false, eol: '\n' })); + assert.ok(!ff.sameFormat({ encoding: 'utf8', bom: false, eol: '\n' }, { encoding: 'utf8', bom: false, eol: '\r\n' })); + assert.ok(!ff.sameFormat(null, { encoding: 'utf8', bom: false, eol: '\n' })); + assert.equal(ff.normalizeText('a\r\nb\rc\n'), 'a\nb\nc\n'); +}); diff --git a/test/fileWatch.test.cjs b/test/fileWatch.test.cjs new file mode 100644 index 0000000..856e804 --- /dev/null +++ b/test/fileWatch.test.cjs @@ -0,0 +1,215 @@ +'use strict'; +/* electron/fileWatch.cjs against real files and real fs.watch events. Run: node --test test/ */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createFileWatcher } = require('../electron/fileWatch.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function waitFor(check, timeout = 3000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (check()) return true; + await sleep(10); + } + return false; +} + +/** A watcher with short timings, recording what it delivers. */ +function setup(t, overrides = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fate-watch-')); + const file = path.join(dir, 'doc.txt'); + fs.writeFileSync(file, 'v0'); + const reads = []; + const deleted = []; + const errors = []; + const watcher = createFileWatcher({ + read: (p) => fs.promises.readFile(p, 'utf8'), + onRead: (p, text) => reads.push(text), + onDeleted: (p) => deleted.push(p), + onError: (p, err) => errors.push(err), + debounceMs: 60, + maxWaitMs: 400, + retryDelays: [40, 80, 160], + ...overrides + }); + t.after(() => { + watcher.closeAll(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + return { dir, file, reads, deleted, errors, watcher }; +} + +test('a write is read once', async (t) => { + const { file, reads, watcher } = setup(t); + watcher.watch(file); + fs.writeFileSync(file, 'v1'); + assert.ok(await waitFor(() => reads.length > 0)); + await sleep(200); + assert.deepEqual(reads, ['v1']); +}); + +test("another editor's atomic save gives ONE read, and the watcher follows the new file", async (t) => { + const { dir, file, reads, watcher } = setup(t); + watcher.watch(file); + const tmp = path.join(dir, '.doc.txt.swp'); + fs.writeFileSync(tmp, 'v1 from another editor'); + fs.renameSync(tmp, file); + assert.ok(await waitFor(() => reads.length > 0)); + await sleep(250); + assert.deepEqual(reads, ['v1 from another editor']); + assert.ok(watcher.isWatching(file)); + + fs.appendFileSync(file, ', then appended'); + assert.ok(await waitFor(() => reads.length > 1), 're-armed on the new inode'); + assert.equal(reads.at(-1), 'v1 from another editor, then appended'); +}); + +test('a burst of writes is coalesced', async (t) => { + const { file, reads, watcher } = setup(t); + watcher.watch(file); + fs.writeFileSync(file, 'a'); + fs.writeFileSync(file, 'ab'); + fs.writeFileSync(file, 'abc'); + assert.ok(await waitFor(() => reads.length > 0)); + await sleep(250); + assert.deepEqual(reads, ['abc']); +}); + +test('a file written continuously still updates (no starvation)', async (t) => { + const { file, reads, watcher } = setup(t); + watcher.watch(file); + let text = ''; + for (let i = 0; i < 25; i++) { + text += `${i}\n`; + fs.writeFileSync(file, text); + await sleep(40); + } + assert.ok(reads.length >= 2, `got ${reads.length} reads during a 1 s stream`); + assert.ok(await waitFor(() => reads.at(-1) === text)); +}); + +test('delete then recreate re-arms the watcher', async (t) => { + const { file, reads, deleted, watcher } = setup(t); + watcher.watch(file); + fs.unlinkSync(file); + await sleep(90); // inside the retry window + fs.writeFileSync(file, 'recreated'); + assert.ok(await waitFor(() => reads.includes('recreated'))); + fs.writeFileSync(file, 'edited after'); + assert.ok(await waitFor(() => reads.includes('edited after')), 'still watching after the recreate'); + assert.deepEqual(deleted, []); +}); + +test('a file that stays deleted is reported once', async (t) => { + const { file, reads, deleted, watcher } = setup(t); + watcher.watch(file); + fs.unlinkSync(file); + assert.ok(await waitFor(() => deleted.length > 0, 2000)); + await sleep(300); + assert.deepEqual(deleted, [file]); + assert.deepEqual(reads, []); + assert.equal(watcher.isWatching(file), false); + + // A later save by FATE (which calls watch again) brings it back. + fs.writeFileSync(file, 'saved again'); + watcher.watch(file); + assert.ok(watcher.isWatching(file)); + fs.writeFileSync(file, 'and edited'); + assert.ok(await waitFor(() => reads.includes('and edited'))); +}); + +test('unwatch stops everything, including pending retries', async (t) => { + const { file, reads, deleted, watcher } = setup(t); + watcher.watch(file); + fs.unlinkSync(file); + await sleep(100); + watcher.unwatch(file); + fs.writeFileSync(file, 'back'); + await sleep(500); + assert.deepEqual(reads, []); + assert.deepEqual(deleted, []); +}); + +test('hold parks events during our own save, then reads once', async (t) => { + const { file, reads, watcher } = setup(t); + watcher.watch(file); + const release = watcher.hold(file); + fs.writeFileSync(file, 'saved by FATE'); + await sleep(250); + assert.deepEqual(reads, [], 'nothing delivered while held'); + watcher.watch(file, { rearm: true }); + release(); + assert.ok(await waitFor(() => reads.length > 0)); + await sleep(200); + assert.deepEqual(reads, ['saved by FATE']); +}); + +test('a read overtaken by a newer one is dropped', async (t) => { + let calls = 0; + const { file, reads, watcher } = setup(t, { + read: async (p) => { + const text = await fs.promises.readFile(p, 'utf8'); + if (calls++ === 0) await sleep(400); // the first read is slow + return text; + } + }); + watcher.watch(file); + fs.writeFileSync(file, 'first'); + await sleep(120); // first read is now in flight + fs.writeFileSync(file, 'second'); + assert.ok(await waitFor(() => reads.length > 0)); + await sleep(500); + assert.deepEqual(reads, ['second'], 'the slow, stale read never lands'); +}); + +test('a read in flight when a save starts is dropped', async (t) => { + let slow = true; + const { file, reads, watcher } = setup(t, { + read: async (p) => { + const text = await fs.promises.readFile(p, 'utf8'); + if (slow) await sleep(300); + return text; + } + }); + watcher.watch(file); + fs.writeFileSync(file, 'external edit'); + await sleep(100); // its read is in flight + slow = false; + const release = watcher.hold(file); + fs.writeFileSync(file, 'saved by FATE'); + release(); + await sleep(600); + assert.ok(!reads.includes('external edit'), `stale read delivered: ${JSON.stringify(reads)}`); +}); + +test('a save that starts while a burst is being checked holds the read back', async (t) => { + const { file, reads, watcher } = setup(t); + watcher.watch(file); + // Slow the stat inside settle down, so the hold lands between the stat and the read. + const realStat = fs.promises.stat; + let release = null; + fs.promises.stat = async (...args) => { + const result = await realStat(...args); + if (!release) { + release = watcher.hold(file); + fs.writeFileSync(file, 'saved by FATE'); + } + return result; + }; + t.after(() => { + fs.promises.stat = realStat; + }); + fs.writeFileSync(file, 'external edit'); + assert.ok(await waitFor(() => release !== null)); + fs.promises.stat = realStat; + await sleep(250); + assert.deepEqual(reads, [], 'nothing read while our save holds the path'); + release(); + assert.ok(await waitFor(() => reads.length > 0)); + await sleep(200); + assert.deepEqual(reads, ['saved by FATE']); +}); diff --git a/test/textFiles.test.cjs b/test/textFiles.test.cjs new file mode 100644 index 0000000..aa28613 --- /dev/null +++ b/test/textFiles.test.cjs @@ -0,0 +1,198 @@ +'use strict'; +/* Atomic saves and async reads in electron/textFiles.cjs. Run: node --test test/ */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { readTextFile, writeFileAtomic } = require('../electron/textFiles.cjs'); + +const posix = process.platform !== 'win32'; + +function tempDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fate-textfiles-')); + t.after(() => { + try { + fs.chmodSync(dir, 0o755); + } catch { + /* already gone */ + } + fs.rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +/** Files in `dir` other than the ones named: leftovers from a save. */ +const strays = (dir, keep) => fs.readdirSync(dir).filter((n) => !keep.includes(n)); + +test('readTextFile decodes and reports the format', async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'a.ps1'); + fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('Write-Host hi\r\n', 'utf16le')])); + const doc = await readTextFile(file, { maxBytes: 1024 }); + assert.equal(doc.text, 'Write-Host hi\n'); + assert.deepEqual(doc.format, { encoding: 'utf16le', bom: true, eol: '\r\n' }); +}); + +test('readTextFile refuses folders, oversized and binary files with coded errors', async (t) => { + const dir = tempDir(t); + await assert.rejects(readTextFile(dir), (err) => err.code === 'NOT_FILE'); + const big = path.join(dir, 'big.txt'); + fs.writeFileSync(big, 'x'.repeat(2048)); + await assert.rejects(readTextFile(big, { maxBytes: 1024 }), (err) => err.code === 'TOO_LARGE' && err.size === 2048); + const bin = path.join(dir, 'a.bin'); + fs.writeFileSync(bin, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00])); + await assert.rejects(readTextFile(bin), (err) => err.code === 'BINARY'); + await assert.rejects(readTextFile(path.join(dir, 'missing.txt')), (err) => err.code === 'ENOENT'); +}); + +test('an atomic save replaces the file and leaves nothing behind', async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'notes.md'); + fs.writeFileSync(file, 'old'); + const before = fs.statSync(file).ino; + const res = await writeFileAtomic(file, Buffer.from('new content')); + assert.equal(res.method, 'atomic'); + assert.equal(fs.readFileSync(file, 'utf8'), 'new content'); + if (posix) assert.notEqual(fs.statSync(file).ino, before, 'a new inode: the rename happened'); + assert.deepEqual(strays(dir, ['notes.md']), []); +}); + +test('a new file is created', async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'new.txt'); + await writeFileAtomic(file, Buffer.from('hello')); + assert.equal(fs.readFileSync(file, 'utf8'), 'hello'); + assert.deepEqual(strays(dir, ['new.txt']), []); +}); + +test('permission bits are preserved', { skip: !posix }, async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'run.sh'); + fs.writeFileSync(file, '#!/bin/sh\n'); + fs.chmodSync(file, 0o750); + await writeFileAtomic(file, Buffer.from('#!/bin/sh\necho hi\n')); + assert.equal(fs.statSync(file).mode & 0o777, 0o750); +}); + +test('new files get the requested mode', { skip: !posix }, async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'secret.json'); + await writeFileAtomic(file, Buffer.from('{}'), { mode: 0o600 }); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); +}); + +test('saving through a symlink updates the target and keeps the link', { skip: !posix }, async (t) => { + const dir = tempDir(t); + fs.mkdirSync(path.join(dir, 'real')); + const target = path.join(dir, 'real', 'config.ini'); + const link = path.join(dir, 'link.ini'); + fs.writeFileSync(target, 'a=1'); + fs.symlinkSync(target, link); + await writeFileAtomic(link, Buffer.from('a=2')); + assert.ok(fs.lstatSync(link).isSymbolicLink(), 'still a symlink'); + assert.equal(fs.readFileSync(target, 'utf8'), 'a=2'); + assert.deepEqual(strays(path.join(dir, 'real'), ['config.ini']), []); +}); + +test('a dangling symlink is written through, not replaced', { skip: !posix }, async (t) => { + const dir = tempDir(t); + const target = path.join(dir, 'gone.txt'); + const link = path.join(dir, 'link.txt'); + fs.symlinkSync(target, link); + const res = await writeFileAtomic(link, Buffer.from('back')); + assert.equal(res.method, 'in-place'); + assert.ok(fs.lstatSync(link).isSymbolicLink()); + assert.equal(fs.readFileSync(target, 'utf8'), 'back'); +}); + +test('hard links are kept by writing in place', { skip: !posix }, async (t) => { + const dir = tempDir(t); + const a = path.join(dir, 'a.txt'); + const b = path.join(dir, 'b.txt'); + fs.writeFileSync(a, 'one'); + fs.linkSync(a, b); + const res = await writeFileAtomic(a, Buffer.from('two, and longer')); + assert.equal(res.method, 'in-place'); + assert.equal(fs.readFileSync(b, 'utf8'), 'two, and longer'); + // Shorter content truncates the old tail. + await writeFileAtomic(a, Buffer.from('3')); + assert.equal(fs.readFileSync(b, 'utf8'), '3'); +}); + +test('a folder that refuses new files falls back to an in-place write', { skip: !posix || process.getuid() === 0 }, async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'locked.txt'); + fs.writeFileSync(file, 'before'); + fs.chmodSync(dir, 0o555); // can write the file, cannot create the temporary one + const res = await writeFileAtomic(file, Buffer.from('after')); + assert.equal(res.method, 'in-place'); + assert.equal(fs.readFileSync(file, 'utf8'), 'after'); + assert.deepEqual(strays(dir, ['locked.txt']), []); +}); + +test('an unwritable file fails cleanly and keeps its content', { skip: !posix || process.getuid() === 0 }, async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'ro.txt'); + fs.writeFileSync(file, 'keep me'); + fs.chmodSync(file, 0o444); + fs.chmodSync(dir, 0o555); + await assert.rejects(writeFileAtomic(file, Buffer.from('lost')), (err) => err.code === 'EACCES'); + assert.equal(fs.readFileSync(file, 'utf8'), 'keep me'); + assert.deepEqual(strays(dir, ['ro.txt']), []); +}); + +test('a read-only file in a writable folder is not replaced', { skip: !posix || process.getuid() === 0 }, async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'protected.txt'); + fs.writeFileSync(file, 'keep me'); + fs.chmodSync(file, 0o444); + await assert.rejects(writeFileAtomic(file, Buffer.from('overwritten')), (err) => err.code === 'EACCES'); + assert.equal(fs.readFileSync(file, 'utf8'), 'keep me'); + assert.equal(fs.statSync(file).mode & 0o777, 0o444); + assert.deepEqual(strays(dir, ['protected.txt']), []); +}); + +test('a failed rename never leaves the temporary file behind', async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'x.txt'); + fs.writeFileSync(file, 'old'); + const original = fs.promises.rename; + fs.promises.rename = async () => { + const err = new Error('simulated: no space left on device'); + err.code = 'ENOSPC'; + throw err; + }; + t.after(() => { + fs.promises.rename = original; + }); + await assert.rejects(writeFileAtomic(file, Buffer.from('new')), (err) => err.code === 'ENOSPC'); + fs.promises.rename = original; + assert.equal(fs.readFileSync(file, 'utf8'), 'old', 'the original is untouched'); + assert.deepEqual(strays(dir, ['x.txt']), []); +}); + +test('a rename refused by a lock falls back to writing in place', async (t) => { + const dir = tempDir(t); + const file = path.join(dir, 'locked-by-another-app.txt'); + fs.writeFileSync(file, 'old'); + const original = fs.promises.rename; + fs.promises.rename = async () => { + const err = new Error('simulated: resource busy or locked'); + err.code = 'EBUSY'; + throw err; + }; + t.after(() => { + fs.promises.rename = original; + }); + const res = await writeFileAtomic(file, Buffer.from('new')); + fs.promises.rename = original; + assert.equal(res.method, 'in-place'); + assert.equal(fs.readFileSync(file, 'utf8'), 'new'); + assert.deepEqual(strays(dir, ['locked-by-another-app.txt']), []); +}); + +test('saving onto a folder is refused', async (t) => { + const dir = tempDir(t); + await assert.rejects(writeFileAtomic(dir, Buffer.from('x')), (err) => err.code === 'EISDIR'); +}); From b7102899a34bd5e59487941c106301e84751ab8a Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 07:19:39 -0700 Subject: [PATCH 05/28] Add hot-exit backup store and renderer settings whitelist; plainer fs errors - backups.cjs: userData/backups/.json written atomically (0700 folder, 0600 files). Ids validated against /^[A-Za-z0-9_-]{1,64}$/, fields type-checked, sizes and count capped, calls per id serialised so a remove can't be overtaken by an in-flight write. list() skips corrupt or foreign files instead of failing; only remove() and clear() delete. - rendererSettings.cjs: the keys store-get/store-set accept from the renderer (today's settings plus spellcheck and remoteImages), each with a cheap type check and a size cap. Main-only keys such as recentFiles and registrationStamp are out of reach. (M14) - fsAccess.cjs: plain wording for common errors (disk full, read-only drive, file in use, ...) so save failures never show Node's raw text or an atomic save's temp path; every result also carries a short `reason` and the snap `advice`, for one-dialog summaries. Co-Authored-By: Claude Opus 5.5 --- electron/backups.cjs | 198 +++++++++++++++++++++++++++++++++ electron/fsAccess.cjs | 60 ++++++++-- electron/rendererSettings.cjs | 80 +++++++++++++ test/backups.test.cjs | 119 ++++++++++++++++++++ test/fsAccess.test.cjs | 52 +++++++++ test/rendererSettings.test.cjs | 69 ++++++++++++ 6 files changed, 570 insertions(+), 8 deletions(-) create mode 100644 electron/backups.cjs create mode 100644 electron/rendererSettings.cjs create mode 100644 test/backups.test.cjs create mode 100644 test/fsAccess.test.cjs create mode 100644 test/rendererSettings.test.cjs diff --git a/electron/backups.cjs b/electron/backups.cjs new file mode 100644 index 0000000..d12e88c --- /dev/null +++ b/electron/backups.cjs @@ -0,0 +1,198 @@ +'use strict'; +/** + * backups.cjs: hot exit. The renderer copies each unsaved buffer (Untitled ones included) here a + * moment after it changes, so a crash, a forced reload or a power cut loses nothing; on the next + * launch it lists what is left and offers it back. + * + * One file per tab: `/.json`, written atomically (textFiles.writeFileAtomic), so a crash + * DURING a backup leaves the previous backup rather than half a file. The folder is created + * owner-only (0700) and the files 0600: a backup of ~/.ssh/config should not become readable by + * other accounts just because FATE copied it. + * + * The renderer is trusted with its own documents but not with the file system, so: + * - `id` must match ID_PATTERN; it becomes a file name, and must never be able to say `../x`. + * - every field is type-checked, and sizes are capped (per string, per file, and in number of + * backups), so a runaway caller cannot fill the disk. + * - `list()` skips anything it cannot parse or that has the wrong shape instead of throwing: + * one corrupt file must never stop the rest from being restored. + * + * Main never deletes a backup on its own; only `remove(id)` (the tab was saved or closed) and + * `clear()` (the session ended cleanly) do. + * + * Calls for the same id run in order (a per-id queue), so a `remove` that arrives while that + * tab's `write` is still on its way to disk cannot be overtaken by it and resurrect the backup. + * + * Free of Electron imports; test/backups.test.cjs runs it under plain node. + */ +const fs = require('fs'); +const path = require('path'); +const { writeFileAtomic } = require('./textFiles.cjs'); +const { resolveFormat } = require('./fileFormat.cjs'); + +const fsp = fs.promises; + +const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; +const FILE_PATTERN = /^([A-Za-z0-9_-]{1,64})\.json$/; +const KINDS = ['markdown', 'code']; + +/** Per text field (content, savedContent), in UTF-16 units. FATE opens files up to 25 MB. */ +const MAX_TEXT_CHARS = 64 * 1024 * 1024; +/** A whole backup file, serialised. Also the most `list()` will read. */ +const MAX_FILE_BYTES = 192 * 1024 * 1024; +/** Open tabs with unsaved work, at most. Far past any real session; a stop for a runaway loop. */ +const MAX_BACKUPS = 100; + +function codedError(code, message) { + const err = new Error(message); + err.code = code; + return err; +} + +function checkId(id) { + if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw codedError('BAD_ID', 'Backup ids are 1-64 letters, digits, _ or -'); +} + +/** + * The renderer's data, checked and reduced to the known fields. Missing optional fields get + * their empty value; a field of the wrong type is an error. An unusable `format` becomes null + * rather than costing the user the backup. + */ +function cleanData(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) throw codedError('BAD_DATA', 'Backup data must be an object'); + const bad = (field) => codedError('BAD_DATA', `Backup field "${field}" is missing or the wrong type`); + const { kind, name, content } = data; + const filePath = data.path ?? null; + const savedContent = data.savedContent ?? null; + const untitled = data.untitled ?? false; + + if (!KINDS.includes(kind)) throw bad('kind'); + if (typeof name !== 'string' || name.length > 1024) throw bad('name'); + if (filePath !== null && (typeof filePath !== 'string' || filePath.length === 0 || filePath.length > 32767)) throw bad('path'); + if (typeof content !== 'string') throw bad('content'); + if (savedContent !== null && typeof savedContent !== 'string') throw bad('savedContent'); + if (typeof untitled !== 'boolean') throw bad('untitled'); + if (data.editMode !== undefined && typeof data.editMode !== 'boolean') throw bad('editMode'); + if (content.length > MAX_TEXT_CHARS || (savedContent && savedContent.length > MAX_TEXT_CHARS)) { + throw codedError('TOO_LARGE', 'This document is too large to back up'); + } + + let format = null; + if (data.format != null) { + try { + format = resolveFormat(data.format, {}); + } catch { + format = null; + } + } + const clean = { kind, name, path: filePath, content, format, savedContent, untitled }; + if (data.editMode !== undefined) clean.editMode = data.editMode; + return clean; +} + +/** + * @param {string} dir the backups folder (main passes userData/backups) + * @param {{ now?: () => number }} [opts] + */ +function createBackupStore(dir, { now = Date.now } = {}) { + const queues = new Map(); + + /** Run `job` after every earlier call for `id`; returns its promise. */ + function enqueue(id, job) { + const previous = queues.get(id) || Promise.resolve(); + const run = previous.then(job, job); + const settled = run.catch(() => {}); + queues.set(id, settled); + settled.then(() => { + if (queues.get(id) === settled) queues.delete(id); + }); + return run; + } + + const fileFor = (id) => path.join(dir, `${id}.json`); + + /** Store (or replace) the backup for `id`. Resolves { savedAt }. */ + async function write(id, data) { + checkId(id); + const clean = cleanData(data); + return enqueue(id, async () => { + await fsp.mkdir(dir, { recursive: true, mode: 0o700 }); + const names = await fsp.readdir(dir); + if (!names.includes(`${id}.json`) && names.filter((n) => FILE_PATTERN.test(n)).length >= MAX_BACKUPS) { + throw codedError('TOO_MANY', 'Too many unsaved documents to back up'); + } + const savedAt = now(); + const json = JSON.stringify({ fate: 'backup', version: 1, savedAt, ...clean }); + const bytes = Buffer.from(json, 'utf8'); + if (bytes.length > MAX_FILE_BYTES) throw codedError('TOO_LARGE', 'This document is too large to back up'); + await writeFileAtomic(fileFor(id), bytes, { mode: 0o600 }); + return { savedAt }; + }); + } + + /** Delete the backup for `id`; a backup that isn't there is not an error. */ + async function remove(id) { + checkId(id); + return enqueue(id, async () => { + try { + await fsp.unlink(fileFor(id)); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + }); + } + + /** Every readable backup: [{ id, savedAt, ...data }], oldest first. */ + async function list() { + await Promise.all([...queues.values()]); + let names; + try { + names = await fsp.readdir(dir); + } catch (err) { + if (err.code === 'ENOENT') return []; + throw err; + } + const backups = []; + for (const name of names) { + const match = FILE_PATTERN.exec(name); + if (!match) continue; // temporary files from an interrupted write, or strangers + try { + const file = path.join(dir, name); + const stat = await fsp.stat(file); + if (!stat.isFile() || stat.size > MAX_FILE_BYTES) continue; + const parsed = JSON.parse(await fsp.readFile(file, 'utf8')); + if (!parsed || parsed.fate !== 'backup') continue; + const savedAt = Number.isFinite(parsed.savedAt) ? parsed.savedAt : stat.mtimeMs; + backups.push({ id: match[1], savedAt, ...cleanData(parsed) }); + } catch { + /* corrupt, half-written by a crash, or the wrong shape: skip it, restore the rest */ + } + } + return backups.sort((a, b) => a.savedAt - b.savedAt); + } + + /** Delete every backup (and any temporary file left by an interrupted write). */ + async function clear() { + await Promise.all([...queues.values()]); + let names; + try { + names = await fsp.readdir(dir); + } catch (err) { + if (err.code === 'ENOENT') return; + throw err; + } + await Promise.all( + names.map(async (name) => { + const file = path.join(dir, name); + try { + if ((await fsp.lstat(file)).isFile()) await fsp.unlink(file); + } catch { + /* gone already */ + } + }) + ); + } + + return { dir, write, remove, list, clear }; +} + +module.exports = { createBackupStore, ID_PATTERN, MAX_BACKUPS }; diff --git a/electron/fsAccess.cjs b/electron/fsAccess.cjs index 2a81912..58af73a 100644 --- a/electron/fsAccess.cjs +++ b/electron/fsAccess.cjs @@ -18,12 +18,40 @@ * was dropped, and `Open with → FATE` from a USB stick did not open anything. * * Free of Electron imports on purpose; it runs under plain node for tests. + * + * Besides `title`/`message` (an error box) and `short` (the status bar), every result carries + * `reason`, a few words without the file name, and `advice`, the snap remedy or null, so a + * summary of several failures (session restore) can list the files once and the remedy once. */ const path = require('path'); const { execFileSync } = require('child_process'); const REMOVABLE_MEDIA_ROOTS = ['/media', '/run/media', '/mnt']; +/** + * Plain words for the errors people actually hit, in place of Node's "ENOSPC: no space left on + * device, write". Also keeps the temporary file of an atomic save (textFiles.cjs) out of the + * message: it is the path in many raw messages, and means nothing to the user. + */ +const PLAIN_REASONS = { + ENOSPC: 'the disk is full', + EDQUOT: 'your disk quota is used up', + EROFS: 'the drive is read-only', + EBUSY: 'another program is using it', + ENOENT: 'it or its folder no longer exists', + ENOTDIR: 'it or its folder no longer exists', + EISDIR: 'it is a folder', + ENAMETOOLONG: 'the path is too long', + EIO: 'the drive reported a read or write error', + ETIMEDOUT: 'the drive did not respond', + EHOSTDOWN: 'the network drive is unreachable', + EHOSTUNREACH: 'the network drive is unreachable', + ENETUNREACH: 'the network drive is unreachable', + ENETDOWN: 'the network drive is unreachable', + EMFILE: 'too many files are open', + ENFILE: 'too many files are open' +}; + /** The part of `filePath` below `root`, or null when it is not inside it. POSIX paths only. */ function relativeWithin(root, filePath) { const rel = path.posix.relative(root, filePath); @@ -46,8 +74,9 @@ function snapPlugConnected(plug) { * @param {string} filePath the path that failed * @param {'open'|'save'} action what FATE was doing * @param {object} [deps] `env` (default process.env) and `isConnected(plug)`, for tests - * @returns {{ title: string, message: string, short: string }} - * `title` and `message` suit dialog.showErrorBox; `short` is a one-liner for the status bar. + * @returns {{ title: string, message: string, short: string, reason: string, advice: string|null }} + * `title` and `message` suit dialog.showErrorBox; `short` is a one-liner for the status bar; + * `reason` and `advice` are the pieces of a multi-file summary (see the note at the top). */ function describeFsError(err, filePath, action = 'open', deps = {}) { const env = deps.env || process.env; @@ -71,7 +100,9 @@ function describeFsError(err, filePath, action = 'open', deps = {}) { `${name} is on a removable drive, and this copy of FATE is a snap. Snaps cannot reach ` + `removable drives until you allow it, once, in a terminal:\n\n ${cmd}\n\n` + `Then ${verb} the file again.`, - short: `Snap cannot reach removable drives. Run: ${cmd}` + short: `Snap cannot reach removable drives. Run: ${cmd}`, + reason: 'on a removable drive the snap cannot reach yet', + advice: `This copy of FATE is a snap. Snaps cannot reach removable drives until you allow it, once, in a terminal:\n\n ${cmd}` }; } @@ -84,7 +115,11 @@ function describeFsError(err, filePath, action = 'open', deps = {}) { `${name} is inside a hidden folder of your home directory (one whose name starts with ` + `a dot). Snap confinement does not let any snap read or write those, and no setting ` + `changes it.\n\nTo ${verb} files like this one, use FATE from the .deb, .rpm or AppImage instead.`, - short: 'Snap confinement blocks hidden files in your home folder' + short: 'Snap confinement blocks hidden files in your home folder', + reason: 'in a hidden folder of your home directory, which snaps cannot reach', + advice: + 'Snap confinement does not let any snap read or write hidden folders of your home ' + + 'directory. For files like these, use FATE from the .deb, .rpm or AppImage instead.' }; } } @@ -94,7 +129,11 @@ function describeFsError(err, filePath, action = 'open', deps = {}) { message: `FATE was not allowed to ${verb} ${name}.\n\n${detail}\n\nThis copy of FATE is a snap, ` + `which can reach your home directory (hidden files excepted) and connected removable drives only.`, - short: `Permission denied: ${name}` + short: `Permission denied: ${name}`, + reason: 'permission denied', + advice: + 'This copy of FATE is a snap, which can reach your home directory (hidden files excepted) ' + + 'and connected removable drives only.' }; } @@ -102,14 +141,19 @@ function describeFsError(err, filePath, action = 'open', deps = {}) { return { title: 'Permission denied', message: `FATE was not allowed to ${verb} ${name}.\n\n${detail}`, - short: `Permission denied: ${name}` + short: `Permission denied: ${name}`, + reason: 'permission denied', + advice: null }; } + const plain = PLAIN_REASONS[code]; return { title: action === 'save' ? 'Could not save file' : 'Could not open file', - message: `${name} could not be ${action === 'save' ? 'saved' : 'opened'}.\n\n${detail}`, - short: detail + message: `${name} could not be ${action === 'save' ? 'saved' : 'opened'}.\n\n${plain ? `${plain[0].toUpperCase()}${plain.slice(1)}.` : detail}`, + short: plain ? `Can't ${verb} ${name}: ${plain}` : detail, + reason: plain || detail, + advice: null }; } diff --git a/electron/rendererSettings.cjs b/electron/rendererSettings.cjs new file mode 100644 index 0000000..93a4616 --- /dev/null +++ b/electron/rendererSettings.cjs @@ -0,0 +1,80 @@ +'use strict'; +/** + * rendererSettings.cjs: the settings the renderer may read and write through store-get/store-set. + * + * Up to 1.13.4 those two handlers passed any key straight to electron-store. That is fine while + * the renderer is trusted, but the renderer displays documents, and one sanitiser bypass would + * have let a document rewrite main-only state: `recentFiles` (whose paths main opens), the + * Windows `registrationStamp` (so the registry self-heal never runs again), or a dotted path + * into any of them, since electron-store reads `a.b` as a nested key. + * + * So the renderer gets exactly the keys below, each with a cheap type check. Main-only keys + * (recentFiles, registrationStamp, claimedTypes, and anything main adds later) are neither + * readable nor writable from the renderer. The checks are deliberately loose about CONTENT: the + * renderer already sanitises every value it reads back (resolveTheme, resolveFonts, + * resolveShortcuts, resolveCustomTheme), so this only keeps out the wrong TYPES and runaway sizes. + * + * ADDING A SETTING: add its key here, or store.set() of it is refused (main logs a warning + * naming the key, and the value is simply not saved). + * + * Free of Electron imports; test/rendererSettings.test.cjs runs it under plain node. + */ + +const isBool = (v) => typeof v === 'boolean'; +const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); +const isString = (max) => (v) => typeof v === 'string' && v.length <= max; +const isNumber = (min, max) => (v) => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max; + +/** { action id: 'Control+Shift+X' } */ +const isShortcutMap = (v) => isPlainObject(v) && Object.values(v).every((s) => typeof s === 'string' && s.length <= 64); + +/** + * The tabs to reopen: { paths: string[], active: string | null }. Other fields are allowed so the + * renderer can keep more per-tab state here without a main-process change. + */ +const isSession = (v) => + isPlainObject(v) && + (v.paths === undefined || (Array.isArray(v.paths) && v.paths.every((p) => typeof p === 'string'))) && + (v.active === undefined || v.active === null || typeof v.active === 'string'); + +const RENDERER_SETTINGS = Object.freeze({ + theme: isString(64), + autoUpdatesEnabled: isBool, + sidebarWidth: isNumber(0, 10000), + shortcuts: isShortcutMap, + printPageSize: isString(32), + printLandscape: isBool, + editorWrap: isBool, + editorTabSize: isNumber(1, 16), + editorLint: isBool, + fonts: isPlainObject, + restoreSession: isBool, + customTheme: (v) => v === null || isPlainObject(v), + session: isSession, + // 1.14.0 + spellcheck: isBool, + remoteImages: isBool +}); + +/** No single setting needs more than this once serialised; a session of hundreds of tabs is ~50 KB. */ +const MAX_VALUE_BYTES = 256 * 1024; + +function isRendererSettingKey(key) { + return typeof key === 'string' && Object.prototype.hasOwnProperty.call(RENDERER_SETTINGS, key); +} + +/** May the renderer store `value` under `key`? Resolves { ok: true } or { ok: false, error }. */ +function checkRendererSetting(key, value) { + if (!isRendererSettingKey(key)) return { ok: false, error: `Unknown setting: ${String(key).slice(0, 64)}` }; + if (!RENDERER_SETTINGS[key](value)) return { ok: false, error: `Wrong type for setting ${key}` }; + let json; + try { + json = JSON.stringify(value); + } catch { + return { ok: false, error: `Setting ${key} cannot be stored` }; + } + if (json.length > MAX_VALUE_BYTES) return { ok: false, error: `Setting ${key} is too large` }; + return { ok: true }; +} + +module.exports = { RENDERER_SETTINGS, isRendererSettingKey, checkRendererSetting }; diff --git a/test/backups.test.cjs b/test/backups.test.cjs new file mode 100644 index 0000000..bf3f479 --- /dev/null +++ b/test/backups.test.cjs @@ -0,0 +1,119 @@ +'use strict'; +/* Hot-exit backups in electron/backups.cjs. Run: node --test test/ */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createBackupStore, MAX_BACKUPS } = require('../electron/backups.cjs'); + +function store(t, opts) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fate-backups-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return createBackupStore(path.join(root, 'backups'), opts); +} + +const doc = (over = {}) => ({ + kind: 'code', + name: 'script.ps1', + path: '/home/me/script.ps1', + content: 'Write-Host "unsaved"\n', + format: { encoding: 'utf16le', bom: true, eol: '\r\n' }, + savedContent: 'Write-Host "saved"\n', + untitled: false, + ...over +}); + +test('write, list, remove round trip', async (t) => { + let clock = 1000; + const backups = store(t, { now: () => clock++ }); + await backups.write('tab-1', doc()); + await backups.write('tab_2', doc({ kind: 'markdown', name: 'Untitled-1', path: null, format: null, savedContent: null, untitled: true, editMode: true })); + const listed = await backups.list(); + assert.deepEqual(listed, [ + { id: 'tab-1', savedAt: 1000, ...doc() }, + { id: 'tab_2', savedAt: 1001, kind: 'markdown', name: 'Untitled-1', path: null, content: 'Write-Host "unsaved"\n', format: null, savedContent: null, untitled: true, editMode: true } + ]); + + await backups.write('tab-1', doc({ content: 'newer' })); + assert.equal((await backups.list()).find((b) => b.id === 'tab-1').content, 'newer'); + + await backups.remove('tab-1'); + await backups.remove('tab-1'); // already gone: fine + assert.deepEqual((await backups.list()).map((b) => b.id), ['tab_2']); +}); + +test('ids that could escape the folder, or are not ids, are refused', async (t) => { + const backups = store(t); + for (const id of ['', '../evil', 'a/b', 'a b', 'x'.repeat(65), 'tab.1', null, 7, '..', 'C:']) { + await assert.rejects(backups.write(id, doc()), (err) => err.code === 'BAD_ID', String(id)); + await assert.rejects(backups.remove(id), (err) => err.code === 'BAD_ID', String(id)); + } + assert.deepEqual(await backups.list(), []); +}); + +test('data of the wrong shape is refused; an unusable format becomes null', async (t) => { + const backups = store(t); + for (const bad of [null, 'text', [], doc({ kind: 'image' }), doc({ content: 42 }), doc({ name: null }), doc({ path: 5 }), doc({ untitled: 'no' }), doc({ editMode: 1 }), doc({ savedContent: {} })]) { + await assert.rejects(backups.write('x', bad), (err) => err.code === 'BAD_DATA', JSON.stringify(bad)); + } + await backups.write('x', doc({ format: { encoding: 'klingon' }, extra: 'dropped' })); + const [entry] = await backups.list(); + assert.equal(entry.format, null); + assert.equal('extra' in entry, false); + // Missing optional fields get their empty values. + await backups.write('y', { kind: 'code', name: 'Untitled-2', content: 'x' }); + const y = (await backups.list()).find((b) => b.id === 'y'); + assert.deepEqual([y.path, y.format, y.savedContent, y.untitled], [null, null, null, false]); +}); + +test('corrupt and foreign files are skipped, never fatal', async (t) => { + const backups = store(t); + await backups.write('good', doc()); + const dir = backups.dir; + fs.writeFileSync(path.join(dir, 'truncated.json'), '{"fate":"backup","version":1,"kind":"co'); + fs.writeFileSync(path.join(dir, 'notjson.json'), '\u0000\u0001binary'); + fs.writeFileSync(path.join(dir, 'wrongshape.json'), JSON.stringify({ fate: 'backup', kind: 'code', name: 3 })); + fs.writeFileSync(path.join(dir, 'stranger.json'), JSON.stringify({ hello: 'world' })); + fs.writeFileSync(path.join(dir, 'null.json'), 'null'); + fs.writeFileSync(path.join(dir, '.good.json.123-abcd.fate-tmp'), 'half a write'); + fs.writeFileSync(path.join(dir, 'README.txt'), 'not a backup'); + fs.mkdirSync(path.join(dir, 'folder.json')); + assert.deepEqual((await backups.list()).map((b) => b.id), ['good']); +}); + +test('list on a fresh profile is empty', async (t) => { + assert.deepEqual(await store(t).list(), []); +}); + +test('clear removes every backup and leftover', async (t) => { + const backups = store(t); + await backups.write('a', doc()); + await backups.write('b', doc()); + fs.writeFileSync(path.join(backups.dir, '.a.json.1-ff.fate-tmp'), 'x'); + await backups.clear(); + assert.deepEqual(fs.readdirSync(backups.dir), []); + await store(t).clear(); // no folder yet: fine +}); + +test('a remove queued behind a write wins', async (t) => { + const backups = store(t); + const writing = backups.write('race', doc()); + const removing = backups.remove('race'); + await Promise.all([writing, removing]); + assert.deepEqual(await backups.list(), []); +}); + +test('backups are private to the user', { skip: process.platform === 'win32' }, async (t) => { + const backups = store(t); + await backups.write('p', doc()); + assert.equal(fs.statSync(backups.dir).mode & 0o777, 0o700); + assert.equal(fs.statSync(path.join(backups.dir, 'p.json')).mode & 0o777, 0o600); +}); + +test('the number of backups is capped', async (t) => { + const backups = store(t); + for (let i = 0; i < MAX_BACKUPS; i++) await backups.write(`t${i}`, doc({ content: '' })); + await assert.rejects(backups.write('one-too-many', doc()), (err) => err.code === 'TOO_MANY'); + await backups.write('t0', doc({ content: 'replacing an existing one is fine' })); +}); diff --git a/test/fsAccess.test.cjs b/test/fsAccess.test.cjs new file mode 100644 index 0000000..059dc85 --- /dev/null +++ b/test/fsAccess.test.cjs @@ -0,0 +1,52 @@ +'use strict'; +/* Error wording in electron/fsAccess.cjs. Run: node --test test/ */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { describeFsError } = require('../electron/fsAccess.cjs'); + +const err = (code, message = `${code}: raw message, open '/x/.notes.md.123-ab.fate-tmp'`) => Object.assign(new Error(message), { code }); +const snapEnv = { SNAP: '/snap/fate/1', SNAP_NAME: 'fate', SNAP_REAL_HOME: '/home/me' }; + +test('common failures are described in plain words, without the temporary file', () => { + const d = describeFsError(err('ENOSPC'), '/home/me/notes.md', 'save'); + assert.equal(d.short, "Can't save notes.md: the disk is full"); + assert.equal(d.reason, 'the disk is full'); + assert.equal(d.advice, null); + assert.match(d.message, /notes\.md could not be saved\.\n\nThe disk is full\./); + assert.doesNotMatch(d.short + d.message, /fate-tmp/); + assert.equal(describeFsError(err('EROFS'), '/mnt/cd/a.txt', 'save').short, "Can't save a.txt: the drive is read-only"); + assert.equal(describeFsError(err('EBUSY'), 'C:\\a.txt', 'save').reason, 'another program is using it'); +}); + +test('unknown errors keep the raw detail', () => { + const d = describeFsError(err('EWEIRD', 'something odd'), '/a/b.txt', 'open'); + assert.equal(d.short, 'something odd'); + assert.equal(d.reason, 'something odd'); +}); + +test('permission denied, outside a snap', () => { + const d = describeFsError(err('EACCES'), '/etc/shadow', 'open', { env: {} }); + assert.equal(d.title, 'Permission denied'); + assert.equal(d.short, 'Permission denied: shadow'); + assert.equal(d.reason, 'permission denied'); + assert.equal(d.advice, null); +}); + +test('snap: removable media not connected gives the connect command as advice', () => { + const d = describeFsError(err('EACCES'), '/media/me/USB/notes.md', 'open', { env: snapEnv, isConnected: () => false }); + assert.equal(d.title, 'Removable drive not connected'); + assert.match(d.advice, /sudo snap connect fate:removable-media/); + assert.doesNotMatch(d.reason, /notes\.md/); +}); + +test('snap: hidden folders in home', () => { + const d = describeFsError(err('EACCES'), '/home/me/.config/app/settings.json', 'save', { env: snapEnv, isConnected: () => true }); + assert.equal(d.title, 'Hidden file not accessible'); + assert.match(d.advice, /\.deb, \.rpm or AppImage/); +}); + +test('snap: other denials', () => { + const d = describeFsError(err('EPERM'), '/opt/x.txt', 'open', { env: snapEnv, isConnected: () => true }); + assert.equal(d.title, 'Permission denied'); + assert.match(d.advice, /is a snap/); +}); diff --git a/test/rendererSettings.test.cjs b/test/rendererSettings.test.cjs new file mode 100644 index 0000000..958e17a --- /dev/null +++ b/test/rendererSettings.test.cjs @@ -0,0 +1,69 @@ +'use strict'; +/* The renderer's settings whitelist in electron/rendererSettings.cjs. Run: node --test test/ */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { checkRendererSetting, isRendererSettingKey } = require('../electron/rendererSettings.cjs'); + +const show = (value) => { + try { + return JSON.stringify(value)?.slice(0, 80); + } catch { + return '(unserialisable)'; + } +}; +const ok = (key, value) => assert.deepEqual(checkRendererSetting(key, value), { ok: true }, `${key} = ${show(value)}`); +const refused = (key, value) => assert.equal(checkRendererSetting(key, value).ok, false, `${key} = ${show(value)}`); + +test('every setting the renderer writes today is accepted', () => { + ok('theme', 'fate'); + ok('theme', 'custom'); + ok('autoUpdatesEnabled', false); + ok('sidebarWidth', 300); + ok('shortcuts', { openFile: 'Control+O', toggleSplit: 'Control+\\' }); + ok('printPageSize', 'A4'); + ok('printLandscape', true); + ok('editorWrap', true); + ok('editorTabSize', 4); + ok('editorLint', false); + ok('fonts', { ui: 'inter', markdown: 'system:Georgia', code: 'jetbrains-mono', markdownSize: 16, editorSize: 14, ligatures: true, perType: { ps1: 'cascadia-code' } }); + ok('restoreSession', true); + ok('customTheme', { base: '#070B1A', accent: '#D4AF37' }); + ok('customTheme', null); + ok('session', { paths: ['/home/me/a.md', 'C:\\Users\\me\\b.ps1'], active: '/home/me/a.md' }); + ok('session', { paths: [], active: null }); + ok('spellcheck', true); + ok('remoteImages', false); +}); + +test('main-only keys and unknown keys are refused', () => { + for (const key of ['recentFiles', 'registrationStamp', 'claimedTypes', 'recentFiles.0.path', 'theme.x', '__proto__', 'constructor', 'toString', '', null, 7]) { + assert.equal(isRendererSettingKey(key), false, String(key)); + refused(key, 'x'); + } +}); + +test('wrong types are refused', () => { + refused('theme', 3); + refused('theme', 'x'.repeat(65)); + refused('autoUpdatesEnabled', 'yes'); + refused('sidebarWidth', NaN); + refused('sidebarWidth', -5); + refused('sidebarWidth', '300'); + refused('shortcuts', ['Control+O']); + refused('shortcuts', { openFile: 5 }); + refused('editorTabSize', 0); + refused('fonts', 'inter'); + refused('customTheme', '#fff'); + refused('session', { paths: '/etc/passwd' }); + refused('session', { paths: [1, 2] }); + refused('session', { paths: [], active: 5 }); + refused('spellcheck', 1); + refused('remoteImages', undefined); +}); + +test('values too large or unserialisable are refused', () => { + refused('session', { paths: Array.from({ length: 5000 }, (_, i) => `/very/long/path/number/${i}/`.repeat(3)) }); + const cyclic = { ui: 'inter' }; + cyclic.self = cyclic; + refused('fonts', cyclic); +}); From 3190a4c4f136746066daab7dc6f72237d0727a1a Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 07:19:50 -0700 Subject: [PATCH 06/28] Main process: keep file formats, save atomically, self-healing live reload Wires the new modules into main.cjs's file I/O regions: - Open, reload and reopen decode via fileFormat: the renderer gets \n text without a BOM plus meta.format; fileFormats (per watchKey) is recorded on open, save and reload and dropped on close-file. A CRLF file rewritten with the same text no longer sends file-changed (the watcher compares a digest of normalised text). (H1, H4) - save-file / save-file-as take (..., content, format?): the passed format, else the recorded one, else the platform default; they resolve with the format written, or { ok: false, code, error } in plain words (UNENCODABLE for Windows-1252). Saves are atomic and async. (H5) - New IPC reopen-with-encoding(path, encoding); the choice sticks for the tab's later reloads. - Live reload via fileWatch: one event per atomic save, deleted files retried then reported with file-deleted, re-armed after our own saves, held while saving so a racing reload can't echo. (M10) - No sync file access on hot paths: opens, reloads, saves and the recents existence checks are async (parallel, 1.5 s cap). Opens are delivered in request order so restored tabs keep their order, with a 2 s cap so one dead network share can't hold the rest back. (M12) - save-file only writes paths FATE has open (any open route, or chosen in Save As); store-get/store-set only take renderer settings keys. (M14) - backup-write/remove/list/clear IPC over backups.cjs (hot exit). - Recents: app.emit('fate-recents-changed', list) on every change, Jump List entries on Windows (cleared with the list), malformed stored entries ignored. - Session restore failures are collected into one summary dialog, keeping the snap guidance, instead of one modal box per file. Co-Authored-By: Claude Opus 5.5 --- electron/main.cjs | 670 +++++++++++++++++++++++++++++++++------------- 1 file changed, 489 insertions(+), 181 deletions(-) diff --git a/electron/main.cjs b/electron/main.cjs index e2d26c7..03be32f 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -38,7 +38,8 @@ const MAX_RECENT_FILES = 8; /* ════════════════════════════════════════════════════════════════════════════════════════════ FILE TYPES The main process reads bytes and watches paths; it does not care what a file *is*. FATE opens - ANY text file. The only gates are size (MAX_FILE_BYTES) and a binary sniff (isProbablyBinary). + ANY text file. The only gates are size (MAX_FILE_BYTES) and a binary sniff (isProbablyBinary, + in fileFormat.cjs). These lists exist for the curated open-dialog filters and for Windows registration (which types the installer advertises FATE for). Up to 1.12.0 they ALSO gated the command line and drag & drop, so "Edit in FATE" on a `.config`, `.properties`, `.reg`, `.csv` or any other @@ -127,15 +128,6 @@ function isOpenableArg(arg) { return true; } -/** - * Cheap binary sniff: a NUL byte in the first 8 KB. Text encodings FATE can read (UTF-8, ASCII) - * never contain NUL; executables, images and archives contain them almost immediately. This is a - * guard against "Open with FATE" on the wrong file, not a general-purpose detector. - */ -function isProbablyBinary(buffer) { - return buffer.subarray(0, 8192).includes(0); -} - /** * The registered application name (Windows Settings, RegisteredApplications) and the home-screen * window title. Renamed from "FATE - Markdown Viewer" in 1.11.0, since the app is a full editor now. @@ -198,18 +190,82 @@ for (const staleKey of []) { RECENT DOCUMENTS ════════════════════════════════════════════════════════════════════════════════════════════ */ +/** + * The stored list, minus anything malformed. Every open records itself here, so one bad entry + * (a hand-edited config.json, or one written before the renderer lost write access to this key) + * must not be able to throw and break opening files. + */ +function storedRecents() { + const entries = store.get('recentFiles'); + return Array.isArray(entries) ? entries.filter((entry) => entry && typeof entry.path === 'string' && entry.path) : []; +} + +/** + * The recents list changed (an open, a Save As, Clear, a dead entry dropped). The application + * menu's Open Recent rebuilds from this event; it carries the stored list, [{ path, openedAt }], + * newest first. + */ +function recentsChanged() { + app.emit('fate-recents-changed', storedRecents()); +} + /** * Record a document in the recents list: newest first, de-duplicated by path, capped. * Stores only the path and a timestamp. The display name and existence check are derived on read, * so a moved or renamed file cannot leave a stale name behind in the store. + * + * On Windows the document also goes into the Recent category of FATE's taskbar Jump List. */ function rememberRecentFile(filePath) { - const existing = store.get('recentFiles') || []; + const existing = storedRecents(); const normalized = path.normalize(filePath); const key = watchKey(filePath); const deduped = existing.filter((entry) => watchKey(entry.path) !== key); deduped.unshift({ path: normalized, openedAt: Date.now() }); store.set('recentFiles', deduped.slice(0, MAX_RECENT_FILES)); + if (process.platform === 'win32') app.addRecentDocument(normalized); + recentsChanged(); +} + +/** Drop one entry whose file is gone, so the list heals itself instead of offering it again. */ +function forgetRecentFile(filePath) { + const existing = storedRecents(); + const key = watchKey(filePath); + const remaining = existing.filter((entry) => watchKey(entry.path) !== key); + if (remaining.length === existing.length) return; + store.set('recentFiles', remaining); + recentsChanged(); +} + +/** Clear the list, and on Windows the Jump List too: whoever clears one expects both gone. */ +function clearRecentFiles() { + store.set('recentFiles', []); + if (process.platform === 'win32') app.clearRecentDocuments(); + recentsChanged(); +} + +/* + * The home screen's existence checks run in parallel and off the main thread. Up to 1.13.4 they + * were fs.existsSync in a loop, so one entry on a disconnected network drive froze the window for + * the whole network timeout. They also stop waiting after RECENT_CHECK_TIMEOUT_MS: an entry with + * no answer by then shows as present, because opening it will say what is wrong, while greying it + * out as "File not found" just because a server is slow would be false. A check still running + * from an earlier visit is reused rather than repeated, so a dead share ties up one libuv thread, + * not one more per visit. + */ +const RECENT_CHECK_TIMEOUT_MS = 1500; +const existenceChecks = new Map(); + +function isExistingFile(filePath) { + let check = existenceChecks.get(filePath); + if (!check) { + check = fs.promises + .stat(filePath) + .then((stat) => stat.isFile(), () => false) + .finally(() => existenceChecks.delete(filePath)); + existenceChecks.set(filePath, check); + } + return check; } /** @@ -217,15 +273,18 @@ function rememberRecentFile(filePath) { * Missing files are returned rather than filtered out so the UI can show them greyed with a reason: * silently dropping an entry looks like the app forgot the file. */ -function readRecentFiles() { - const entries = store.get('recentFiles') || []; - return entries.map((entry) => ({ - path: entry.path, - name: path.basename(entry.path), - dir: path.dirname(entry.path), - openedAt: entry.openedAt, - exists: fs.existsSync(entry.path) - })); +async function readRecentFiles() { + const entries = storedRecents(); + const deadline = new Promise((resolve) => setTimeout(resolve, RECENT_CHECK_TIMEOUT_MS, true)); + return Promise.all( + entries.map(async (entry) => ({ + path: entry.path, + name: path.basename(entry.path), + dir: path.dirname(entry.path), + openedAt: entry.openedAt, + exists: await Promise.race([isExistingFile(entry.path), deadline]) + })) + ); } /* ════════════════════════════════════════════════════════════════════════════════════════════ @@ -993,23 +1052,46 @@ const updateSource = detectUpdateSource(); let mainWindow; /* - * ── Multi-tab file tracking ─────────────────────────────────────────────────────────────────── - * Since tabs (1.10.0) any number of files can be open at once, so watching is per-path: - * - * fileWatchers path → fs.FSWatcher. One live watcher per open tab. - * lastSavedByApp path → content of the last write FATE itself made to that file. Saving from - * the editor fires the same fs.watch 'change' an external edit does; without - * this, every save would bounce back to the renderer as a "file changed on - * disk" event. The watcher compares what it read against this and stays silent - * on a match. + * ── Open documents ──────────────────────────────────────────────────────────────────────────── + * The file I/O lives in small modules beside this one, each free of Electron and tested under + * plain node (test/): fileFormat.cjs (encodings, byte-order marks, line endings), textFiles.cjs + * (async reads, atomic saves), fileWatch.cjs (live reload), backups.cjs (hot exit) and + * rendererSettings.cjs (the settings keys the renderer may touch). Required here, beside the code + * that uses them. + */ +const crypto = require('crypto'); +const { ENCODINGS, ENCODING_LABELS, defaultFormat, encode, resolveFormat, sameFormat, normalizeText } = require('./fileFormat.cjs'); +const { readTextFile, writeFileAtomic } = require('./textFiles.cjs'); +const { createFileWatcher } = require('./fileWatch.cjs'); +const { createBackupStore } = require('./backups.cjs'); +const { isRendererSettingKey, checkRendererSetting } = require('./rendererSettings.cjs'); + +/* + * Since tabs (1.10.0) any number of files can be open at once, so all of this is per path, keyed + * by watchKey(): * - * Paths are normalized+lowercased as map keys (Windows paths are case-insensitive). + * fileFormats path → the file's format on disk, { encoding, bom, eol } (fileFormat.cjs). + * Recorded on open, on every save and on every reload; dropped when the tab + * closes. A save that names no format writes this one, which is what keeps a + * CRLF or UTF-16 file that way. Doubles as the list of paths the renderer may + * save to: see isOpenPath. + * knownText path → digest of the text the renderer last received for the file, or last + * saved to it. Saving fires the same watch events an external edit does, and + * Windows fires them for Defender scans and indexing too; a reload whose text + * matches is not a change and is not sent. (This was lastSavedByApp, which knew + * only FATE's own saves and compared raw text, so a CRLF file reloaded on every + * stray event: its \r\n text never equalled the editor's \n text.) A digest, + * so a 25 MB log is not held a second time. + * forcedEncodings path → the encoding picked with Reopen with encoding. Reloads keep decoding + * with it; Windows-1252 text that happens to be valid UTF-8 would otherwise + * flip back to UTF-8 at the next watch event. */ -const fileWatchers = new Map(); -const lastSavedByApp = new Map(); +const fileFormats = new Map(); +const knownText = new Map(); +const forcedEncodings = new Map(); /** - * The comparable key for a path, used for the watcher map, recents dedupe and tab dedupe. + * The comparable key for a path, used for the per-path maps, recents dedupe and tab dedupe. * Windows and (by default) macOS compare paths case-insensitively; Linux does not, where * `Notes.md` and `notes.md` are two files and folding case would give them one watcher. */ @@ -1019,6 +1101,85 @@ function watchKey(filePath) { return CASE_INSENSITIVE_PATHS ? normalized.toLowerCase() : normalized; } +function textDigest(text) { + return crypto.createHash('sha1').update(text).digest('base64'); +} + +/** + * May the renderer save to (or reopen) this path? Only when FATE opened it, by any route, or the + * user chose it in Save As, and its tab is still open. Up to 1.13.4 save-file wrote wherever it + * was told, which a renderer compromised through a document could have used to drop a script + * into the Startup folder. + */ +function isOpenPath(filePath) { + return typeof filePath === 'string' && filePath !== '' && fileFormats.has(watchKey(filePath)); +} + +/** + * Read a document the way its tab expects: in the user's forced encoding if there is one (back to + * detection if the file no longer decodes that way) and, for a file without a line break, with + * the line ending it already had rather than the platform's. + */ +async function readDocument(filePath) { + const key = watchKey(filePath); + const opts = { maxBytes: MAX_FILE_BYTES, defaultEol: fileFormats.get(key)?.eol }; + const forced = forcedEncodings.get(key); + if (forced) { + try { + return await readTextFile(filePath, { ...opts, forcedEncoding: forced }); + } catch (err) { + if (err.code !== 'INVALID' && err.code !== 'BINARY') throw err; + forcedEncodings.delete(key); + } + } + return readTextFile(filePath, opts); +} + +/** + * The format to record for text just read. Detection is ambiguous in a way that must not flip a + * file's format back and forth: plain ASCII reads as UTF-8 whatever it was saved as. When the + * recorded format still produces exactly these bytes, it stays. + */ +function adoptFormat(key, doc) { + const recorded = fileFormats.get(key); + if (!recorded || sameFormat(recorded, doc.format)) return doc.format; + try { + if (encode(doc.text, recorded).equals(doc.bytes)) return recorded; + } catch { + /* the recorded format cannot even hold this text */ + } + return doc.format; +} + +/** + * Live reload, one watcher per open file (fileWatch.cjs): bursts coalesced, files replaced by + * atomic saves followed, deleted files retried and reported if they stay gone, and only the + * newest read ever delivered. + */ +const fileWatcher = createFileWatcher({ + keyOf: watchKey, + read: readDocument, + onRead: (filePath, doc) => { + const key = watchKey(filePath); + if (!fileFormats.has(key)) return; // the tab closed meanwhile + const format = adoptFormat(key, doc); + const digest = textDigest(doc.text); + // Our own save landing, or a scan or indexer touching the file: not a change. + if (digest === knownText.get(key) && sameFormat(format, fileFormats.get(key))) return; + fileFormats.set(key, format); + knownText.set(key, digest); + /* + * The path rides along so the renderer can route the update to the right tab; the format, + * because it can change on its own (a CRLF → LF conversion arrives with the text unchanged). + */ + if (mainWindow) mainWindow.webContents.send('file-changed', doc.text, filePath, { format }); + }, + onDeleted: (filePath) => { + if (mainWindow && isOpenPath(filePath)) mainWindow.webContents.send('file-deleted', filePath); + }, + onError: (filePath, err) => console.error(`Live reload of ${filePath} failed:`, err.message) +}); + /** * Whether ANY open tab holds unsaved edits. Mirrored over the 'set-edited' channel on every * aggregate transition, so the window-close guard below can ask before discarding them. @@ -1171,129 +1332,208 @@ function createWindow() { } /** - * Start (or keep) a live-reload watcher for a path. One watcher per open tab; closing the tab - * removes it via 'close-file'. Separate from openAndWatchFile because Save As needs to adopt a - * new path WITHOUT re-sending 'open-file' (the renderer already holds the content, and reloading it - * would throw away the cursor and scroll position for no reason). + * Stop tracking a path: its tab closed, or Save As moved the tab elsewhere. The watcher goes, and + * so does the path's place among the files the renderer may save to. */ -function watchFile(filePath) { +function forgetPath(filePath) { + if (typeof filePath !== 'string' || !filePath) return; const key = watchKey(filePath); - if (fileWatchers.has(key)) return; // already watching (e.g. the same file reopened) - - const readAndNotify = () => { - try { - const updatedContent = fs.readFileSync(filePath, 'utf-8'); - // Our own save just landing back on us, not an external change. See lastSavedByApp. - if (updatedContent === lastSavedByApp.get(key)) return; - if (mainWindow) { - // The path rides along so the renderer can route the update to the right tab. - mainWindow.webContents.send('file-changed', updatedContent, filePath); - } - } catch (err) { - console.error('Error reading updated file:', err); - } - }; + fileWatcher.unwatch(filePath); + fileFormats.delete(key); + knownText.delete(key); + forcedEncodings.delete(key); +} - const watcher = fs.watch(filePath, (eventType) => { - if (eventType === 'change') { - readAndNotify(); - } else if (eventType === 'rename') { - /* - * ATOMIC SAVES arrive as 'rename', not 'change': most editors (VS Code among them) write a - * temp file and rename it over the original, which replaces the inode this watcher is bound - * to. Ignoring 'rename' meant edits from such editors never live-reloaded. Re-attach to the - * new inode (after a beat; the rename may still be mid-flight) and read it. - */ - setTimeout(() => { - if (!fileWatchers.has(key)) return; // tab closed in the meantime - if (!fs.existsSync(filePath)) return; // genuinely deleted/moved away - try { - fileWatchers.get(key)?.close(); - } catch { - /* already dead */ - } - fileWatchers.delete(key); - watchFile(filePath); - readAndNotify(); - }, 100); - } - }); +/* + * ── Opening ─────────────────────────────────────────────────────────────────────────────────── + * Every open funnels through openAndWatchFile: the dialog, argv and second instances, and + * open-recent-file (recents, drag and drop, session restore). + * + * Reads run in parallel, but tabs are delivered in the order the opens were asked for. Session + * restore asks for every path at once, and with async reads a small file would otherwise + * overtake a large one and shuffle the user's tabs. The wait for an earlier open is capped at + * OPEN_ORDER_WAIT_MS, so one tab on a dead network share cannot hold every other tab back for + * the whole network timeout; it arrives late, in its own time. + */ +const OPEN_ORDER_WAIT_MS = 2000; +let openDelivery = Promise.resolve(); - fileWatchers.set(key, watcher); +/** + * Open a file in a tab and watch it. Resolves { ok: true }, or { ok: false, reason }: 'missing' + * (gone, or not a file; quiet, as always, since a stale recent or a restored tab whose file was + * deleted is not news) or 'error' (the user has been told why). + */ +function openAndWatchFile(filePath, opts = {}) { + const reading = readDocument(filePath).then((doc) => ({ doc }), (err) => ({ err })); + const turn = Promise.race([openDelivery, new Promise((resolve) => setTimeout(resolve, OPEN_ORDER_WAIT_MS))]); + const delivered = turn + .then(() => reading) + .then(({ doc, err }) => deliverOpenedFile(filePath, opts, doc, err)) + .catch((err) => { + console.error('Error opening file:', err); + return { ok: false, reason: 'error' }; + }); + openDelivery = delivered; + return delivered; } -/** Stop watching a path (its tab closed) and drop its save-suppression record. */ -function unwatchFile(filePath) { - if (!filePath) return; +function deliverOpenedFile(filePath, opts, doc, err) { + if (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR' || err.code === 'NOT_FILE') return { ok: false, reason: 'missing' }; + reportOpenFailure(filePath, err, opts); + return { ok: false, reason: 'error' }; + } + /* + * Since tabs, opening a file ADDS a tab rather than replacing anything, so there is no + * unsaved-changes gate here any more. (If the file is already open, the renderer just + * activates its existing tab.) Dirty buffers are guarded where something is actually + * discarded: closing a tab, and closing the window. + */ const key = watchKey(filePath); - const watcher = fileWatchers.get(key); - if (watcher) { - watcher.close(); - fileWatchers.delete(key); + const format = adoptFormat(key, doc); + fileFormats.set(key, format); + knownText.set(key, textDigest(doc.text)); + rememberRecentFile(filePath); + + if (mainWindow) { + /* + * `fromRestore` rides along so the renderer can tell a tab the user just asked for from one + * it is merely reinstating. Session restore replays every path from last time, and each + * replay used to activate its tab, so double-clicking a file to LAUNCH FATE landed you on + * whichever restored tab happened to arrive last, not on the file you opened. + * `format` is how the file is stored; the text itself is already decoded, BOM-free, `\n`. + */ + mainWindow.webContents.send('open-file', doc.text, path.basename(filePath), filePath, { + fromRestore: !!opts.fromRestore, + format + }); } - lastSavedByApp.delete(key); + fileWatcher.watch(filePath); + return { ok: true }; } -async function openAndWatchFile(filePath, opts = {}) { - let stat; - try { - stat = fs.statSync(filePath); - } catch (err) { - // Gone (a stale recent, or a restored tab whose file was deleted since): stay quiet, as always. - if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return; - // Anything else the user needs to hear. Under snap confinement "permission denied" means an - // interface is not connected, and until 1.13.2 it looked exactly like "no such file". - const { title, message } = describeFsError(err, filePath, 'open'); - dialog.showErrorBox(title, message); +/** Error-box wording for a file that would not open, plus the reason/advice a summary lists. */ +function describeOpenFailure(filePath, err) { + const name = path.basename(filePath); + const limitMb = MAX_FILE_BYTES / 1048576; + if (err.code === 'TOO_LARGE') { + const mb = (err.size / 1048576).toFixed(1); + return { + title: 'File too large', + message: `${name} is ${mb} MB. FATE opens text files up to ${limitMb} MB.`, + reason: `${mb} MB, over the ${limitMb} MB limit`, + advice: null + }; + } + if (err.code === 'BINARY') { + return { + title: 'Not a text file', + message: `${name} appears to be a binary file, which FATE cannot display.`, + reason: 'looks like a binary file', + advice: null + }; + } + // Under snap confinement "permission denied" means an interface is not connected, and until + // 1.13.2 it looked exactly like "no such file"; describeFsError says which. + const { title, message, reason, advice } = describeFsError(err, filePath, 'open'); + return { title, message, reason, advice }; +} + +/* + * Session restore reopens every tab at once, and each one that failed used to raise its own modal + * error box: a session on an unplugged drive meant clicking through a box per file. Failures of + * restored tabs are collected instead and reported in ONE dialog once they stop arriving: each + * file with its reason, then each distinct remedy (such as the snap guidance) once. + */ +const RESTORE_REPORT_DELAY_MS = 600; +const restoreFailures = []; +let restoreReportTimer = null; + +function reportOpenFailure(filePath, err, opts) { + const failure = describeOpenFailure(filePath, err); + if (!opts.fromRestore) { + dialog.showErrorBox(failure.title, failure.message); return; } - if (!stat.isFile()) return; + restoreFailures.push({ name: path.basename(filePath), ...failure }); + clearTimeout(restoreReportTimer); + restoreReportTimer = setTimeout(showRestoreFailures, RESTORE_REPORT_DELAY_MS); +} - try { - /* - * Since tabs, opening a file ADDS a tab rather than replacing anything, so there is no - * unsaved-changes gate here any more. (If the file is already open, the renderer just - * activates its existing tab.) Dirty buffers are guarded where something is actually - * discarded: closing a tab, and closing the window. - */ - if (stat.size > MAX_FILE_BYTES) { - dialog.showErrorBox( - 'File too large', - `${path.basename(filePath)} is ${(stat.size / 1048576).toFixed(1)} MB. ` + - `FATE opens text files up to ${MAX_FILE_BYTES / 1048576} MB.` - ); - return; - } +function showRestoreFailures() { + restoreReportTimer = null; + const failures = restoreFailures.splice(0); + if (failures.length === 0) return; + let options; + if (failures.length === 1) { + const [failure] = failures; + options = { type: 'error', title: failure.title, message: failure.title, detail: failure.message }; + } else { + const remedies = [...new Set(failures.map((f) => f.advice).filter(Boolean))]; + options = { + type: 'warning', + title: 'Some tabs could not be restored', + message: `FATE couldn't reopen ${failures.length} files from your last session.`, + detail: [failures.map((f) => `${f.name}: ${f.reason}`).join('\n'), ...remedies].join('\n\n') + }; + } + const win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; + (win ? dialog.showMessageBox(win, options) : dialog.showMessageBox(options)).catch(() => {}); +} - const buffer = fs.readFileSync(filePath); - if (isProbablyBinary(buffer)) { - dialog.showErrorBox( - 'Not a text file', - `${path.basename(filePath)} appears to be a binary file, which FATE cannot display.` - ); - return; - } +/** + * Open a path the renderer names (a recents entry, a dropped file, a restored session tab); also + * the way in for an Open Recent menu. A missing file is dropped from recents so the list heals + * itself instead of offering it again. Resolves { ok } or { ok: false, reason }. + */ +async function openRecentFile(filePath, opts) { + if (typeof filePath !== 'string' || !filePath) return { ok: false, reason: 'missing' }; + const result = await openAndWatchFile(filePath, { fromRestore: !!(opts && opts.fromRestore) }); + if (result.reason === 'missing') forgetRecentFile(filePath); + return result; +} - const content = buffer.toString('utf-8'); - const name = path.basename(filePath); - lastSavedByApp.delete(watchKey(filePath)); // fresh read, nothing saved from the app yet - rememberRecentFile(filePath); +/* + * ── Saving ──────────────────────────────────────────────────────────────────────────────────── + * The renderer hands over `\n` text and, optionally, the format to write it in. Without one, the + * format the path was opened or last saved with applies (fileFormats), and for a path FATE has + * never read, the platform default, so a CRLF or UTF-16 file goes back the way it came. + */ +const saveFailure = (code, error) => ({ ok: false, code, error }); - if (mainWindow) { - /* - * `fromRestore` rides along so the renderer can tell a tab the user just asked for from one - * it is merely reinstating. Session restore replays every path from last time, and each - * replay used to activate its tab, so double-clicking a file to LAUNCH FATE landed you on - * whichever restored tab happened to arrive last, not on the file you opened. - */ - mainWindow.webContents.send('open-file', content, name, filePath, { fromRestore: !!opts.fromRestore }); - } +/** Settle the format and encode, before anything touches the disk: { ok, format, data } or a failure. */ +function prepareSave(name, content, passedFormat, recordedFormat) { + try { + const format = resolveFormat(passedFormat, recordedFormat || defaultFormat()); + return { ok: true, format, data: encode(content, format) }; + } catch (err) { + // UNENCODABLE's message says what and where: "“→” (U+2192) on line 3 can't be saved in + // Windows-1252. Save the file as UTF-8 instead." + if (err.code === 'UNENCODABLE') return saveFailure(err.code, `${name}: ${err.message}`); + return saveFailure(err.code || 'BAD_FORMAT', `Can't save ${name}: ${err.message}`); + } +} - watchFile(filePath); - } catch (e) { - console.error('Error reading file:', e); - const { title, message } = describeFsError(e, filePath, 'open'); - dialog.showErrorBox(title, message); +/** + * Write encoded bytes atomically (textFiles.cjs) and record what is now on disk. The path's + * watcher is held meanwhile, so neither a reload racing the write nor the write's own events can + * come back to the renderer as an external change; then it is re-armed, because an atomic save + * leaves a new file at the path. + */ +async function commitSave(filePath, prepared, content) { + const key = watchKey(filePath); + const release = fileWatcher.hold(filePath); + try { + await writeFileAtomic(filePath, prepared.data); + fileFormats.set(key, prepared.format); + knownText.set(key, textDigest(normalizeText(content))); + if (forcedEncodings.get(key) !== prepared.format.encoding) forcedEncodings.delete(key); + fileWatcher.watch(filePath, { rearm: true }); + return { ok: true, format: prepared.format }; + } catch (err) { + return saveFailure(err.code || 'EIO', describeFsError(err, filePath, 'save').short); + } finally { + release(); } } @@ -1355,9 +1595,19 @@ app.whenReady().then(() => { documentEdited = !!edited; }); - ipcMain.handle('store-get', (event, key) => store.get(key)); + /* + * Settings: only the keys the renderer owns, each type-checked (rendererSettings.cjs). Main-only + * state such as recentFiles and the Windows registrationStamp is out of the renderer's reach. + */ + ipcMain.handle('store-get', (event, key) => (isRendererSettingKey(key) ? store.get(key) : undefined)); ipcMain.handle('store-set', (event, key, val) => { + const check = checkRendererSetting(key, val); + if (!check.ok) { + console.warn(`store-set refused: ${check.error}`); + return check; + } store.set(key, val); + return { ok: true }; }); ipcMain.handle('open-file-dialog', async () => { @@ -1379,18 +1629,22 @@ app.whenReady().then(() => { } }); - // ── Saving (code editor) ────────────────────────────────────────────────────────────────── - ipcMain.handle('save-file', (event, filePath, content) => { - try { - if (!filePath) return { ok: false, error: 'No file path to save to' }; - // Record BEFORE writing: fs.watch can fire before writeFileSync returns. - lastSavedByApp.set(watchKey(filePath), content); - fs.writeFileSync(filePath, content, 'utf-8'); - return { ok: true }; - } catch (err) { - lastSavedByApp.delete(watchKey(filePath)); - return { ok: false, error: describeFsError(err, filePath, 'save').short }; + // ── Saving ──────────────────────────────────────────────────────────────────────────────── + /** + * Save a tab to its own path, in the format passed or else the path's recorded one. Only paths + * FATE has open are accepted (isOpenPath); anywhere else is refused, and Save As is the way to + * write somewhere new. Resolves { ok: true, format } with the format actually written, or + * { ok: false, code, error }. + */ + ipcMain.handle('save-file', async (event, filePath, content, format) => { + if (typeof filePath !== 'string' || !filePath) return saveFailure('BAD_ARGS', 'No file path to save to'); + if (typeof content !== 'string') return saveFailure('BAD_ARGS', 'Nothing to save'); + const name = path.basename(filePath); + if (!isOpenPath(filePath)) { + return saveFailure('NOT_OPEN', `Can't save ${name}: it isn't a file FATE has open. Use Save As instead.`); } + const prepared = prepareSave(name, content, format, fileFormats.get(watchKey(filePath))); + return prepared.ok ? commitSave(filePath, prepared, content) : prepared; }); /** @@ -1398,13 +1652,22 @@ app.whenReady().then(() => { * the new path without re-sending 'open-file'. The renderer already holds the content, and a * reload would discard the cursor and scroll position. The renderer updates its own name/path * from the response instead. `oldPath` (the tab's previous path, if any) stops being watched. + * + * The format is the one passed, else `oldPath`'s, else the default, and the text is encoded + * BEFORE the dialog opens, so text that Windows-1252 can't hold fails without a wasted dialog. */ - ipcMain.handle('save-file-as', async (event, suggestedName, content, oldPath) => { - let chosenPath = null; + ipcMain.handle('save-file-as', async (event, suggestedName, content, oldPath, format) => { + if (typeof content !== 'string') return saveFailure('BAD_ARGS', 'Nothing to save'); + const suggested = typeof suggestedName === 'string' && suggestedName ? suggestedName : 'untitled.txt'; + const fromPath = isOpenPath(oldPath) ? oldPath : null; + const prepared = prepareSave(suggested, content, format, fromPath && fileFormats.get(watchKey(fromPath))); + if (!prepared.ok) return prepared; + + let filePath; try { - const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { + const result = await dialog.showSaveDialog(mainWindow, { title: 'Save As', - defaultPath: suggestedName || 'untitled.txt', + defaultPath: suggested, // "All files" first: with a curated filter selected, Windows appends that filter's first // extension to any name typed without one, so saving a new buffer as `Dockerfile` or // `.env` produced `Dockerfile.md`. The typed name is now taken literally. @@ -1414,24 +1677,57 @@ app.whenReady().then(() => { { name: 'Code files', extensions: [...CODE_EXTENSIONS] } ] }); - if (canceled || !filePath) return { ok: false, canceled: true }; - chosenPath = filePath; - - lastSavedByApp.set(watchKey(filePath), content); - fs.writeFileSync(filePath, content, 'utf-8'); - if (oldPath && watchKey(oldPath) !== watchKey(filePath)) unwatchFile(oldPath); - rememberRecentFile(filePath); - watchFile(filePath); - return { ok: true, filePath, name: path.basename(filePath) }; + if (result.canceled || !result.filePath) return { ok: false, canceled: true }; + filePath = result.filePath; } catch (err) { - if (chosenPath) lastSavedByApp.delete(watchKey(chosenPath)); - return { ok: false, error: chosenPath ? describeFsError(err, chosenPath, 'save').short : err.message }; + return saveFailure('DIALOG', err.message); } + + const saved = await commitSave(filePath, prepared, content); + if (!saved.ok) return saved; + if (fromPath && watchKey(fromPath) !== watchKey(filePath)) forgetPath(fromPath); + rememberRecentFile(filePath); + return { ok: true, filePath, name: path.basename(filePath), format: saved.format }; }); - /** A tab closed: stop watching its file. */ + /** A tab closed: stop watching its file, and stop accepting saves to it (forgetPath). */ ipcMain.on('close-file', (event, filePath) => { - unwatchFile(filePath); + forgetPath(filePath); + }); + + /** + * Re-read an open file decoding it as `encoding`, for a file whose encoding was guessed wrong. + * Refused rather than opened lossy when that decoding could not be saved back unchanged (see + * fileFormat.decode). The choice sticks for the tab's later reloads (forcedEncodings). The + * watcher is held so a reload already in flight, decoded the old way, cannot land afterwards. + * Resolves { ok: true, content, format } or { ok: false, code, error }. + */ + ipcMain.handle('reopen-with-encoding', async (event, filePath, encoding) => { + if (!isOpenPath(filePath)) return { ok: false, code: 'NOT_OPEN', error: "That file isn't open in FATE" }; + if (!ENCODINGS.includes(encoding)) return { ok: false, code: 'BAD_ENCODING', error: `Unknown encoding: ${String(encoding)}` }; + const key = watchKey(filePath); + const name = path.basename(filePath); + const release = fileWatcher.hold(filePath); + try { + const doc = await readTextFile(filePath, { + maxBytes: MAX_FILE_BYTES, + forcedEncoding: encoding, + defaultEol: fileFormats.get(key)?.eol + }); + if (!isOpenPath(filePath)) return { ok: false, code: 'NOT_OPEN', error: `${name} was closed` }; + fileFormats.set(key, doc.format); + knownText.set(key, textDigest(doc.text)); + forcedEncodings.set(key, encoding); + return { ok: true, content: doc.text, format: doc.format }; + } catch (err) { + if (err.code === 'BINARY' || err.code === 'INVALID') { + return { ok: false, code: err.code, error: `Can't reopen ${name} as ${ENCODING_LABELS[encoding]}: ${err.message}.` }; + } + if (err.code === 'TOO_LARGE') return { ok: false, code: err.code, error: describeOpenFailure(filePath, err).message }; + return { ok: false, code: err.code, error: describeFsError(err, filePath, 'open').short }; + } finally { + release(); + } }); /** @@ -1474,24 +1770,36 @@ app.whenReady().then(() => { // ── Recent documents ────────────────────────────────────────────────────────────────────── ipcMain.handle('get-recent-files', () => readRecentFiles()); - ipcMain.handle('open-recent-file', (event, filePath, opts) => { - // Re-check existence here rather than trusting the renderer's cached `exists` flag; the file - // may have been deleted since the list was rendered. - if (!filePath || !fs.existsSync(filePath)) { - // Drop the dead entry so the list self-heals instead of offering it again. - const remaining = (store.get('recentFiles') || []).filter((e) => watchKey(e.path) !== watchKey(filePath)); - store.set('recentFiles', remaining); - return { ok: false, reason: 'missing' }; - } - openAndWatchFile(filePath, opts || {}); - return { ok: true }; - }); + // Recents, drag and drop and session restore all open through here (see openRecentFile). + ipcMain.handle('open-recent-file', (event, filePath, opts) => openRecentFile(filePath, opts)); ipcMain.handle('clear-recent-files', () => { - store.set('recentFiles', []); + clearRecentFiles(); return { ok: true }; }); + // ── Hot exit ────────────────────────────────────────────────────────────────────────────── + /* + * Unsaved buffers, backed up by the renderer a moment after each change and offered back after + * a crash (backups.cjs owns the file format, the id rules and the size caps). Failures resolve + * { ok: false, code, error } rather than reject; a backup folder that can't be read lists as []. + */ + const backups = createBackupStore(path.join(app.getPath('userData'), 'backups')); + const backupResult = (job) => + job.then( + (value) => ({ ok: true, ...value }), + (err) => ({ ok: false, code: err.code, error: err.message }) + ); + ipcMain.handle('backup-write', (event, id, data) => backupResult(backups.write(id, data))); + ipcMain.handle('backup-remove', (event, id) => backupResult(backups.remove(id))); + ipcMain.handle('backup-clear', () => backupResult(backups.clear())); + ipcMain.handle('backup-list', () => + backups.list().catch((err) => { + console.error('Could not list backups:', err.message); + return []; + }) + ); + // ── Printing & PDF export ───────────────────────────────────────────────────────────────── // Both are wrapped so a render failure surfaces in the UI instead of rejecting into the void. ipcMain.handle('print-preview', async (event, docName) => { From 0ab37bc6bc8d82dcea5f815f04a6a02ee2bd86d9 Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 07:20:59 -0700 Subject: [PATCH 07/28] Move the text digest into fileFormat.cjs Keeps main.cjs free of a module-level `crypto` binding, which would collide with a `const crypto` added by another change to the same file. Co-Authored-By: Claude Opus 5.5 --- electron/fileFormat.cjs | 11 +++++++++++ electron/main.cjs | 7 +------ test/fileFormat.test.cjs | 4 +++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/electron/fileFormat.cjs b/electron/fileFormat.cjs index 4e4d21e..4ec4fd9 100644 --- a/electron/fileFormat.cjs +++ b/electron/fileFormat.cjs @@ -25,6 +25,8 @@ * test/fileFormat.test.cjs. */ +const crypto = require('crypto'); + const ENCODINGS = ['utf8', 'utf16le', 'utf16be', 'windows1252']; const EOLS = ['\n', '\r\n', '\r']; @@ -218,6 +220,14 @@ function normalizeText(text) { return text.indexOf('\r') === -1 ? text : text.replace(/\r\n?/g, '\n'); } +/** + * A short fingerprint of a document's text, for "has the file really changed?" checks without + * holding a second copy of a 25 MB log. Not a security measure; collisions are irrelevant here. + */ +function textDigest(text) { + return crypto.createHash('sha1').update(text).digest('base64'); +} + /** * Decode a file's bytes. * @@ -398,6 +408,7 @@ module.exports = { resolveFormat, sameFormat, normalizeText, + textDigest, isProbablyBinary, sniffUtf16 }; diff --git a/electron/main.cjs b/electron/main.cjs index 03be32f..e49d9d7 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1059,8 +1059,7 @@ let mainWindow; * rendererSettings.cjs (the settings keys the renderer may touch). Required here, beside the code * that uses them. */ -const crypto = require('crypto'); -const { ENCODINGS, ENCODING_LABELS, defaultFormat, encode, resolveFormat, sameFormat, normalizeText } = require('./fileFormat.cjs'); +const { ENCODINGS, ENCODING_LABELS, defaultFormat, encode, resolveFormat, sameFormat, normalizeText, textDigest } = require('./fileFormat.cjs'); const { readTextFile, writeFileAtomic } = require('./textFiles.cjs'); const { createFileWatcher } = require('./fileWatch.cjs'); const { createBackupStore } = require('./backups.cjs'); @@ -1101,10 +1100,6 @@ function watchKey(filePath) { return CASE_INSENSITIVE_PATHS ? normalized.toLowerCase() : normalized; } -function textDigest(text) { - return crypto.createHash('sha1').update(text).digest('base64'); -} - /** * May the renderer save to (or reopen) this path? Only when FATE opened it, by any route, or the * user chose it in Save As, and its tab is still open. Up to 1.13.4 save-file wrote wherever it diff --git a/test/fileFormat.test.cjs b/test/fileFormat.test.cjs index 6ac9b0e..6a248b5 100644 --- a/test/fileFormat.test.cjs +++ b/test/fileFormat.test.cjs @@ -199,7 +199,9 @@ test('a truncated UTF-16 file with a BOM still opens', () => { assert.equal(text, 'ok�'); }); -test('sameFormat and normalizeText', () => { +test('sameFormat, normalizeText and textDigest', () => { + assert.equal(ff.textDigest('a\nb'), ff.textDigest('a\nb')); + assert.notEqual(ff.textDigest('a\nb'), ff.textDigest('a\nc')); assert.ok(ff.sameFormat({ encoding: 'utf8', bom: false, eol: '\n' }, { encoding: 'utf8', bom: false, eol: '\n' })); assert.ok(!ff.sameFormat({ encoding: 'utf8', bom: false, eol: '\n' }, { encoding: 'utf8', bom: false, eol: '\r\n' })); assert.ok(!ff.sameFormat(null, { encoding: 'utf8', bom: false, eol: '\n' })); From 5a89e5f60b30e27aa3c822edc523f2bc5014a5f7 Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 07:23:19 -0700 Subject: [PATCH 08/28] Components: palette modes, accessible dialogs, shortcut helpers, JSON sniffing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command palette - New src/paletteModes.js: createPaletteModes(ctx) returns the ":" (go to line[:col], clamped), "@" (symbols from the Lezer tree, or Markdown headings scanned from the text in a reading view) and "#" (search every open tab, 20 hits per tab, 200 in all) modes. Pure helpers are exported and unit-tested against the real bundled grammars. - CommandPalette takes `modes` and `initialQuery`, shows the active mode as a badge and a quiet ": go to line · @ symbol · # search open tabs" footer on an empty query, and understands disabled/error rows. - Dialog semantics (aria-modal), combobox + listbox with aria-activedescendant/aria-selected, focus trap and focus return. Settings - initialSection prop; a new Markdown page with the spell check and remote images switches (with the Linux dictionary note and the privacy note). - Live bindings everywhere ("New files (Ctrl+T)", not a hard-coded Ctrl+N). - Sidebar width commits on blur/Enter only, clamped to 200-600; Escape reverts; junk input is ignored. - Real modal: role/aria-modal/aria-labelledby, focus in on open, Tab trapped, focus back to the editor on close, Escape handled by the dialog (it no longer closes a diff behind Settings first), background inert. Every switch, select, slider and field is named by its label. - The shortcut recorder stops when Settings closes; Escape cancels it. Shared - src/dialogFocus.js: the modal focus hook both dialogs use. - settingsMeta.js: parseShortcut, normalizeBinding, bindingFromEvent, matchesShortcut, isAllowedShortcut, shortcutParts, formatShortcutLabel, toElectronAccelerator, clampSidebarWidth. The + key is stored as "Plus" (legacy "Control++" parses and is migrated by resolveShortcuts, which also drops bindings that would fire while typing). - languageDetect.js: logs starting "[2026-10-04 …]" or "[INFO]" are no longer sniffed as JSON; isJsonWithComments(fileName) for the linter; JSONC names the registry doesn't know open as JSON. DiffView and Starfield - DiffView: leftFileName/rightFileName pick each side's language. It also called EditorState.appendConfig (undefined), so no diff was ever highlighted; now StateEffect.appendConfig. - Starfield pauses on window blur as well as when hidden, draws at most 30 fps with time-based motion, and builds its gradients and the meteor sprite once per build instead of every frame. Co-Authored-By: Claude Opus 5.5 --- src/App.css | 63 +++ src/components/CommandPalette.jsx | 145 ++++-- src/components/DiffView.jsx | 38 +- src/components/SettingsModal.jsx | 475 ++++++++++++++------ src/components/Starfield.jsx | 183 +++++--- src/dialogFocus.js | 148 +++++++ src/languageDetect.js | 68 ++- src/paletteModes.js | 710 ++++++++++++++++++++++++++++++ src/settingsMeta.js | 241 +++++++++- test/languageDetect.test.mjs | 101 +++++ test/paletteModes.test.mjs | 519 ++++++++++++++++++++++ test/settingsMeta.test.mjs | 169 +++++++ 12 files changed, 2630 insertions(+), 230 deletions(-) create mode 100644 src/dialogFocus.js create mode 100644 src/paletteModes.js create mode 100644 test/languageDetect.test.mjs create mode 100644 test/paletteModes.test.mjs create mode 100644 test/settingsMeta.test.mjs diff --git a/src/App.css b/src/App.css index ddafe0f..cfd0f78 100644 --- a/src/App.css +++ b/src/App.css @@ -2513,6 +2513,69 @@ kbd { font-size: 0.85rem; } +/* ── 1.14.0: palette modes, dialog focus, shortcut recording ───────────────────────────────── + Palette: the badge naming the active mode (":" Go to line, "@" Go to symbol, "#" Search open + tabs), the rows a mode shows as hints or input errors, and the quiet footer that lists the + prefixes while the query is empty. Settings and the palette now take focus themselves + (src/dialogFocus.js); the dialog box only holds it after a click on empty space, so it gets + no focus ring. The shortcut recording hint is an always-present live region, empty (and + taking no space) until a binding is being recorded. */ +.palette:focus, +.settings-modal:focus { + outline: none; +} + +.palette-mode { + flex-shrink: 0; + padding: 0.12rem 0.5rem; + border-radius: var(--radius-pill); + background: var(--accent-subtle); + color: var(--text-accent); + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; +} + +.palette-item.disabled { + cursor: default; + color: var(--text-muted); +} + +.palette-item.disabled.selected { + background: var(--surface-highest); + color: var(--text-muted); +} + +.palette-item.error, +.palette-item.error .palette-icon { + color: var(--status-danger); +} + +.palette-hint { + flex-shrink: 0; + padding: 0.45rem 0.9rem; + border-top: 1px solid var(--border-subtle); + font-size: 0.72rem; + line-height: 1.6; + color: var(--text-faint); +} + +.palette-hint kbd { + margin-right: 0.1rem; +} + +.shortcut-recording-hint { + color: var(--text-accent); +} + +.shortcut-recording-hint:empty { + margin: 0; +} + +.shortcuts-head + .shortcut-recording-hint { + margin-top: 0.75rem; +} + /* ── Split view ──────────────────────────────────────────────────────────────────────────── */ /* Layout is CSS-only so the panes never re-parent, because re-parenting would remount the editors and lose their state. Active pane left (order 0), split pane right (order 1). */ diff --git a/src/components/CommandPalette.jsx b/src/components/CommandPalette.jsx index b6d02d4..32107ad 100644 --- a/src/components/CommandPalette.jsx +++ b/src/components/CommandPalette.jsx @@ -1,5 +1,7 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { Fragment, useState, useEffect, useRef, useMemo, useId } from 'react'; import { MagnifyingGlass } from '@phosphor-icons/react'; +import { scoreMatch } from '../paletteModes.js'; +import { useDialogFocus } from '../dialogFocus.js'; /** * CommandPalette: Ctrl+K. One fuzzy search over everything: open tabs, recent files, commands, @@ -8,45 +10,73 @@ import { MagnifyingGlass } from '@phosphor-icons/react'; * * Matching is subsequence-based with a small scorer (consecutive hits and word starts count * extra), which is the whole of what a palette needs, with no fuzzy-search dependency for one loop. + * The scorer lives in paletteModes.js, shared with the symbol filter. + * + * ── Modes (1.14.0) ─────────────────────────────────────────────────────────────────────────── + * `modes` maps a prefix to a mode (see paletteModes.js): a query starting with ":" goes to a + * line, "@" lists the document's symbols, "#" searches every open tab. The mode gets the query + * without its prefix and returns its own ranked rows, shown as they are. `initialQuery` opens the + * palette already in a mode (the Go to line and Go to symbol menu items pass ":" and "@"). + * + * ── Accessibility ──────────────────────────────────────────────────────────────────────────── + * A modal dialog (focus moves in, Tab can't leave, focus goes back to the editor on close; see + * dialogFocus.js) around an ARIA combobox: the input owns a listbox and points at the highlighted + * row with aria-activedescendant, so a screen reader follows the arrow keys while focus stays in + * the input, which is what lets typing carry on at any point. */ -function scoreMatch(query, text) { - const q = query.toLowerCase(); - const t = text.toLowerCase(); - if (!q) return 1; - let qi = 0; - let score = 0; - let streak = 0; - for (let ti = 0; ti < t.length && qi < q.length; ti++) { - if (t[ti] === q[qi]) { - qi++; - streak++; - score += 1 + streak; // consecutive matches compound - if (ti === 0 || t[ti - 1] === ' ' || t[ti - 1] === '.') score += 4; // word starts matter - } else { - streak = 0; - } +/** The mode prefix the query starts with (longest first), or null. */ +function modePrefixOf(modes, query) { + if (!modes || !query) return null; + let best = null; + for (const prefix of Object.keys(modes)) { + if (query.startsWith(prefix) && (best === null || prefix.length > best.length)) best = prefix; } - return qi === q.length ? score : 0; + return best; } -function CommandPalette({ items, onClose }) { - const [query, setQuery] = useState(''); +function CommandPalette({ items, onClose, modes = null, initialQuery = '' }) { + const [query, setQuery] = useState(initialQuery); const [selected, setSelected] = useState(0); + const dialogRef = useRef(null); const inputRef = useRef(null); const listRef = useRef(null); + const baseId = useId(); + const listId = `${baseId}-list`; + const hintId = `${baseId}-hint`; + const optionId = (i) => `${baseId}-opt-${i}`; + + /* + * A new initialQuery while open (Go to symbol chosen from the menu with the palette already up) + * replaces the query. Render-time adjustment, not an effect, like the selection reset below. + */ + const [lastInitialQuery, setLastInitialQuery] = useState(initialQuery); + if (lastInitialQuery !== initialQuery) { + setLastInitialQuery(initialQuery); + setQuery(initialQuery); + } + + const prefix = modePrefixOf(modes, query); + const mode = prefix === null ? null : modes[prefix]; const results = useMemo(() => { + if (mode) return mode.getItems(query.slice(prefix.length)) || []; const scored = items .map((item) => ({ item, score: scoreMatch(query, `${item.section} ${item.label}`) })) .filter((r) => r.score > 0) .sort((a, b) => b.score - a.score); return scored.slice(0, 40).map((r) => r.item); - }, [items, query]); + }, [items, query, mode, prefix]); - useEffect(() => { - inputRef.current?.focus(); - }, []); + useDialogFocus(dialogRef, { + // The caret goes to the end, so an initial ":" or "@" is ready to be typed after. + initialFocus: () => { + const input = inputRef.current; + input?.setSelectionRange(input.value.length, input.value.length); + return input; + }, + onEscape: onClose + }); /* Reset the selection when the query changes (render-time adjustment, not an effect). */ const [lastQuery, setLastQuery] = useState(query); @@ -54,13 +84,15 @@ function CommandPalette({ items, onClose }) { setLastQuery(query); setSelected(0); } + const active = Math.min(selected, results.length - 1); useEffect(() => { - const el = listRef.current?.children[selected]; + const el = listRef.current?.children[active]; el?.scrollIntoView({ block: 'nearest' }); - }, [selected, results]); + }, [active, results]); const run = (item) => { + if (!item || item.disabled) return; onClose(); // After the overlay unmounts, so a command that opens a dialog isn't fighting the palette. setTimeout(() => item.run(), 0); @@ -75,7 +107,7 @@ function CommandPalette({ items, onClose }) { setSelected((s) => Math.max(0, s - 1)); } else if (e.key === 'Enter') { e.preventDefault(); - if (results[selected]) run(results[selected]); + run(results[active]); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); @@ -83,11 +115,27 @@ function CommandPalette({ items, onClose }) { } }; + const showHints = !!modes && query === ''; + return (
-
e.stopPropagation()} role="dialog" aria-label="Command palette"> +
e.stopPropagation()} + /* Clicks on rows and padding keep focus in the input, so typing and the arrow keys carry + on working after a mouse move or a stray click. */ + onMouseDown={(e) => { + if (e.target !== inputRef.current) e.preventDefault(); + }} + >
+ {mode && {mode.label}} setQuery(e.target.value)} onKeyDown={onKeyDown} spellCheck={false} + role="combobox" + aria-label={mode ? mode.label : 'Search tabs, files, commands and themes'} + aria-expanded="true" + aria-controls={listId} + aria-autocomplete="list" + aria-activedescendant={active >= 0 ? optionId(active) : undefined} + aria-describedby={showHints ? hintId : undefined} /> Esc
-
    - {results.length === 0 &&
  • No matches
  • } +
      {results.map((item, i) => { const Icon = item.icon; + const classes = ['palette-item']; + if (i === active) classes.push('selected'); + if (item.disabled) classes.push('disabled'); + if (item.error) classes.push('error'); return (
    • setSelected(i)} onClick={() => run(item)} > {Icon && } {item.label} - {item.section} + {(item.detail ?? item.section) && ( + {item.detail ?? item.section} + )}
    • ); })}
    + {results.length === 0 && ( +
    + {mode ? mode.placeholder : 'No matches'} +
    + )} + + {/* The quiet footer: ": go to line · @ symbol · # search open tabs". */} + {showHints && ( +
    + {Object.entries(modes).map(([p, m], i) => ( + + {i > 0 && ' · '} + {p} {m.hint ?? m.label} + + ))} +
    + )}
); diff --git a/src/components/DiffView.jsx b/src/components/DiffView.jsx index 98c9cf2..a43e971 100644 --- a/src/components/DiffView.jsx +++ b/src/components/DiffView.jsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import { MergeView } from '@codemirror/merge'; import { EditorView, lineNumbers, highlightSpecialChars } from '@codemirror/view'; -import { EditorState } from '@codemirror/state'; +import { EditorState, StateEffect } from '@codemirror/state'; import { syntaxHighlighting } from '@codemirror/language'; import { tokenHighlightStyle } from '../editorTheme.js'; import { detectLanguage } from '../languageDetect.js'; @@ -16,8 +16,14 @@ import { detectLanguage } from '../languageDetect.js'; * * Colours: the merge chunk backgrounds are styled in App.css from the status/accent tokens, and * syntax highlighting reuses the shared token HighlightStyle, so a diff looks native in any theme. + * + * Names: `leftName`/`rightName` are the column titles, which can be decorated ("app.js (saved)" + * for a diff of unsaved changes). `leftFileName`/`rightFileName` are the real file names, used to + * pick each side's language; they default to the titles. Up to 1.13 the titles were all there + * was, and "app.js (saved)" matched no language. Each side's text rides along too, so an + * extensionless file can still be sniffed (see languageDetect). */ -function DiffView({ leftText, rightText, leftName, rightName }) { +function DiffView({ leftText, rightText, leftName, rightName, leftFileName, rightFileName }) { const hostRef = useRef(null); useEffect(() => { @@ -42,28 +48,30 @@ function DiffView({ leftText, rightText, leftName, rightName }) { /* * Language support is loaded async per side (it may be a lazy chunk); dispatched into the * merge editors when it lands. appendConfig avoids rebuilding the MergeView. + * + * It is StateEffect.appendConfig. Up to 1.13 this called EditorState.appendConfig, which + * doesn't exist: the dispatch threw inside the promise (an unhandled rejection nobody saw) and + * no diff, split or unsaved, was ever highlighted. */ let cancelled = false; - const loadLang = (name, editor) => { - const desc = detectLanguage(name); + const loadLang = (name, text, editor) => { + const desc = detectLanguage(name, text); if (!desc) return; - desc.load().then( - (support) => { - if (!cancelled) { - editor.dispatch({ effects: EditorState.appendConfig.of(support) }); - } - }, - () => {} - ); + desc + .load() + .then((support) => { + if (!cancelled) editor.dispatch({ effects: StateEffect.appendConfig.of(support) }); + }) + .catch((err) => console.error(`Failed to load language ${desc.name} for the diff:`, err)); }; - loadLang(leftName, view.a); - loadLang(rightName, view.b); + loadLang(leftFileName || leftName, leftText, view.a); + loadLang(rightFileName || rightName, rightText, view.b); return () => { cancelled = true; view.destroy(); }; - }, [leftText, rightText, leftName, rightName]); + }, [leftText, rightText, leftName, rightName, leftFileName, rightFileName]); return (
diff --git a/src/components/SettingsModal.jsx b/src/components/SettingsModal.jsx index bc0ca21..4c6a557 100644 --- a/src/components/SettingsModal.jsx +++ b/src/components/SettingsModal.jsx @@ -1,14 +1,19 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useId } from 'react'; import { X, Palette, TextAa, Code, Printer, WindowsLogo, Keyboard, Info, CaretDown, Check, Trash, ArrowSquareOut, CheckCircle, Plus, Copy, ArrowCounterClockwise, - Warning + Warning, MarkdownLogo } from '@phosphor-icons/react'; import fateLogo from '../assets/FATE-Square-Icon.png'; -import { THEMES, PAGE_SIZES, SHORTCUT_ACTIONS, DEFAULT_SHORTCUTS, FIXED_SHORTCUTS } from '../settingsMeta.js'; +import { + THEMES, PAGE_SIZES, SHORTCUT_ACTIONS, DEFAULT_SHORTCUTS, FIXED_SHORTCUTS, + shortcutParts, formatShortcutLabel, normalizeBinding, + clampSidebarWidth, SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX +} from '../settingsMeta.js'; import { PROSE_FONTS, CODE_FONTS, fontById, systemFontEntry } from '../fonts.js'; import { CODE_EXTENSIONS } from '../fileKinds.js'; import { DEFAULT_CUSTOM, CUSTOM_FIELDS, customThemeCss } from '../themeCustom.js'; +import { useDialogFocus } from '../dialogFocus.js'; /** * SettingsModal: navigation rail + content pane (1.10.0 redesign, extended in 1.11.0 with the @@ -21,6 +26,19 @@ import { DEFAULT_CUSTOM, CUSTOM_FIELDS, customThemeCss } from '../themeCustom.js * - FontPicker is a custom listbox because a native onChange(parseInt(e.target.value, 10))} /> {value}{suffix} @@ -179,16 +207,90 @@ function SizeSlider({ label, value, min, max, suffix = 'px', onChange }) { ); } -/** Pretty-print a stored binding ("Control+Shift+S" → chips). */ +/** + * The toggle switch, named by its setting's label (`labelledBy`, an element id) and optionally + * described by its hint. role="switch" so it is announced as on/off rather than as a checkbox. + */ +function Switch({ checked, onChange, labelledBy, describedBy, disabled }) { + return ( + + ); +} + +/** + * Pretty-print a stored binding ("Control+Shift+S" → chips). Through shortcutParts, which knows + * the stored format: splitting on "+" here broke on a binding of the + key itself. + */ function BindingChips({ binding }) { - const parts = (binding || '').split('+').map((p) => (p === 'Control' ? 'Ctrl' : p)); return ( - {parts.map((p, i) => {p})} + {shortcutParts(binding).map((p, i) => {p})} ); } +/** A live binding inside running text: Ctrl+T. */ +function InlineBinding({ binding }) { + return shortcutParts(binding).map((p, i) => ( + {i > 0 && '+'}{p} + )); +} + +/** + * Default sidebar width. Up to 1.13 every keystroke saved straight to the store with no bounds, + * so typing "450" saved 4, then 45, then 450, and the contents sidebar of every open document + * jumped to 4 px wide on the way. Now the field keeps a draft while you type and commits on blur + * or Enter, clamped to 200–600; Escape puts the saved value back. Input that isn't a number + * (an empty field, a lone "-") is ignored. + */ +function SidebarWidthField({ value, onCommit, labelledBy, describedBy }) { + const [draft, setDraft] = useState(null); + + const commit = () => { + if (draft === null) return; + setDraft(null); + const n = parseInt(draft, 10); + if (!Number.isFinite(n)) return; + const width = clampSidebarWidth(n); + if (width !== value) onCommit(width); + }; + + return ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commit(); + } else if (e.key === 'Escape' && draft !== null) { + // First Escape reverts the edit; the next one closes Settings. + e.preventDefault(); + setDraft(null); + } + }} + /> + ); +} + function SettingsModal({ onClose, settings, @@ -200,9 +302,19 @@ function SettingsModal({ setActiveShortcutRebind, onSidebarWidthChange, runtimeInfo, - systemFonts + systemFonts, + initialSection = 'appearance' }) { - const [section, setSection] = useState('appearance'); + const [section, setSection] = useState(initialSection || 'appearance'); + /* + * A new initialSection while open (About chosen from the menu with Settings already up) moves + * to that page. Render-time adjustment rather than an effect, like CommandPalette's query. + */ + const [lastInitialSection, setLastInitialSection] = useState(initialSection); + if (lastInitialSection !== initialSection) { + setLastInitialSection(initialSection); + if (initialSection) setSection(initialSection); + } const [coverage, setCoverage] = useState(null); const [repairBusy, setRepairBusy] = useState(false); const [repairResult, setRepairResult] = useState(null); @@ -220,11 +332,47 @@ function SettingsModal({ { id: 'appearance', label: 'Appearance', icon: Palette }, { id: 'fonts', label: 'Fonts', icon: TextAa }, { id: 'editor', label: 'Code Editor', icon: Code }, + { id: 'markdown', label: 'Markdown', icon: MarkdownLogo }, { id: 'printing', label: 'Printing', icon: Printer }, ...(isWindows ? [{ id: 'windows', label: 'Windows', icon: WindowsLogo }] : []), { id: 'shortcuts', label: 'Shortcuts', icon: Keyboard }, { id: 'about', label: 'About', icon: Info } ]; + // An unknown or unavailable page (Windows, off Windows) opens Appearance instead. + const current = sections.some((s) => s.id === section) ? section : 'appearance'; + + /* + * Ids for aria-labelledby/aria-describedby: each control is named by its setting's visible + * label rather than by a second, hidden copy of the text. + */ + const uid = useId(); + const idFor = (key) => `${uid}-${key}`; + + const platform = window.electronAPI?.platform; + // Until App stores them, fall back to the 1.14.0 defaults (see the settings contract). + const spellcheckOn = typeof settings.spellcheck === 'boolean' ? settings.spellcheck : platform === 'win32'; + const remoteImagesOn = settings.remoteImages === true; + + /* ── Modal focus: in on open, trapped while open, back to the editor on close ─────────────── */ + const dialogRef = useRef(null); + useDialogFocus(dialogRef, { + initialFocus: () => dialogRef.current?.querySelector('.settings-nav-item.active'), + // While a binding is recording, Escape cancels it (and is not recorded); otherwise it closes. + onEscape: () => (activeShortcutRebind ? setActiveShortcutRebind(null) : onClose()), + // Tab may be part of the shortcut being recorded, so the recorder must get it. + trapTab: !activeShortcutRebind + }); + + /* + * Closing Settings stops the recorder. Up to 1.13 it stayed armed after Settings closed, and the + * next key typed anywhere, even a plain letter in the editor, became that action's shortcut. + * The latest setter is read through a ref so the cleanup runs once, on unmount. + */ + const stopRecordingRef = useRef(setActiveShortcutRebind); + useEffect(() => { + stopRecordingRef.current = setActiveShortcutRebind; + }); + useEffect(() => () => stopRecordingRef.current?.(null), []); const refreshCoverage = () => { if (window.electronAPI?.getAssociationCoverage) { @@ -245,8 +393,8 @@ function SettingsModal({ /* Association coverage runs a registry sweep, fetched only when its section opens. */ useEffect(() => { - if (section === 'windows') refreshCoverage(); - }, [section]); + if (current === 'windows') refreshCoverage(); + }, [current]); const repairTypes = async () => { setRepairBusy(true); @@ -271,12 +419,16 @@ function SettingsModal({ setFonts({ perType: next }); }; - /* Duplicate bindings, flagged inline rather than silently letting first-match win. */ + /* + * Duplicate bindings, flagged inline rather than silently letting first-match win. Compared in + * normalized form, so "Control+k" and "Control+K" (or a legacy "Control++" and "Control+Plus") + * count as the same binding. + */ const conflicts = useMemo(() => { const seen = {}; const dupes = new Set(); for (const { id } of SHORTCUT_ACTIONS) { - const b = settings.shortcuts[id]; + const b = normalizeBinding(settings.shortcuts[id]); if (!b) continue; if (seen[b]) { dupes.add(b); @@ -285,6 +437,7 @@ function SettingsModal({ } return dupes; }, [settings.shortcuts]); + const isConflicted = (id) => conflicts.has(normalizeBinding(settings.shortcuts[id])); const saveCustomTheme = () => { updateSetting('customTheme', customDraft); @@ -309,17 +462,26 @@ function SettingsModal({ return (
-
e.stopPropagation()} role="dialog" aria-label="Settings"> +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby={idFor('title')} + tabIndex={-1} + >
{/* ── APPEARANCE ─────────────────────────────────────────────────────────────── */} - {section === 'appearance' && ( + {current === 'appearance' && ( <>
Theme @@ -433,60 +595,60 @@ function SettingsModal({
Layout & startup -
- Default sidebar width - +
+ Default sidebar width + + Width of a Markdown document's contents sidebar, from {SIDEBAR_WIDTH_MIN} to{' '} + {SIDEBAR_WIDTH_MAX} pixels. + +
+ { - const val = parseInt(e.target.value, 10); - if (Number.isFinite(val)) { - updateSetting('sidebarWidth', val); - onSidebarWidthChange(val); - } + labelledBy={idFor('sidebar')} + describedBy={idFor('sidebar-hint')} + onCommit={(width) => { + updateSetting('sidebarWidth', width); + onSidebarWidthChange(width); }} />
- Reopen last session's tabs on launch - + Reopen last session's tabs on launch + Off means FATE closes your tabs when you quit and starts on the home screen next time, without keeping the list of what you had open. Either way, quitting with unsaved work asks you to save or discard it first, tab by tab.
- + updateSetting('restoreSession', v)} + labelledBy={idFor('restore')} + describedBy={idFor('restore-hint')} + />
)} {/* ── FONTS ──────────────────────────────────────────────────────────────────── */} - {section === 'fonts' && ( + {current === 'fonts' && ( <>
Interface
- Application font - setFonts({ ui: id })} /> + Application font + setFonts({ ui: id })} />
Markdown documents
- Document font - setFonts({ markdown: id })} /> + Document font + setFonts({ markdown: id })} />
setFonts({ markdownSize: v })} />
@@ -494,16 +656,17 @@ function SettingsModal({
Code
- Code font - setFonts({ code: id })} /> + Code font + setFonts({ code: id })} />
setFonts({ editorSize: v })} />
- Font ligatures - + Font ligatures + setFonts({ ligatures: v })} + labelledBy={idFor('ligatures')} + />
@@ -516,9 +679,10 @@ function SettingsModal({ {overrides.map(([ext, fontId]) => (
- .{ext} + .{ext}
- Wrap long lines - + Wrap long lines + updateSetting('editorWrap', v)} + labelledBy={idFor('wrap')} + />
- Indent size - updateSetting('editorTabSize', parseInt(e.target.value, 10))} + aria-labelledby={idFor('indent')} + > @@ -566,8 +735,8 @@ function SettingsModal({
- Highlight syntax errors - + Highlight syntax errors + Underlines code the language parser can't make sense of (missing brackets, unclosed strings, stray tokens), with a marker in the gutter. Works for languages with structural parsers (JavaScript, TypeScript, HTML, @@ -575,14 +744,12 @@ function SettingsModal({ rather than guessing.
- + updateSetting('editorLint', v)} + labelledBy={idFor('lint')} + describedBy={idFor('lint-hint')} + />
@@ -590,31 +757,87 @@ function SettingsModal({ Code files open straight into the editor; markdown opens in the reading view with an Edit button (and live preview) in the header. New files - (Ctrl+N) save as any supported format. Fonts live in - the Fonts section; every shortcut is rebindable under Shortcuts. + () save as any supported + format. Fonts live in the Fonts section; every shortcut is rebindable under + Shortcuts.
)} + {/* ── MARKDOWN (1.14.0) ────────────────────────────────────────────────────────── + Spell check and remote images: both are about what leaves the machine, so each + hint says plainly what turning it on sends where. */} + {current === 'markdown' && ( + <> +
+ Editing +
+
+ Spell check while editing Markdown + + Underlines misspelled words in Edit mode. + {platform === 'linux' && ( + <> Turning it on downloads a dictionary from Google once. + )} + +
+ updateSetting('spellcheck', v)} + labelledBy={idFor('spellcheck')} + describedBy={idFor('spellcheck-hint')} + /> +
+
+ +
+ Privacy +
+
+ + Load images from the internet in Markdown documents + + + Off by default for privacy: loading a remote image tells its server that + you opened the document, and when. Images stored on this computer are not + affected. + +
+ updateSetting('remoteImages', v)} + labelledBy={idFor('remote-images')} + describedBy={idFor('remote-images-hint')} + /> +
+
+ + )} + {/* ── PRINTING ───────────────────────────────────────────────────────────────── */} - {section === 'printing' && ( + {current === 'printing' && (
- Paper size - updateSetting('printPageSize', e.target.value)} + aria-labelledby={idFor('paper')} + > {PAGE_SIZES.map((p) => ( ))}
- Landscape - + Landscape + updateSetting('printLandscape', v)} + labelledBy={idFor('landscape')} + />
@@ -631,7 +854,7 @@ function SettingsModal({ )} {/* ── WINDOWS ────────────────────────────────────────────────────────────────── */} - {section === 'windows' && isWindows && ( + {current === 'windows' && isWindows && ( <>
Markdown default @@ -740,16 +963,13 @@ function SettingsModal({
- Always show full context menus - + Always show full context menus +
{explorerRestartNeeded && (
@@ -776,7 +996,7 @@ function SettingsModal({ )} {/* ── SHORTCUTS ──────────────────────────────────────────────────────────────── */} - {section === 'shortcuts' && ( + {current === 'shortcuts' && ( <>
@@ -796,21 +1016,33 @@ function SettingsModal({ higher in this list wins. Rebind one of them.

)} - {SHORTCUT_ACTIONS.map((a) => ( -
- - {a.label} - - -
- ))} + {/* Announced when recording starts, so a screen reader user knows what to press. */} +

+ {activeShortcutRebind + ? 'Press the new shortcut: Ctrl or Alt with a key, or a function key (F1–F12). Esc cancels.' + : ''} +

+ {SHORTCUT_ACTIONS.map((a) => { + const recording = activeShortcutRebind === a.id; + const conflicted = isConflicted(a.id); + return ( +
+ + {a.label} + + +
+ ); + })}
@@ -828,7 +1060,7 @@ function SettingsModal({ )} {/* ── ABOUT ──────────────────────────────────────────────────────────────────── */} - {section === 'about' && ( + {current === 'about' && ( <>
Updates @@ -847,15 +1079,12 @@ function SettingsModal({
) : (
- Automatic updates - + Automatic updates + updateSetting('autoUpdatesEnabled', v)} + labelledBy={idFor('updates')} + />
)}
diff --git a/src/components/Starfield.jsx b/src/components/Starfield.jsx index 89de3d9..c652f1f 100644 --- a/src/components/Starfield.jsx +++ b/src/components/Starfield.jsx @@ -19,8 +19,15 @@ import { useEffect, useRef } from 'react'; * ── Cost control ────────────────────────────────────────────────────────────────────────────── * - Nothing here touches React state; it draws straight to a canvas. * - The rAF loop only runs while mounted, i.e. only on the home screen. - * - It also pauses while the window is hidden (`visibilitychange`), so a minimised or background - * window costs nothing. + * - It pauses while the window is hidden (`visibilitychange`) AND while it is unfocused + * (window `blur`/`focus`), so a minimised window, or FATE sitting behind the app you are + * actually using, costs nothing. Up to 1.13 only hiding paused it, and an open-but-unfocused + * window animated at the display's full refresh rate all day. + * - Drawing is capped at 30 fps. Twinkling dust doesn't need 144 frames a second, and rAF still + * fires at the display rate, so frames in between are skipped. Motion is scaled by the real + * time between frames, so meteors cross at the same speed on any display. + * - Gradients and the meteor sprite are built once per build() (resize or theme change), not + * allocated per link per frame. * - `prefers-reduced-motion` renders one static frame and never starts the loop. * - Device pixel ratio is capped at 2; beyond that the cost doubles for no visible gain. */ @@ -36,6 +43,15 @@ const LINK_RADIUS = 190; const LINKS_PER_ANCHOR = 2; /** Average gap between meteors, ms. Randomised per spawn so they never feel metronomic. */ const METEOR_INTERVAL = 7000; +/** Frame budget: 30 fps. */ +const FRAME_MS = 1000 / 30; +/** + * Meteor speeds and fades are in "per 60 Hz frame" units (what they were tuned at); each frame + * scales them by the time it actually covers. + */ +const TUNED_FRAME_MS = 1000 / 60; +/** Meteor tails are drawn from this one pre-rendered strip, stretched to each meteor's length. */ +const METEOR_SPRITE_W = 256; export default function Starfield({ className = '' }) { const canvasRef = useRef(null); @@ -66,15 +82,51 @@ export default function Starfield({ className = '' }) { }; let theme = readTheme(); + /** The accent as `r, g, b` for rgba(); resolved in build(), never per frame. */ + let accentRgb = '212, 175, 55'; let dust = []; let anchors = []; + /** Links between nearby anchors: { a, b, gradient }. The gradient is built once, in build(). */ let links = []; let meteors = []; + /** A meteor's tail as a pre-rendered strip: head (right end) at half opacity, fading left. */ + let meteorSprite = null; let rafId = null; + /** Animation time in ms. Advances only while the loop runs, so a pause doesn't skip ahead. */ + let elapsed = 0; + /** When the last frame was drawn, or null when the loop is (re)starting. */ + let lastFrame = null; let nextMeteorAt = METEOR_INTERVAL; + /** Convert a hex colour to `r, g, b` so it can be used inside rgba(). */ + const rgb = (hex) => { + const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim()); + return m + ? `${parseInt(m[1], 16)}, ${parseInt(m[2], 16)}, ${parseInt(m[3], 16)}` + : '212, 175, 55'; + }; + + function buildMeteorSprite() { + const sprite = document.createElement('canvas'); + sprite.width = METEOR_SPRITE_W; + sprite.height = Math.ceil(1.4 * DPR) + 2; + const sctx = sprite.getContext('2d'); + if (!sctx) return null; + const g = sctx.createLinearGradient(0, 0, METEOR_SPRITE_W, 0); + g.addColorStop(0, `rgba(${accentRgb}, 0)`); + g.addColorStop(1, `rgba(${accentRgb}, 0.5)`); + sctx.strokeStyle = g; + sctx.lineWidth = 1.4 * DPR; + sctx.beginPath(); + sctx.moveTo(0, sprite.height / 2); + sctx.lineTo(METEOR_SPRITE_W, sprite.height / 2); + sctx.stroke(); + return sprite; + } + function build() { theme = readTheme(); + accentRgb = rgb(theme.accent); const { clientWidth: w, clientHeight: h } = canvas.parentElement || canvas; canvas.width = Math.max(1, Math.floor(w * DPR)); canvas.height = Math.max(1, Math.floor(h * DPR)); @@ -119,9 +171,18 @@ export default function Starfield({ className = '' }) { } near.sort((u, v) => u[1] - v[1]); for (let k = 0; k < Math.min(LINKS_PER_ANCHOR, near.length); k++) { - links.push([a, near[k][0]]); + // Anchors never move, so each link's gradient is fixed until the next build(). + const A = anchors[a]; + const B = anchors[near[k][0]]; + const gradient = ctx.createLinearGradient(A.x, A.y, B.x, B.y); + gradient.addColorStop(0, 'rgba(185, 195, 240, 0.10)'); + gradient.addColorStop(0.5, `rgba(${accentRgb}, 0.10)`); + gradient.addColorStop(1, 'rgba(185, 195, 240, 0.10)'); + links.push({ a: A, b: B, gradient }); } } + + meteorSprite = buildMeteorSprite(); } function spawnMeteor() { @@ -135,28 +196,17 @@ export default function Starfield({ className = '' }) { }); } - /** Convert a hex colour to `r, g, b` so it can be used inside rgba(). */ - const rgb = (hex) => { - const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim()); - return m - ? `${parseInt(m[1], 16)}, ${parseInt(m[2], 16)}, ${parseInt(m[3], 16)}` - : '212, 175, 55'; - }; - - function draw(t) { + /** + * Paint one frame at animation time `t`. `step` is how many 60 Hz frames' worth of motion this + * frame covers (2 at 30 fps); 0 repaints without moving anything (rebuilds, the first frame). + */ + function draw(t, step) { ctx.clearRect(0, 0, canvas.width, canvas.height); - const accentRgb = rgb(theme.accent); // Links first, so stars sit on top of them. ctx.lineWidth = 0.6 * DPR; - for (const [ai, bi] of links) { - const A = anchors[ai]; - const B = anchors[bi]; - const g = ctx.createLinearGradient(A.x, A.y, B.x, B.y); - g.addColorStop(0, 'rgba(185, 195, 240, 0.10)'); - g.addColorStop(0.5, `rgba(${accentRgb}, 0.10)`); - g.addColorStop(1, 'rgba(185, 195, 240, 0.10)'); - ctx.strokeStyle = g; + for (const { a: A, b: B, gradient } of links) { + ctx.strokeStyle = gradient; ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.lineTo(B.x, B.y); @@ -194,56 +244,67 @@ export default function Starfield({ className = '' }) { ctx.fill(); } - // Meteors, newest last so they draw over the field. + // Meteors, newest last so they draw over the field: the cached tail strip, turned to the + // meteor's heading and stretched to its length, with globalAlpha carrying the fade. for (let i = meteors.length - 1; i >= 0; i--) { const m = meteors[i]; - m.x += m.vx; - m.y += m.vy; - m.life -= 0.009; + m.x += m.vx * step; + m.y += m.vy * step; + m.life -= 0.009 * step; if (m.life <= 0 || m.x > canvas.width + 60 * DPR || m.y > canvas.height + 60 * DPR) { meteors.splice(i, 1); continue; } - const tailX = m.x - m.vx * 9; - const tailY = m.y - m.vy * 9; - const g = ctx.createLinearGradient(m.x, m.y, tailX, tailY); - g.addColorStop(0, `rgba(${accentRgb}, ${0.5 * m.life})`); - g.addColorStop(1, `rgba(${accentRgb}, 0)`); - ctx.globalAlpha = 1; - ctx.strokeStyle = g; - ctx.lineWidth = 1.4 * DPR; - ctx.beginPath(); - ctx.moveTo(m.x, m.y); - ctx.lineTo(tailX, tailY); - ctx.stroke(); + if (!meteorSprite) continue; + const length = Math.hypot(m.vx, m.vy) * 9; + ctx.globalAlpha = m.life; + ctx.translate(m.x, m.y); + ctx.rotate(Math.atan2(m.vy, m.vx)); + ctx.drawImage(meteorSprite, -length, -meteorSprite.height / 2, length, meteorSprite.height); + ctx.setTransform(1, 0, 0, 1, 0, 0); } ctx.globalAlpha = 1; } - let start = null; + /* + * rAF fires at the display's rate (60, 144, 240 Hz); a frame is only drawn once FRAME_MS has + * passed since the last one, which caps drawing at 30 fps. The small allowance absorbs timer + * jitter, so a 60 Hz display reliably draws every second frame. + */ function frame(now) { - if (start === null) start = now; - const t = now - start; - if (t > nextMeteorAt) { - spawnMeteor(); - nextMeteorAt = t + METEOR_INTERVAL * (0.6 + Math.random()); + if (lastFrame === null || now - lastFrame >= FRAME_MS - 2) { + // The time this frame covers, capped so a stall (a busy main thread) can't fling meteors. + const dt = lastFrame === null ? FRAME_MS : Math.min(now - lastFrame, 4 * FRAME_MS); + lastFrame = now; + elapsed += dt; + if (elapsed > nextMeteorAt) { + spawnMeteor(); + nextMeteorAt = elapsed + METEOR_INTERVAL * (0.6 + Math.random()); + } + draw(elapsed, dt / TUNED_FRAME_MS); } - draw(t); rafId = requestAnimationFrame(frame); } + /** Whether the window is focused; kept current by the focus/blur listeners below. */ + let focused = document.hasFocus(); + const shouldAnimate = () => !reduced && !document.hidden && focused; + const stop = () => { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } + lastFrame = null; // the first frame after a pause draws at once, with a normal step }; const play = () => { - if (rafId === null && !reduced) rafId = requestAnimationFrame(frame); + if (rafId === null && shouldAnimate()) rafId = requestAnimationFrame(frame); }; + const sync = () => (shouldAnimate() ? play() : stop()); + build(); /* @@ -252,16 +313,26 @@ export default function Starfield({ className = '' }) { * Two reasons this is unconditional rather than only in the reduced-motion branch: * 1. requestAnimationFrame does not fire until the next compositor frame, so without this the * canvas is blank for a frame on mount: a visible flash of empty sky. - * 2. rAF does not fire at all while the document is hidden. If the window opens minimised or - * on another desktop, the loop never starts and the sky stays blank until the window is - * focused. Drawing once here means there is always a sky, and the loop only adds motion. + * 2. rAF does not fire at all while the document is hidden, and the loop doesn't run while + * the window is unfocused. If the window opens minimised, on another desktop or behind + * another app, the sky would stay blank until it was focused. Drawing once here means + * there is always a sky, and the loop only adds motion. */ - draw(0); - if (!reduced) play(); + draw(0, 0); + play(); - // Pause entirely while the window is hidden; a minimised window should cost nothing. - const onVisibility = () => (document.hidden ? stop() : play()); - document.addEventListener('visibilitychange', onVisibility); + // Pause while the window is hidden or unfocused; resume when it is visible and focused again. + const onFocus = () => { + focused = true; + sync(); + }; + const onBlur = () => { + focused = false; + sync(); + }; + document.addEventListener('visibilitychange', sync); + window.addEventListener('focus', onFocus); + window.addEventListener('blur', onBlur); /* * Rebuild the field. Debounced with setTimeout, NOT requestAnimationFrame: rAF does not fire @@ -274,7 +345,7 @@ export default function Starfield({ className = '' }) { rebuildTimer = setTimeout(() => { rebuildTimer = null; build(); - draw(0); // repaint now; the loop may not be running (hidden window, or reduced motion) + draw(elapsed, 0); // repaint now; the loop may not be running (paused, or reduced motion) }, 80); }; @@ -301,7 +372,9 @@ export default function Starfield({ className = '' }) { if (rebuildTimer !== null) clearTimeout(rebuildTimer); observer.disconnect(); themeObserver.disconnect(); - document.removeEventListener('visibilitychange', onVisibility); + document.removeEventListener('visibilitychange', sync); + window.removeEventListener('focus', onFocus); + window.removeEventListener('blur', onBlur); }; }, []); diff --git a/src/dialogFocus.js b/src/dialogFocus.js new file mode 100644 index 0000000..ccc2064 --- /dev/null +++ b/src/dialogFocus.js @@ -0,0 +1,148 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * dialogFocus.js: keyboard focus for modal dialogs (Settings, the command palette), 1.14.0. + * + * Up to 1.13 both dialogs left focus wherever it happened to be: Tab walked out of the dialog + * into the tab strip and editors behind the backdrop, and closing either left focus on , so + * the next keystroke went nowhere until you clicked back into the document. A modal has to: + * + * 1. move focus inside when it opens, + * 2. keep Tab and Shift+Tab cycling inside while it is open, with the rest of the app inert + * (unreachable for focus and hidden from screen readers), + * 3. give focus back to whatever had it before (usually the editor) when it closes. + * + * The opener is captured in a useState initializer, i.e. during the dialog's first render, which + * is before anything inside it can take focus. The Tab and Escape handlers sit on `document`, not + * on the dialog, so they still work after a click on a non-focusable part of the dialog has left + * focus on . They run after React's own handlers (React listens on #root, below document), + * so a control inside that handles a key itself and calls preventDefault keeps it. + * + * Own module (not inside a component file) because react-refresh needs component files to export + * only components, and both dialogs use it. + */ + +const FOCUSABLE = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[contenteditable="true"]', + '[contenteditable=""]', + '[tabindex]' +].join(','); + +/** Dialogs currently open, oldest first (their refs); keyboard handling belongs to the last. */ +const openDialogs = []; + +/** Tab stops inside `container`, in DOM order: focusable, not tabindex="-1", and rendered. */ +export function focusableElements(container) { + if (!container) return []; + return Array.from(container.querySelectorAll(FOCUSABLE)).filter( + (el) => el.tabIndex >= 0 && el.getClientRects().length > 0 + ); +} + +/** + * Modal focus handling for the dialog element in `dialogRef`. + * + * initialFocus() the element to focus on open (default: the first tab stop, else the dialog) + * onEscape(e) called for an Escape that no control inside has handled (preventDefault); + * the event is preventDefault-ed, so App's own Escape handling (close the diff, + * leave focus mode, close the tab) leaves it alone + * trapTab false lets Tab through untouched, e.g. while the shortcut recorder is + * listening and Tab may be part of the shortcut being recorded + */ +export function useDialogFocus(dialogRef, { initialFocus, onEscape, trapTab = true } = {}) { + const [opener] = useState(() => (typeof document === 'undefined' ? null : document.activeElement)); + + // Latest options for the once-registered document listener. + const optionsRef = useRef({ onEscape, trapTab }); + useEffect(() => { + optionsRef.current = { onEscape, trapTab }; + }); + + const initialFocusRef = useRef(initialFocus); + + useEffect(() => { + const dialog = dialogRef.current; + + /* + * Everything beside the dialog's backdrop (its parent) goes inert while it is open: the tab + * strip, the editors, the status bar. aria-modal alone doesn't do it in Chromium; a screen + * reader could still wander into the document behind, and so could focus. Only elements this + * dialog made inert are restored, so a palette opened over Settings leaves Settings' own + * inert background alone when it closes. + */ + const backdrop = dialog?.parentElement; + const madeInert = []; + for (const el of backdrop?.parentElement?.children ?? []) { + if (el !== backdrop && !el.hasAttribute('inert')) { + el.setAttribute('inert', ''); + madeInert.push(el); + } + } + + const target = initialFocusRef.current?.() || focusableElements(dialog)[0] || dialog; + target?.focus({ preventScroll: true }); + + return () => { + for (const el of madeInert) el.removeAttribute('inert'); + /* + * Back to the opener, if it is still in the page and can take focus. A CodeMirror editor + * restores its own selection on focus. An opener that has gone (the palette's input, when + * the palette opened Settings) is skipped; whoever is showing now owns focus. + */ + if (opener && opener !== document.body && opener.isConnected && typeof opener.focus === 'function') { + opener.focus({ preventScroll: true }); + } + }; + }, [dialogRef, opener]); + + useEffect(() => { + openDialogs.push(dialogRef); + const onKeyDown = (e) => { + const { onEscape: escape, trapTab: trapping } = optionsRef.current; + // Only the topmost dialog answers: the palette can open over Settings (Ctrl+K), and + // Settings' trap would otherwise pull focus out of the palette on the first Tab. + if (e.defaultPrevented || openDialogs[openDialogs.length - 1] !== dialogRef) return; + const dialog = dialogRef.current; + if (!dialog) return; + + if (e.key === 'Escape') { + if (escape) { + e.preventDefault(); + escape(e); + } + return; + } + // Ctrl+Tab and friends are tab-switching shortcuts, not focus movement. + if (!trapping || e.key !== 'Tab' || e.ctrlKey || e.altKey || e.metaKey) return; + + const stops = focusableElements(dialog); + if (stops.length === 0) { + e.preventDefault(); + dialog.focus({ preventScroll: true }); + return; + } + const first = stops[0]; + const last = stops[stops.length - 1]; + const active = document.activeElement; + const inside = active !== dialog && dialog.contains(active); + if (e.shiftKey && (!inside || active === first)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && (!inside || active === last)) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + const i = openDialogs.lastIndexOf(dialogRef); + if (i !== -1) openDialogs.splice(i, 1); + }; + }, [dialogRef]); +} diff --git a/src/languageDetect.js b/src/languageDetect.js index 6a52328..c88ed9b 100644 --- a/src/languageDetect.js +++ b/src/languageDetect.js @@ -22,6 +22,8 @@ import { languages } from '@codemirror/language-data'; export function detectLanguage(fileName, content) { const byName = fileName ? LanguageDescription.matchFilename(languages, fileName) : null; if (byName) return byName; + // JSONC names the registry doesn't know (.jsonc, .eslintrc, .code-workspace) are JSON to look at. + if (isJsonWithComments(fileName)) return byLanguageName('JSON'); return sniffLanguage(content); } @@ -69,7 +71,71 @@ export function sniffLanguage(content) { const firstMeaningful = trimmed.split(/\r?\n/).find((line) => line.trim() && !/^\s*[;#]/.test(line)) || ''; if (/^\[[A-Za-z0-9 ._:\\/-]+\]\s*$/.test(firstMeaningful.trim())) return byLanguageName('Properties files'); - if (/^[{[]/.test(trimmed)) return byLanguageName('JSON'); + if (looksLikeJson(trimmed, content)) return byLanguageName('JSON'); return null; } + +/** Files up to this size get a full JSON.parse when their first token doesn't settle it. */ +const JSON_PARSE_LIMIT = 1024 * 1024; + +/** Whitespace and JSONC comments, which may sit between JSON tokens. */ +const GAP = String.raw`(?:\s|//[^\n]*(?:\n|$)|/\*[\s\S]*?\*/)*`; +const SCALAR = String.raw`(?:-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)`; +const OBJECT_HEAD = new RegExp(String.raw`^\{${GAP}["}]`); +const ARRAY_HEAD = new RegExp(String.raw`^\[${GAP}(?:[[{"]|${SCALAR}${GAP}[,\]]|\]${GAP}$)`); + +/** + * Is this JSON? Up to 1.13 anything starting with `[` or `{` was, so a log whose lines start + * `[2026-10-04 12:00:01] INFO` or `[INFO]` opened as JSON and syntax checking underlined hundreds + * of "errors". Now the first token after the bracket has to be one JSON allows there (comments + * may come first, for JSONC): + * + * `[` then `{` `[` `"`, a number / true / false / null followed by `,` or `]`, or `]` that + * ends the file + * `{` then `"` `}` + * + * A number has to be followed by `,` or `]` because a timestamp starts with digits too + * (`[2026-10-04` is the number 2026 followed by `-`), and an empty array has to be the whole file + * because `[ ] task` is a checklist. When the head doesn't settle it, a file under 1 MB that + * parses as a whole still counts. `head` is the trimmed start of the file. + */ +export function looksLikeJson(head, content = head) { + if (typeof head !== 'string') return false; + if (head[0] === '{') return OBJECT_HEAD.test(head) || parsesAsJson(content); + if (head[0] === '[') return ARRAY_HEAD.test(head) || parsesAsJson(content); + return false; +} + +function parsesAsJson(content) { + if (typeof content !== 'string' || content.length > JSON_PARSE_LIMIT) return false; + try { + JSON.parse(content.replace(/^\uFEFF/, '')); + return true; + } catch { + return false; + } +} + +/** Exact basenames of JSON files whose tools accept comments (lower-cased). */ +const JSONC_NAMES = new Set([ + '.eslintrc', '.eslintrc.json', + // VS Code's own files: .vscode/settings.json, keybindings.json, launch.json, tasks.json. + 'settings.json', 'keybindings.json', 'launch.json', 'tasks.json', + 'devcontainer.json', '.devcontainer.json' +]); + +/** + * Is this a JSON-with-comments file (JSONC)? Comments and trailing commas there are allowed by + * the tool that reads the file, so a syntax checker must not flag them as JSON errors: `.jsonc`, + * tsconfig*.json and jsconfig*.json (TypeScript reads them as JSONC), .eslintrc(.json), VS Code's + * settings/keybindings/launch/tasks.json and .code-workspace files, and devcontainer.json. + * Takes a bare name or a full path. + */ +export function isJsonWithComments(fileName) { + if (typeof fileName !== 'string' || !fileName) return false; + const base = fileName.split(/[\\/]/).pop().toLowerCase(); + if (base.endsWith('.jsonc') || base.endsWith('.code-workspace')) return true; + if (/^(?:ts|js)config.*\.json$/.test(base)) return true; + return JSONC_NAMES.has(base); +} diff --git a/src/paletteModes.js b/src/paletteModes.js new file mode 100644 index 0000000..41e8ab2 --- /dev/null +++ b/src/paletteModes.js @@ -0,0 +1,710 @@ +import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'; +import { + ArrowBendDownRight, Warning, Function as FunctionIcon, Cube, Shapes, ListNumbers, TextT, + Package, PuzzlePiece, BracketsCurly, TextH, FileCode, FileText +} from '@phosphor-icons/react'; + +/** + * paletteModes.js: the command palette's prefix modes (1.14.0). + * + * : go to line ":42", ":42:7" (line:column) in the active tab + * @ go to symbol the active tab's functions, classes, … or its Markdown headings + * # search open tabs case-insensitive text search across every open tab + * + * createPaletteModes(ctx) builds the three from callbacks App supplies, because App owns the tabs + * and the editors: + * + * ctx.getActiveDoc() the active tab's doc ({ id, kind, name, … }), or null + * ctx.getActiveView() the active tab's live CodeMirror EditorView, or null (a Markdown + * tab in its reading view has none) + * ctx.getDocs() every open tab's doc, in tab order + * ctx.getDocText(doc) a tab's current text (the live buffer when an editor is mounted) + * ctx.activateDoc(id) make a tab the active one + * ctx.revealLine(docId, l, c) put the caret on line l, column c (1-based) and show it; in a + * reading view, scroll to that source line + * + * A mode is { label, hint, placeholder, getItems(query) }. The palette passes the query without + * its prefix and shows the returned items as they are, in order: modes rank their own results. + * Items have the palette's usual shape { id, label, detail?, icon?, run } plus two optional flags: + * `disabled` (a hint or empty-state row that Enter and clicks ignore) and `error` (the same, shown + * as a problem with the input). + * + * Everything below createPaletteModes is pure and unit-tested under Node + * (test/paletteModes.test.mjs). + */ + +/** + * Subsequence match with a small scorer: consecutive hits and word starts count extra. Shared by + * the palette's main list and the symbol filter, so both rank the same way. + */ +export function scoreMatch(query, text) { + const q = query.toLowerCase(); + const t = text.toLowerCase(); + if (!q) return 1; + let qi = 0; + let score = 0; + let streak = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) { + qi++; + streak++; + score += 1 + streak; // consecutive matches compound + if (ti === 0 || t[ti - 1] === ' ' || t[ti - 1] === '.') score += 4; // word starts matter + } else { + streak = 0; + } + } + return qi === q.length ? score : 0; +} + +/* ── : go to line ─────────────────────────────────────────────────────────────────────────── */ + +/** Lines in a string: one more than its line breaks (the renderer only ever holds `\n`). */ +export function countLines(text) { + if (!text) return 1; + let n = 1; + for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) n++; + return n; +} + +/** Length of 1-based line `n` of a string, 0 past the end. */ +export function lineLengthIn(text, n) { + let start = 0; + for (let i = 1; i < n; i++) { + const nl = text.indexOf('\n', start); + if (nl === -1) return 0; + start = nl + 1; + } + const end = text.indexOf('\n', start); + return (end === -1 ? text.length : end) - start; +} + +/** + * Parse what follows ":" in the palette: "42", "42:7" or "42,7". Returns null for nothing typed, + * { line, col } (col null when not given), or { error } with a message for the palette row. + * "42:" counts as line 42, since the column is usually still being typed. + */ +export function parseLineQuery(query) { + const q = (query ?? '').trim(); + if (!q) return null; + const m = /^(\d+)(?:\s*[:,]\s*(\d*))?$/.exec(q); + if (!m) return { error: 'Type a line number, like 42 or 42:7' }; + return { line: parseInt(m[1], 10), col: m[2] ? parseInt(m[2], 10) : null }; +} + +/** + * Clamp a 1-based line and column into a document of `lineCount` lines. `lineLength(n)` gives a + * line's length; the column may sit one past the end, after the last character. A missing column + * is column 1. + */ +export function clampLineCol(line, col, lineCount, lineLength) { + const l = Math.min(Math.max(1, line), Math.max(1, lineCount)); + const maxCol = (lineLength ? lineLength(l) : 0) + 1; + const c = col == null ? 1 : Math.min(Math.max(1, col), maxCol); + return { line: l, col: c }; +} + +/* ── # search open tabs ───────────────────────────────────────────────────────────────────── */ + +const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Case-insensitive plain-text search of one string. At most `limit` hits and one per line, as + * { line, col, text } with a 1-based line and column and the whole line's text. + * + * A RegExp with the `i` flag rather than indexOf over a lower-cased copy: lower-casing can change + * a string's length ("İ" becomes two code units), which would shift every position after it, and + * it would copy every open document on every keystroke. + */ +export function searchText(text, query, limit = Infinity) { + const hits = []; + if (typeof text !== 'string' || !query || limit <= 0) return hits; + const re = new RegExp(escapeRegExp(query), 'gi'); + let line = 1; + let lineStart = 0; + let counted = 0; // line breaks before this offset are already in `line` + let m; + while ((m = re.exec(text)) !== null) { + const at = m.index; + for (let nl = text.indexOf('\n', counted); nl !== -1 && nl < at; nl = text.indexOf('\n', nl + 1)) { + line++; + lineStart = nl + 1; + } + counted = at; + const end = text.indexOf('\n', at); + const lineEnd = end === -1 ? text.length : end; + hits.push({ line, col: at - lineStart + 1, text: text.slice(lineStart, lineEnd) }); + if (hits.length >= limit || end === -1) break; + re.lastIndex = lineEnd + 1; // one hit per line: carry on from the next line + } + return hits; +} + +/** + * Search several documents in tab order: at most `perDoc` hits from each and `total` in all. + * Returns [{ doc, line, col, text }]. + */ +export function searchDocs(docs, getText, query, { perDoc = 20, total = 200 } = {}) { + const out = []; + for (const doc of docs) { + if (out.length >= total) break; + for (const hit of searchText(getText(doc), query, Math.min(perDoc, total - out.length))) { + out.push({ doc, ...hit }); + } + } + return out; +} + +/** + * A matching line as a palette label: trimmed, and when it is long (minified code, a log line), + * cut to a window that keeps the match in view. + */ +export function snippet(lineText, col, max = 120) { + const lead = lineText.length - lineText.trimStart().length; + const t = lineText.trim(); + if (t.length <= max) return t; + const at = Math.max(0, col - 1 - lead); + const start = Math.max(0, Math.min(at - 30, t.length - max)); + return `${start > 0 ? '…' : ''}${t.slice(start, start + max)}${start + max < t.length ? '…' : ''}`; +} + +/* ── @ go to symbol ───────────────────────────────────────────────────────────────────────── */ + +/* + * Symbols come from the Lezer syntax tree the editor already has, so every tree-based language + * FATE bundles works without a per-language outline module. Lezer grammars name their nodes + * consistently enough for a table: a definition node (FunctionDeclaration, ClassDefinition, + * FunctionItem, FunctionDecl, …) with an identifier-like child that is its name. Stream-parsed + * legacy modes (PowerShell, shell, batch, …) build no tree, so they simply have no symbols. + */ + +/** Definition node → symbol kind, for the bundled grammars. */ +const SYMBOL_NODES = { + // JavaScript, TypeScript, JSX (also inside HTML '; + assert.deepEqual(await symbolsOf('HTML', src), ['function inScript @2:10', 'rule .cls @3:17']); +}); + +test('filterSymbols ranks by match quality, then document order', () => { + const symbols = [ + { name: 'renderPane', line: 1 }, + { name: 'openRecent', line: 2 }, + { name: 'render', line: 3 }, + { name: 'onRender', line: 4 } + ]; + assert.deepEqual(filterSymbols(symbols, ''), symbols); + assert.deepEqual(filterSymbols(symbols, 'render').map((s) => s.name), ['renderPane', 'render', 'onRender']); + assert.deepEqual(filterSymbols(symbols, 'zzz'), []); + assert.ok(scoreMatch('rp', 'renderPane') > 0); +}); + +/* ── createPaletteModes with a fake App ───────────────────────────────────────────────────── */ + +async function viewFor(languageName, text) { + const support = await supportFor(languageName); + return { state: EditorState.create({ doc: text, extensions: [support] }) }; +} + +function fakeApp({ docs, activeId, views = {} }) { + const calls = []; + const ctx = { + getActiveDoc: () => docs.find((d) => d.id === activeId) ?? null, + getActiveView: () => views[activeId] ?? null, + getDocs: () => docs, + getDocText: (d) => d.text, + activateDoc: (id) => calls.push(['activate', id]), + revealLine: (id, line, col) => calls.push(['reveal', id, line, col]) + }; + return { modes: createPaletteModes(ctx), calls }; +} + +test('modes have labels, hints and placeholders for the palette', () => { + const { modes } = fakeApp({ docs: [], activeId: null }); + assert.deepEqual(Object.keys(modes), [':', '@', '#']); + assert.deepEqual(Object.values(modes).map((m) => m.hint), ['go to line', 'symbol', 'search open tabs']); + for (const m of Object.values(modes)) { + assert.equal(typeof m.label, 'string'); + assert.equal(typeof m.placeholder, 'string'); + assert.equal(typeof m.getItems, 'function'); + } +}); + +test(': mode goes to a clamped line in a reading-view tab (text only)', () => { + const doc = { id: 7, kind: 'markdown', name: 'notes.md', text: '# A\n\ntext\nlast line' }; + const { modes, calls } = fakeApp({ docs: [doc], activeId: 7 }); + const go = modes[':']; + + const [hint] = go.getItems(''); + assert.ok(hint.disabled && /1 to 4/.test(hint.label)); + const [error] = go.getItems('abc'); + assert.ok(error.disabled && error.error); + + const [item] = go.getItems('3'); + assert.equal(item.label, 'Go to line 3'); + assert.equal(item.detail, 'notes.md · 4 lines'); + item.run(); + const [far] = go.getItems('999:999'); + assert.equal(far.label, 'Go to line 4, column 10'); + far.run(); + assert.deepEqual(calls, [['reveal', 7, 3, 1], ['reveal', 7, 4, 10]]); +}); + +test(': mode with a live editor uses its document', async () => { + const doc = { id: 1, kind: 'code', name: 'a.js', text: 'stale text' }; + const view = await viewFor('JavaScript', 'let a = 1;\nlet bb = 2;\n'); + const { modes, calls } = fakeApp({ docs: [doc], activeId: 1, views: { 1: view } }); + const [item] = modes[':'].getItems('2:99'); + assert.equal(item.label, 'Go to line 2, column 12'); + item.run(); + assert.deepEqual(calls, [['reveal', 1, 2, 12]]); + const [none] = fakeApp({ docs: [], activeId: null }).modes[':'].getItems('3'); + assert.ok(none.disabled); +}); + +test('@ mode lists and filters symbols, and reveals the chosen one', async () => { + const text = 'function alpha() {}\nclass Beta { gamma() {} }\nconst delta = () => 1;\n'; + const doc = { id: 3, kind: 'code', name: 'x.js', text }; + const view = await viewFor('JavaScript', text); + const { modes, calls } = fakeApp({ docs: [doc], activeId: 3, views: { 3: view } }); + const sym = modes['@']; + + const all = sym.getItems(''); + assert.deepEqual(all.map((i) => i.label), ['alpha', 'Beta', 'gamma', 'delta']); + assert.deepEqual(all.map((i) => i.detail), ['Function · line 1', 'Class · line 2', 'Method · line 2', 'Function · line 3']); + assert.ok(all.every((i) => typeof i.icon === 'object' || typeof i.icon === 'function')); + + const filtered = sym.getItems('gam'); + assert.deepEqual(filtered.map((i) => i.label), ['gamma']); + filtered[0].run(); + assert.deepEqual(calls, [['reveal', 3, 2, 14]]); + + const [nomatch] = sym.getItems('zzz'); + assert.ok(nomatch.disabled && /No symbols match "zzz"/.test(nomatch.label)); +}); + +test('@ mode in a Markdown reading view scans the text for headings', () => { + const doc = { id: 4, kind: 'markdown', name: 'r.md', text: '# One\n\n## Two\n\nbody\n' }; + const { modes, calls } = fakeApp({ docs: [doc], activeId: 4 }); + const items = modes['@'].getItems(''); + assert.deepEqual(items.map((i) => [i.label, i.detail]), [['One', 'Heading 1 · line 1'], ['Two', 'Heading 2 · line 3']]); + items[1].run(); + assert.deepEqual(calls, [['reveal', 4, 3, 1]]); + + const empty = fakeApp({ docs: [{ ...doc, text: 'no headings' }], activeId: 4 }).modes['@'].getItems(''); + assert.equal(empty[0].label, 'This document has no headings'); +}); + +test('@ mode reuses its symbols while the document is unchanged', async () => { + const text = 'function a() {}\n'; + const view = await viewFor('JavaScript', text); + const doc = { id: 1, kind: 'code', name: 'a.js', text }; + const { modes } = fakeApp({ docs: [doc], activeId: 1, views: { 1: view } }); + const first = modes['@'].getItems(''); + view.state = view.state.update({ changes: { from: text.length, insert: 'function b() {}\n' } }).state; + const second = modes['@'].getItems(''); + assert.deepEqual(first.map((i) => i.label), ['a']); + assert.deepEqual(second.map((i) => i.label), ['a', 'b'], 'a new document state is re-read'); +}); + +test('# mode searches every open tab and switches tab before revealing', () => { + const docs = [ + { id: 1, kind: 'code', name: 'a.js', text: 'const Needle = 1;\nnothing\n needle again' }, + { id: 2, kind: 'markdown', name: 'b.md', text: '# NEEDLE heading' }, + { id: 3, kind: 'code', name: 'c.txt', text: 'no match here' } + ]; + const { modes, calls } = fakeApp({ docs, activeId: 1 }); + const find = modes['#']; + + assert.ok(find.getItems('')[0].disabled); + assert.ok(find.getItems(' ')[0].disabled, 'only spaces after "#" is still an empty query'); + const items = find.getItems(' needle'); + assert.deepEqual(items.map((i) => [i.label, i.detail]), [ + ['const Needle = 1;', 'a.js:1'], + ['needle again', 'a.js:3'], + ['# NEEDLE heading', 'b.md:1'] + ]); + items[1].run(); + items[2].run(); + assert.deepEqual(calls, [['reveal', 1, 3, 3], ['activate', 2], ['reveal', 2, 1, 3]]); + + const [none] = find.getItems('absent'); + assert.ok(none.disabled && /No matches for "absent"/.test(none.label)); + assert.ok(fakeApp({ docs: [], activeId: null }).modes['#'].getItems('x')[0].disabled); +}); diff --git a/test/settingsMeta.test.mjs b/test/settingsMeta.test.mjs new file mode 100644 index 0000000..be9416a --- /dev/null +++ b/test/settingsMeta.test.mjs @@ -0,0 +1,169 @@ +// Shortcut binding helpers and the sidebar width clamp (src/settingsMeta.js). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseShortcut, normalizeBinding, bindingFromEvent, matchesShortcut, isAllowedShortcut, + shortcutParts, formatShortcutLabel, toElectronAccelerator, resolveShortcuts, + DEFAULT_SHORTCUTS, SHORTCUT_ACTIONS, clampSidebarWidth +} from '../src/settingsMeta.js'; + +/** A KeyboardEvent-shaped object. */ +const key = (k, mods = {}) => ({ + key: k, + ctrlKey: !!mods.ctrl, + shiftKey: !!mods.shift, + altKey: !!mods.alt, + metaKey: !!mods.meta +}); + +test('parseShortcut reads modifiers and the key', () => { + assert.deepEqual(parseShortcut('Control+Shift+S'), { ctrl: true, shift: true, alt: false, meta: false, key: 'S' }); + assert.deepEqual(parseShortcut('Alt+Home'), { ctrl: false, shift: false, alt: true, meta: false, key: 'Home' }); + assert.deepEqual(parseShortcut('Escape'), { ctrl: false, shift: false, alt: false, meta: false, key: 'Escape' }); + assert.equal(parseShortcut('Control+\\').key, '\\'); + assert.equal(parseShortcut('Control+,').key, ','); + assert.equal(parseShortcut('Meta+K').meta, true); +}); + +test('parseShortcut handles the + key in the new and the legacy form', () => { + const plus = { ctrl: true, shift: false, alt: false, meta: false, key: 'Plus' }; + assert.deepEqual(parseShortcut('Control+Plus'), plus); + assert.deepEqual(parseShortcut('Control++'), plus, 'legacy "Control++" (split on "+" gave an empty key)'); + assert.deepEqual(parseShortcut('Control+Shift++'), { ...plus, shift: true }); + assert.deepEqual(parseShortcut('+'), { ...plus, ctrl: false }); +}); + +test('parseShortcut accepts aliases and normalises key names', () => { + assert.deepEqual(parseShortcut('Ctrl+k'), parseShortcut('Control+K')); + assert.equal(parseShortcut('Control+space').key, 'Space'); + assert.equal(parseShortcut('Control+Esc').key, 'Escape'); + assert.equal(parseShortcut('Alt+Up').key, 'ArrowUp'); + assert.equal(parseShortcut('f5').key, 'F5'); + assert.equal(parseShortcut('Super+E').meta, true); +}); + +test('parseShortcut rejects bindings with no key or unknown parts', () => { + for (const bad of [undefined, null, '', ' ', 'Control', 'Control+Shift', 'Control+', 'Hyper+K', 'Control++S', 42]) { + assert.equal(parseShortcut(bad), null, String(bad)); + } +}); + +test('normalizeBinding writes the canonical stored form', () => { + assert.equal(normalizeBinding('Control++'), 'Control+Plus'); + assert.equal(normalizeBinding('Shift+Control+s'), 'Control+Shift+S'); + assert.equal(normalizeBinding('ctrl+alt+delete'), 'Control+Alt+Delete'); + assert.equal(normalizeBinding('nonsense+'), null); +}); + +test('every default binding is already in canonical form', () => { + for (const { id, default: binding } of SHORTCUT_ACTIONS) { + assert.equal(normalizeBinding(binding), binding, id); + assert.ok(isAllowedShortcut(binding), `${id} default must be allowed`); + } +}); + +test('bindingFromEvent records modifiers plus the key, in the stored form', () => { + assert.equal(bindingFromEvent(key('s', { ctrl: true })), 'Control+S'); + assert.equal(bindingFromEvent(key('S', { ctrl: true, shift: true })), 'Control+Shift+S'); + assert.equal(bindingFromEvent(key('+', { ctrl: true, shift: true })), 'Control+Shift+Plus'); + assert.equal(bindingFromEvent(key('+', { ctrl: true })), 'Control+Plus'); + assert.equal(bindingFromEvent(key(' ', { ctrl: true })), 'Control+Space'); + assert.equal(bindingFromEvent(key('Home', { alt: true })), 'Alt+Home'); + assert.equal(bindingFromEvent(key('F5')), 'F5'); + assert.equal(bindingFromEvent(key('h')), 'H'); +}); + +test('bindingFromEvent waits while only modifiers are held', () => { + for (const k of ['Control', 'Shift', 'Alt', 'Meta', 'AltGraph', 'CapsLock', 'Dead', 'Unidentified', 'Process', '']) { + assert.equal(bindingFromEvent(key(k, { ctrl: true })), null, k); + } +}); + +test('recording then matching round-trips, including the + key and Space', () => { + const presses = [ + key('k', { ctrl: true }), + key('+', { ctrl: true }), + key('+', { ctrl: true, shift: true }), + key(' ', { ctrl: true }), + key('Tab', { ctrl: true, shift: true }), + key('\\', { ctrl: true }), + key('F12') + ]; + for (const e of presses) { + const binding = bindingFromEvent(e); + assert.ok(matchesShortcut(e, binding), `${binding} should match the press that recorded it`); + } +}); + +test('matchesShortcut needs exactly the bound modifiers', () => { + assert.ok(matchesShortcut(key('s', { ctrl: true }), 'Control+S')); + assert.ok(matchesShortcut(key('S', { ctrl: true, shift: true }), 'Control+Shift+S')); + assert.ok(!matchesShortcut(key('s', { ctrl: true, shift: true }), 'Control+S')); + assert.ok(!matchesShortcut(key('s'), 'Control+S')); + assert.ok(!matchesShortcut(key('s', { ctrl: true, alt: true }), 'Control+S')); + assert.ok(matchesShortcut(key('+', { ctrl: true }), 'Control++'), 'legacy binding of the + key now fires'); + assert.ok(matchesShortcut(key('Escape'), 'Escape')); + assert.ok(!matchesShortcut(key('Escape'), '')); + assert.ok(!matchesShortcut(key('Escape'), undefined)); +}); + +test('isAllowedShortcut: Ctrl, Alt or Meta required, except F1-F12 and a lone Escape', () => { + for (const ok of ['Control+S', 'Alt+Home', 'Meta+K', 'Control+Shift+Plus', 'F1', 'F12', 'Shift+F5', 'Escape', 'Control+Escape']) { + assert.ok(isAllowedShortcut(ok), ok); + } + for (const bad of ['H', 'Shift+H', 'Space', 'Tab', 'Shift+Tab', 'Enter', 'Home', 'F13', 'Shift+Escape', 'Plus', '', 'Control']) { + assert.ok(!isAllowedShortcut(bad), bad); + } +}); + +test('shortcutParts and formatShortcutLabel read like keycaps', () => { + assert.deepEqual(shortcutParts('Control+Shift+S', 'linux'), ['Ctrl', 'Shift', 'S']); + assert.equal(formatShortcutLabel('Control+Plus', 'win32'), 'Ctrl+Plus'); + assert.equal(formatShortcutLabel('Control++', 'win32'), 'Ctrl+Plus'); + assert.equal(formatShortcutLabel('Escape', 'linux'), 'Esc'); + assert.equal(formatShortcutLabel('Control+PageDown', 'linux'), 'Ctrl+PgDn'); + assert.equal(formatShortcutLabel('Alt+ArrowUp', 'linux'), 'Alt+Up'); + assert.equal(formatShortcutLabel('Meta+E', 'win32'), 'Win+E'); + assert.equal(formatShortcutLabel('Meta+E', 'linux'), 'Super+E'); + assert.equal(formatShortcutLabel('Meta+E'), 'Meta+E', 'no platform known (plain Node)'); + assert.equal(formatShortcutLabel(DEFAULT_SHORTCUTS.newFile, 'linux'), 'Ctrl+T'); + assert.deepEqual(shortcutParts('garbage+'), []); +}); + +test('toElectronAccelerator uses Electron key names', () => { + assert.equal(toElectronAccelerator('Control+Shift+S'), 'Control+Shift+S'); + assert.equal(toElectronAccelerator('Control++'), 'Control+Plus'); + assert.equal(toElectronAccelerator('Alt+ArrowLeft'), 'Alt+Left'); + assert.equal(toElectronAccelerator('Control+,'), 'Control+,'); + assert.equal(toElectronAccelerator(''), null); +}); + +test('resolveShortcuts merges, migrates "++" and drops bindings that fire while typing', () => { + const resolved = resolveShortcuts({ + save: 'Control+Alt+S', // a normal rebind survives + closeTab: 'H', // the 1.13 recorder accepted bare letters: back to the default + palette: 'Control++', // legacy + key: migrated + print: 'Control++S', // unparseable: back to the default + goHome: 'shift+home', // Shift alone doesn't count: back to the default + removedAction: 'Control+Q' // no longer exists: dropped + }); + assert.equal(resolved.save, 'Control+Alt+S'); + assert.equal(resolved.closeTab, DEFAULT_SHORTCUTS.closeTab); + assert.equal(resolved.palette, 'Control+Plus'); + assert.equal(resolved.print, DEFAULT_SHORTCUTS.print); + assert.equal(resolved.goHome, DEFAULT_SHORTCUTS.goHome); + assert.ok(!('removedAction' in resolved)); + assert.deepEqual(resolveShortcuts(undefined), DEFAULT_SHORTCUTS); + assert.deepEqual(resolveShortcuts({ close: 'Escape' }).close, 'Escape'); +}); + +test('clampSidebarWidth keeps the width within 200-600', () => { + assert.equal(clampSidebarWidth(450), 450); + assert.equal(clampSidebarWidth(4), 200); + assert.equal(clampSidebarWidth(99999), 600); + assert.equal(clampSidebarWidth('320'), 320); + assert.equal(clampSidebarWidth(250.6), 251); + assert.equal(clampSidebarWidth(undefined), 300); + assert.equal(clampSidebarWidth('abc'), 300); + assert.equal(clampSidebarWidth(NaN, 280), 280); +}); From 42adc3fd100cd7a4d7c19d02ad34b8845678a7e6 Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 07:25:38 -0700 Subject: [PATCH 09/28] Palette: keep the case of a mode row's detail; quieter error rows A "#" hit's detail is a file name and line ("notes.md:3"); the section labels' small caps turned it into "NOTES.MD:3", which misreads a case-sensitive name. Only an error row's icon turns red now. Co-Authored-By: Claude Opus 5.5 --- src/App.css | 8 +++++++- src/components/CommandPalette.jsx | 8 ++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/App.css b/src/App.css index cfd0f78..b705024 100644 --- a/src/App.css +++ b/src/App.css @@ -2546,11 +2546,17 @@ kbd { color: var(--text-muted); } -.palette-item.error, .palette-item.error .palette-icon { color: var(--status-danger); } +.palette-detail { + text-transform: none; + letter-spacing: 0; + font-size: 0.72rem; + font-weight: 500; +} + .palette-hint { flex-shrink: 0; padding: 0.45rem 0.9rem; diff --git a/src/components/CommandPalette.jsx b/src/components/CommandPalette.jsx index 32107ad..7c03c6d 100644 --- a/src/components/CommandPalette.jsx +++ b/src/components/CommandPalette.jsx @@ -175,8 +175,12 @@ function CommandPalette({ items, onClose, modes = null, initialQuery = '' }) { > {Icon && } {item.label} - {(item.detail ?? item.section) && ( - {item.detail ?? item.section} + {/* A mode row's detail (a file name and line, a symbol's kind and line) keeps its + case; the section labels of the main list are small caps. */} + {item.detail != null ? ( + {item.detail} + ) : ( + item.section && {item.section} )} ); From da1d2c65df0301c5c1aadf7b7c697ac11f1683e4 Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 07:37:38 -0700 Subject: [PATCH 10/28] Main process shell: app menu, locked navigation, fate-local hardening, spellcheck privacy Links and navigation (C1, low: target=_blank and mailto:) - will-navigate allows only the exact entry document (dev: the Vite origin); a relative link such as [x](CONTRIBUTING.md) no longer replaces the app. - One link policy (electron/linkSecurity.cjs) for navigations, window.open, the print preview and the new 'open-external' IPC: http(s) after the existing confirmation, mailto: directly, everything else refused. - The print preview gets a navigation guard and its own menu (Ctrl+W). Application menu (C2) - electron/appMenu.cjs: File, Edit, View, Help with Open Recent; no Reload or Force Reload; DevTools only when unpackaged. Renderer items send 'menu-command' and show the user's bindings from 'update-menu' as display-only labels. Rebuilt on 'update-menu' and 'fate-recents-changed'. - will-prevent-unload asks before a blocked reload or close; after the save-or-discard walk the close goes ahead without asking twice. - Check for Updates shares the IPC's logic and reports why nothing happens. fate-local (C5): network, relative and non-image paths are refused before any file system access; the scheme drops supportFetchAPI, bypassCSP and stream. Single instance (M11): lock taken right after the userData override; the losing instance does no whenReady work and creates no window. Every argument opens, relative to the sender's working directory, file: URIs included, and a handoff that arrives while the page loads is queued. Spellcheck: off by default outside Windows. Measured on Electron 42, only an empty language list stops the Hunspell download from Google's servers; setSpellCheckerEnabled(false) and webPreferences.spellcheck do not. New 'set-spellcheck' IPC; suggestions and Add to Dictionary in the context menu. Also: updater promises caught (M13), stale temp files removed at launch, one in-flight font enumeration, show-item-in-folder and show-tab-context-menu IPC, safeFileStem regex without control-character literals. Unit tests: node --test (60 tests for the five new pure modules). Co-Authored-By: Claude Opus 5.5 --- electron/appMenu.cjs | 308 +++++++++++++++++++ electron/launchArgs.cjs | 41 +++ electron/linkSecurity.cjs | 170 ++++++++++ electron/main.cjs | 614 +++++++++++++++++++++++++++++++------ electron/spellcheck.cjs | 119 +++++++ electron/tempCleanup.cjs | 54 ++++ test/appMenu.test.cjs | 279 +++++++++++++++++ test/launchArgs.test.cjs | 47 +++ test/linkSecurity.test.cjs | 197 ++++++++++++ test/spellcheck.test.cjs | 134 ++++++++ test/tempCleanup.test.cjs | 75 +++++ 11 files changed, 1952 insertions(+), 86 deletions(-) create mode 100644 electron/appMenu.cjs create mode 100644 electron/launchArgs.cjs create mode 100644 electron/linkSecurity.cjs create mode 100644 electron/spellcheck.cjs create mode 100644 electron/tempCleanup.cjs create mode 100644 test/appMenu.test.cjs create mode 100644 test/launchArgs.test.cjs create mode 100644 test/linkSecurity.test.cjs create mode 100644 test/spellcheck.test.cjs create mode 100644 test/tempCleanup.test.cjs diff --git a/electron/appMenu.cjs b/electron/appMenu.cjs new file mode 100644 index 0000000..b801328 --- /dev/null +++ b/electron/appMenu.cjs @@ -0,0 +1,308 @@ +'use strict'; +/** + * appMenu.cjs: FATE's application menu (File, Edit, View, Help; Alt shows it), the tab strip's + * right-click menu, and the print preview's menu. Templates only: main.cjs builds them with + * Electron's Menu. Free of Electron imports; test/appMenu.test.cjs runs it under plain node. + * + * ── Why FATE has a menu at all ──────────────────────────────────────────────────────────────── + * Up to 1.13.4 it set none, so Electron installed its default one. autoHideMenuBar hid it, but its + * accelerators stayed live: Ctrl+R and Ctrl+Shift+R reloaded the window, and a reload is not a + * close, so the unsaved-changes guard never ran and every unsaved edit (Untitled buffers included) + * was gone for good. This menu has NO Reload or Force Reload anywhere, and Toggle Developer Tools + * only in unpackaged builds. + * + * ── Who handles the keys ────────────────────────────────────────────────────────────────────── + * The renderer owns every rebindable shortcut (App.jsx's keydown handler, CodeMirror's keymaps), + * so items it implements are DISPLAY-ONLY: `registerAccelerator: false`, a click sends + * 'menu-command' with the item's id, and the label shows the user's own binding, which the + * renderer reports through 'update-menu'. Registering them as well would run a command twice, or + * run the old binding after a rebind. + * + * Cut, Copy, Paste and Select All are display-only too. On Windows and Linux Chromium already maps + * Ctrl+X/C/V/A inside the page (and CodeMirror and FATE's reading-view Ctrl+A take theirs first), + * so a registered role accelerator could only ever fire as a second handler for a key the page + * passed on. Zoom, full screen and Exit keep their registered accelerators: nothing in the page + * handles those keys, and Electron only fires a menu accelerator for a key the page did not + * consume (measured on Electron 42), so a user binding on the same key still wins. + */ + +const RELEASES_URL = 'https://github.com/VagueDustin/FATE/releases'; +const ISSUES_URL = 'https://github.com/VagueDustin/FATE/issues/new/choose'; + +/** Ids the renderer implements; a click sends ('menu-command', id). The contract in preload.cjs. */ +const MENU_COMMANDS = Object.freeze([ + 'newFile', 'openFile', 'save', 'saveAs', 'print', 'exportPdf', 'closeTab', 'toggleEdit', + 'toggleSplit', 'focusMode', 'palette', 'settings', 'shortcuts', 'about', 'find', 'gotoLine', + 'gotoSymbol', 'undo', 'redo' +]); + +/** + * Keys that are fixed rather than rebindable (FIXED_SHORTCUTS in src/settingsMeta.js, plus + * CodeMirror's history keys). Shown unless 'update-menu' sends a binding for the same id, so the + * day one of them becomes rebindable the renderer's value takes over without a change here. + */ +const FIXED_BINDINGS = Object.freeze({ undo: 'Control+Z', redo: 'Control+Y', find: 'Control+F' }); + +/** Labels are display-only, so a binding is short; anything longer is not one FATE recorded. */ +const MAX_BINDING_LENGTH = 64; + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + SHORTCUT LABELS + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +const MODIFIERS = { Control: 'Ctrl', Shift: 'Shift', Alt: 'Alt', Meta: 'Super' }; + +/** `KeyboardEvent.key` names (what App.jsx records) → Electron accelerator key codes. */ +const NAMED_KEYS = { + Escape: 'Esc', Esc: 'Esc', Enter: 'Enter', Return: 'Enter', Tab: 'Tab', Space: 'Space', + Backspace: 'Backspace', Delete: 'Delete', Insert: 'Insert', Home: 'Home', End: 'End', + PageUp: 'PageUp', PageDown: 'PageDown', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', + ArrowRight: 'Right', Up: 'Up', Down: 'Down', Left: 'Left', Right: 'Right', Plus: 'Plus', + PrintScreen: 'PrintScreen' +}; + +/** Printable keys Electron's accelerator parser knows (US layout), `+` spelled as Plus. */ +const PUNCTUATION_KEYS = new Set(Array.from(')!@#$%^&*(:;=<,_->.?/~`{][|\\}"\'')); + +/** + * FATE's binding format (`Control+Shift+S`: modifiers then `KeyboardEvent.key`, joined with `+`) + * → an Electron accelerator (`Ctrl+Shift+S`), or null when it cannot be shown. + * + * Null means "no label", never an error: a dead key, a non-ASCII key (`é`, `ß`) or anything + * malformed simply shows no shortcut, which is better than a wrong one. The renderer still + * handles the key itself either way. A literal `+` key arrives as `Control++` (App.jsx joins with + * the separator it records); it is read as the Plus key here. + */ +function bindingToAccelerator(binding) { + if (typeof binding !== 'string' || !binding || binding.length > MAX_BINDING_LENGTH) return null; + let mods; + let key; + if (binding === '+') { + mods = []; + key = '+'; + } else if (binding.endsWith('++')) { + const head = binding.slice(0, -2); + mods = head ? head.split('+') : []; + key = '+'; + } else { + mods = binding.split('+'); + key = mods.pop(); + } + + const out = []; + for (const mod of mods) { + const name = MODIFIERS[mod]; + if (!name || out.includes(name)) return null; + out.push(name); + } + + let code = null; + if (key === '+') code = 'Plus'; + else if (Object.prototype.hasOwnProperty.call(NAMED_KEYS, key)) code = NAMED_KEYS[key]; + else if (/^F([1-9]|1[0-9]|2[0-4])$/.test(key)) code = key; + else if (/^[a-zA-Z0-9]$/.test(key)) code = key.toUpperCase(); + else if (key.length === 1 && PUNCTUATION_KEYS.has(key)) code = key; + if (!code) return null; + + out.push(code); + return out.join('+'); +} + +/** + * The 'update-menu' payload's shortcuts, kept to what the menu can use: known ids, short strings. + * The renderer is the only sender, but its values end up in native menu labels. + */ +function sanitizeMenuShortcuts(shortcuts) { + const clean = {}; + if (!shortcuts || typeof shortcuts !== 'object' || Array.isArray(shortcuts)) return clean; + for (const id of MENU_COMMANDS) { + const value = shortcuts[id]; + if (typeof value === 'string' && value.length <= MAX_BINDING_LENGTH) clean[id] = value; + } + return clean; +} + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + LABELS + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** + * Menu labels on Windows and Linux treat `&` as the mnemonic marker: `R&D notes.md` would show as + * "RD notes.md" with an underlined D. Text that is not ours (file names, spelling suggestions) + * doubles it, which displays a literal `&`. + */ +function escapeMenuLabel(text) { + return String(text).replace(/&/g, '&&'); +} + +/** A recent file as the menu shows it: the full path (two `notes.md` stay apart), `~` for home. */ +function recentFileLabel(filePath, { homeDir = '', platform = process.platform, maxLength = 80 } = {}) { + let shown = String(filePath); + if (platform !== 'win32' && homeDir && (shown === homeDir || shown.startsWith(`${homeDir}/`))) { + shown = `~${shown.slice(homeDir.length)}`; + } + // Long paths keep their start (drive, home) and their end (the file name), not the middle. + if (shown.length > maxLength) { + const tail = Math.ceil((maxLength - 1) * 0.65); + shown = `${shown.slice(0, maxLength - 1 - tail)}…${shown.slice(-tail)}`; + } + return escapeMenuLabel(shown); +} + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + TEMPLATES + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** + * The application menu. + * + * `state`: { shortcuts (from 'update-menu'), recentFiles (paths, newest first), isPackaged, + * platform, homeDir }. `actions`: { command(id), openRecent(path), clearRecent(), + * checkForUpdates(), openUrl(url) }, which main.cjs wires to IPC, file opening and the updater. + */ +function buildAppMenuTemplate(state, actions) { + const { shortcuts = {}, recentFiles = [], isPackaged = true, platform = process.platform, homeDir = '' } = state || {}; + const separator = { type: 'separator' }; + + /** An item the renderer implements: display-only shortcut, click → 'menu-command'. */ + const command = (label, id) => { + const accelerator = bindingToAccelerator(shortcuts[id] ?? FIXED_BINDINGS[id]); + return { + id, + label, + ...(accelerator ? { accelerator } : {}), + registerAccelerator: false, + click: () => actions.command(id) + }; + }; + + const recentItems = recentFiles + .filter((p) => typeof p === 'string' && p) + .map((filePath, i) => ({ + id: `recent${i}`, + label: recentFileLabel(filePath, { homeDir, platform }), + click: () => actions.openRecent(filePath) + })); + const openRecent = recentItems.length + ? [...recentItems, separator, { id: 'clearRecent', label: 'Clear Recently Opened', click: () => actions.clearRecent() }] + : [ + { id: 'noRecent', label: 'No Recent Files', enabled: false }, + separator, + { id: 'clearRecent', label: 'Clear Recently Opened', enabled: false } + ]; + + return [ + { + label: '&File', + submenu: [ + command('&New', 'newFile'), + command('&Open…', 'openFile'), + { id: 'openRecent', label: 'Open &Recent', submenu: openRecent }, + separator, + command('&Save', 'save'), + command('Save &As…', 'saveAs'), + separator, + command('Print Pre&view…', 'print'), + command('&Export as PDF…', 'exportPdf'), + separator, + command('&Close Tab', 'closeTab'), + separator, + command('Se&ttings…', 'settings'), + separator, + // Goes through the window's close guard like the title-bar X, so unsaved work is asked + // about first. The role keeps Ctrl+Q on Linux and nothing on Windows, where Alt+F4 is it. + { id: 'exit', role: 'quit', label: 'E&xit' } + ] + }, + { + label: '&Edit', + submenu: [ + // CodeMirror keeps its own history, so these are commands, not the undo/redo roles + // (which would run Chromium's document-level undo underneath the editor). + command('&Undo', 'undo'), + command('&Redo', 'redo'), + separator, + { id: 'cut', role: 'cut', label: 'Cu&t', registerAccelerator: false }, + { id: 'copy', role: 'copy', label: '&Copy', registerAccelerator: false }, + { id: 'paste', role: 'paste', label: '&Paste', registerAccelerator: false }, + { id: 'selectAll', role: 'selectAll', label: 'Select &All', registerAccelerator: false }, + separator, + command('&Find…', 'find'), + command('Go to &Line…', 'gotoLine'), + command('Go to S&ymbol…', 'gotoSymbol') + ] + }, + { + label: '&View', + submenu: [ + command('Edit/View &Markdown', 'toggleEdit'), + command('&Split View', 'toggleSplit'), + command('&Focus Mode', 'focusMode'), + separator, + command('&Command Palette…', 'palette'), + separator, + { id: 'resetZoom', role: 'resetZoom', label: '&Actual Size' }, + { id: 'zoomIn', role: 'zoomIn', label: 'Zoom &In' }, + // The role's accelerator is Ctrl+Plus, which on most layouts needs Shift; Ctrl+= is the + // key people press. Hidden items' accelerators still fire on Windows and Linux. + { id: 'zoomInEquals', role: 'zoomIn', accelerator: 'CommandOrControl+=', visible: false }, + { id: 'zoomOut', role: 'zoomOut', label: 'Zoom &Out' }, + separator, + { id: 'fullScreen', role: 'togglefullscreen', label: 'Toggle F&ull Screen' }, + ...(isPackaged ? [] : [separator, { id: 'devTools', role: 'toggleDevTools', label: 'Toggle &Developer Tools' }]) + ] + }, + { + label: '&Help', + submenu: [ + command('&Keyboard Shortcuts', 'shortcuts'), + separator, + { id: 'releaseNotes', label: '&Release Notes', click: () => actions.openUrl(RELEASES_URL) }, + { id: 'reportIssue', label: 'Report an &Issue', click: () => actions.openUrl(ISSUES_URL) }, + separator, + { id: 'checkForUpdates', label: 'Check for &Updates', click: () => actions.checkForUpdates() }, + separator, + command('&About FATE', 'about') + ] + } + ]; +} + +/** + * A tab's right-click menu. `pick(action)` returns the click handler that reports that action; + * the renderer carries it out (copying the path, revealing the file, closing tabs), so main only + * ever says which item was chosen. Path items are disabled for a tab with no file (Untitled). + */ +function buildTabMenuTemplate({ hasPath }, pick) { + return [ + { id: 'copyPath', label: 'Copy Full Path', enabled: !!hasPath, click: pick('copyPath') }, + { id: 'reveal', label: 'Open Containing Folder', enabled: !!hasPath, click: pick('reveal') }, + { type: 'separator' }, + { id: 'close', label: 'Close', click: pick('close') }, + { id: 'closeOthers', label: 'Close Others', click: pick('closeOthers') }, + { id: 'closeRight', label: 'Close Tabs to the Right', click: pick('closeRight') } + ]; +} + +/** + * The print preview window's menu. Without one it would inherit the application menu, whose + * commands all act on the main window, and lose Ctrl+W, which closed the preview through + * Electron's default menu before FATE had its own. + */ +function buildPreviewMenuTemplate() { + return [{ label: '&File', submenu: [{ id: 'closePreview', role: 'close', label: '&Close Preview' }] }]; +} + +module.exports = { + MENU_COMMANDS, + FIXED_BINDINGS, + RELEASES_URL, + ISSUES_URL, + bindingToAccelerator, + sanitizeMenuShortcuts, + escapeMenuLabel, + recentFileLabel, + buildAppMenuTemplate, + buildTabMenuTemplate, + buildPreviewMenuTemplate +}; diff --git a/electron/launchArgs.cjs b/electron/launchArgs.cjs new file mode 100644 index 0000000..1932436 --- /dev/null +++ b/electron/launchArgs.cjs @@ -0,0 +1,41 @@ +'use strict'; +/** + * launchArgs.cjs: which command-line arguments might name files to open, as absolute paths. + * + * A shape filter only: main.cjs's isOpenableArg then keeps the ones that are existing regular + * files (and not FATE's own entry script), and openAndWatchFile decides whether each is text. + * + * - Flags (`--ozone-platform=x11`, Chromium's own `--original-process-start-time=…`) are skipped. + * argv[0] is the executable. + * - Relative paths resolve against `cwd`. For a SECOND instance that must be the second + * process's working directory (the 'second-instance' event's `workingDirectory`): resolving + * against the running instance's folder opened the wrong file, or none. + * - `file:` URIs become paths. FATE's Linux desktop entries launch it with `%U`, and file + * managers may pass local files as `file:///home/me/notes.md`; those used to fail the stat in + * isOpenableArg and open nothing. + * + * Free of Electron imports; test/launchArgs.test.cjs covers Windows-style paths under plain node. + */ + +const path = require('path'); +const { fileURLToPath } = require('url'); + +function candidateFilePaths(argv, { cwd, platform = process.platform } = {}) { + const p = platform === 'win32' ? path.win32 : path.posix; + const paths = []; + for (const arg of (Array.isArray(argv) ? argv : []).slice(1)) { + if (typeof arg !== 'string' || !arg || arg.startsWith('-') || arg.includes('\0')) continue; + let filePath = arg; + if (/^file:/i.test(arg)) { + try { + filePath = fileURLToPath(arg, { windows: platform === 'win32' }); + } catch { + continue; // a remote host on Linux, or not a valid file URL at all + } + } + paths.push(cwd ? p.resolve(cwd, filePath) : p.resolve(filePath)); + } + return paths; +} + +module.exports = { candidateFilePaths }; diff --git a/electron/linkSecurity.cjs b/electron/linkSecurity.cjs new file mode 100644 index 0000000..2176f77 --- /dev/null +++ b/electron/linkSecurity.cjs @@ -0,0 +1,170 @@ +'use strict'; +/** + * linkSecurity.cjs: which URLs FATE's windows may navigate to, which links FATE hands to the + * operating system, and which files the fate-local:// image protocol may serve. + * + * All three answer the same question: a Markdown document is untrusted input, so what may a link + * or an image in one make the app do? Free of Electron imports; test/linkSecurity.test.cjs runs it + * under plain node, Windows-style paths included (every function takes the platform explicitly). + */ + +const path = require('path'); + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + NAVIGATION + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** + * May a window navigate to `rawUrl`? Only to the one document it was opened on. + * + * `allowed` is `{ devServerUrl }` (dev: anything on the Vite server's origin, so its full-page + * reloads keep working) or `{ entryUrl }` (the exact file URL, ignoring a #hash). + * + * Up to 1.13.4 the main window allowed anything under the `dist/` folder's URL. A relative link in + * a document, `[Contributing](CONTRIBUTING.md)`, resolves against the PAGE, not the document, so it + * became `…/dist/CONTRIBUTING.md`: inside the prefix, allowed, and the whole app was replaced by + * an ERR_FILE_NOT_FOUND page with every tab and unsaved edit gone. + * + * File URLs are compared by host and decoded path rather than as strings, so the percent-encoding + * Chromium chooses for a navigation (an install path with spaces or accents) cannot make the app's + * own URL look foreign; on Windows, case-insensitively, like the file system. + */ +function isAllowedNavigation(rawUrl, allowed, platform = process.platform) { + const target = parseUrl(rawUrl); + if (!target) return false; + if (allowed.devServerUrl) { + const dev = parseUrl(allowed.devServerUrl); + return !!dev && target.origin === dev.origin; + } + const entry = parseUrl(allowed.entryUrl); + if (!entry || target.protocol !== 'file:' || entry.protocol !== 'file:') return false; + if (target.search !== entry.search) return false; // `index.html?…` is a different document + const key = fileUrlKey(target, platform); + return key !== null && key === fileUrlKey(entry, platform); +} + +/** Comparable identity of a file URL: host (UNC installs have one) plus the decoded path. */ +function fileUrlKey(url, platform) { + let decoded; + try { + decoded = decodeURIComponent(url.pathname); + } catch { + return null; + } + const key = `${url.hostname}|${decoded}`; + return platform === 'win32' ? key.replace(/\\/g, '/').toLowerCase() : key; +} + +function parseUrl(raw) { + if (typeof raw !== 'string') return null; + try { + return new URL(raw); + } catch { + return null; + } +} + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + EXTERNAL LINKS + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** Longer than any real link; refuses pathological input before it reaches a dialog or the OS. */ +const MAX_EXTERNAL_URL_LENGTH = 8192; + +/** + * How a link may leave FATE: `{ kind: 'web', url }` for http(s), which the caller opens only + * after the user confirms; `{ kind: 'mailto', url }`, opened directly because all it can do is + * open a compose window; or null, refused. + * + * Refused on purpose: `file:` (the OS would OPEN or RUN the target: a document can link to + * `file:///C:/Users/me/Downloads/setup.exe`), `javascript:`/`data:`/`blob:` (meaningless outside + * the page), and every other scheme, because custom schemes (`ms-settings:`, `steam://`, + * `vscode://`, `search-ms:`) launch other applications with arguments the document chose. + * + * `url` is the parser's canonical form, so whitespace or control characters around the input + * never reach the confirmation dialog or `shell.openExternal`. + */ +function classifyExternalUrl(rawUrl) { + if (typeof rawUrl !== 'string' || rawUrl.length > MAX_EXTERNAL_URL_LENGTH) return null; + const url = parseUrl(rawUrl); + if (!url) return null; + if (url.protocol === 'http:' || url.protocol === 'https:') { + return url.hostname ? { kind: 'web', url: url.href } : null; + } + if (url.protocol === 'mailto:') return { kind: 'mailto', url: url.href }; + return null; +} + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + LOCAL IMAGES (fate-local://) + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** What the preview may load through fate-local: images, and nothing a page script could read. */ +const LOCAL_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng']); + +/** Windows device names: `C:\x\COM1.png` opens the serial port, not a file, whatever the extension. */ +const WINDOWS_DEVICE_NAME = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/i; + +/** + * Map a fate-local:// request to the file it may serve: `{ ok: true, filePath }`, or + * `{ ok: false, status, reason }` for the handler to answer with. + * + * URL shape (built in src/markdown.js): `fate-local://local/`, + * `/C%3A/Users/…` on Windows and `/home/…` elsewhere. Refused: + * + * - NETWORK PATHS. `![](//attacker.example/share/x.png)` used to become a fate-local URL for + * `//attacker.example/share/x.png`, and fetching that on Windows opens an SMB connection that + * hands the user's NTLM hash to the attacker's machine. Merely opening the document was enough. + * Any path starting with two slashes of either kind is refused before anything touches the + * file system, and on Windows so is any path whose root is a UNC or device root (`\\server\`, + * `\\?\`, `\\.\`), which also catches mixed spellings like `/\server/share`. + * - RELATIVE PATHS. The renderer only ever sends absolute ones; on Windows that means a drive + * letter (`\x.png` would resolve against whatever the current drive is). + * - ANYTHING NOT AN IMAGE. The handler used to serve every local file to anything in the page, + * `fetch()` included, so one sanitiser bypass could read `~/.ssh/id_rsa`. Now it serves images + * by extension only (and the scheme no longer supports the Fetch API at all). + */ +function resolveLocalImagePath(rawUrl, platform = process.platform) { + const refuse = (status, reason) => ({ ok: false, status, reason }); + const url = parseUrl(rawUrl); + if (!url || url.protocol !== 'fate-local:' || url.hostname !== 'local') return refuse(400, 'Bad fate-local URL'); + + let filePath; + try { + filePath = decodeURIComponent(url.pathname); + } catch { + return refuse(400, 'Bad fate-local URL'); + } + if (filePath.includes('\0')) return refuse(400, 'Bad fate-local URL'); + + // '/C:/Users/…' → 'C:/Users/…'. POSIX paths keep their leading slash. + if (platform === 'win32' && /^\/[a-zA-Z]:/.test(filePath)) filePath = filePath.slice(1); + if (/^[\\/]{2}/.test(filePath)) return refuse(403, 'Network paths are not served'); + + const p = platform === 'win32' ? path.win32 : path.posix; + if (platform === 'win32') { + filePath = p.normalize(filePath.replace(/\//g, '\\')); + if (p.parse(filePath).root.startsWith('\\\\')) return refuse(403, 'Network paths are not served'); + if (!/^[a-zA-Z]:\\/.test(filePath)) return refuse(403, 'Only absolute paths are served'); + } else { + if (!p.isAbsolute(filePath)) return refuse(403, 'Only absolute paths are served'); + filePath = p.normalize(filePath); + } + + // extname ignores a trailing separator, so `x.png/` (a folder, or nothing) would pass it. + const ext = /[\\/]$/.test(filePath) ? '' : p.extname(filePath).slice(1).toLowerCase(); + if (!LOCAL_IMAGE_EXTENSIONS.has(ext)) return refuse(403, 'Only images are served'); + // Windows matches device names on the part before the FIRST dot, trailing spaces ignored. + if (platform === 'win32' && WINDOWS_DEVICE_NAME.test(p.basename(filePath).split('.')[0].replace(/ +$/, ''))) { + return refuse(403, 'Only images are served'); + } + return { ok: true, filePath }; +} + +module.exports = { + isAllowedNavigation, + classifyExternalUrl, + resolveLocalImagePath, + LOCAL_IMAGE_EXTENSIONS, + MAX_EXTERNAL_URL_LENGTH +}; diff --git a/electron/main.cjs b/electron/main.cjs index e2d26c7..e3756de 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain, shell, protocol, dialog, net, Menu, clipboard } = require('electron'); +const { app, BrowserWindow, ipcMain, shell, protocol, dialog, net, Menu, clipboard, session } = require('electron'); const path = require('path'); const fs = require('fs'); const { execFile } = require('child_process'); @@ -6,6 +6,11 @@ const { autoUpdater } = require('electron-updater'); const { pathToFileURL } = require('url'); const Store = require('electron-store'); const { describeFsError } = require('./fsAccess.cjs'); +const { isAllowedNavigation, classifyExternalUrl, resolveLocalImagePath } = require('./linkSecurity.cjs'); +const appMenu = require('./appMenu.cjs'); +const { createSpellcheckController, resolveSpellcheckSetting } = require('./spellcheck.cjs'); +const { removeStaleTempFiles } = require('./tempCleanup.cjs'); +const { candidateFilePaths } = require('./launchArgs.cjs'); /** * ProgId that electron-builder's NSIS installer actually registers for `.md`. @@ -164,6 +169,20 @@ if (process.env.FATE_USER_DATA) { app.setPath('userData', process.env.FATE_USER_DATA); } +/* + * One FATE per profile. Opening a file while FATE runs starts a second process, which hands its + * command line to the first ('second-instance', at the bottom of this file) and quits. + * + * Taken HERE, as early as possible: the lock lives in userData, so after the override above, and + * before anything below starts real work. Up to 1.13.4 it was taken at the very end of the file, + * after app.whenReady() had been wired up, and app.quit() before 'ready' does not stop 'ready' + * from firing. So the losing instance still created a window and ran the Windows registration + * self-heal (a reg import) and the association repair (PowerShell) before it went away. Now the + * whenReady work returns at once without the lock; see the first line there. + */ +const gotTheLock = app.requestSingleInstanceLock(); +if (!gotTheLock) app.quit(); + const store = new Store({ defaults: { // 'fate' = VagueDustin Enterprises navy & gold (utility tier), the default since 1.5.0. @@ -181,7 +200,10 @@ const store = new Store({ // Page setup for print preview and PDF export. 'Letter' rather than 'A4' because the app is // Windows-only and US Letter is the more common default there; both are offered in Settings. printPageSize: 'Letter', - printLandscape: false + printLandscape: false, + // On where it is free (Windows' own checker); off elsewhere, where turning it on downloads a + // dictionary from Google's servers. See electron/spellcheck.cjs. + spellcheck: process.platform === 'win32' } }); @@ -833,7 +855,8 @@ function safeFileStem(name) { // Strip the final extension whatever it is: "script.ps1" should export as "script.pdf", // not "script.ps1.pdf". (Was markdown-only before code files existed.) .replace(/\.[a-z0-9]{1,10}$/i, '') - .replace(/[<>:"/\\|?*\x00-\x1f]/g, '-') + // Characters Windows refuses in file names, and control characters (\p{Cc}: C0, DEL, C1). + .replace(/[<>:"/\\|?*\p{Cc}]/gu, '-') .replace(/\s+/g, ' ') .trim() .slice(0, 120) || 'document' @@ -886,11 +909,23 @@ async function showPrintPreview(docName) { } }); - // The preview must not become a browser. Nothing in a PDF should be able to navigate it. + /* + * The preview must not become a browser. Nothing in a PDF should be able to navigate it: a + * link clicked in Chromium's PDF viewer navigates the WINDOW (only new-window links reached the + * handler that used to be the sole guard here), so both routes go through the same policy as + * the main window's links, confirmation for web links included. + */ + const previewUrl = pathToFileURL(file).toString(); previewWindow.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http')) shell.openExternal(url); + openExternalLink(url, previewWindow); return { action: 'deny' }; }); + previewWindow.webContents.on('will-navigate', (event, url) => { + if (isAllowedNavigation(url, { entryUrl: previewUrl })) return; + event.preventDefault(); + openExternalLink(url, previewWindow); + }); + previewWindow.setMenu(Menu.buildFromTemplate(appMenu.buildPreviewMenuTemplate())); previewWindow.on('closed', () => { previewWindow = null; @@ -919,7 +954,7 @@ async function showPrintPreview(docName) { shell.openPath(file); }); - previewWindow.loadURL(pathToFileURL(file).toString()); + previewWindow.loadURL(previewUrl); return { ok: true, reused: false }; } @@ -941,8 +976,18 @@ async function exportPdf(docName) { return { ok: true, filePath }; } +/* + * fate-local:// serves the images a Markdown document references from disk (handler in + * app.whenReady). Its privileges are what needs and nothing more: + * standard real URL parsing (host, relative resolution); see the `local` host note at the + * handler for why that matters. + * secure counts as a secure origin, so the images are never mixed content. + * Dropped in 1.14.0: `supportFetchAPI`, which let any script in the page fetch() and READ any + * local file through this scheme; `bypassCSP`, since the page's CSP lists fate-local: in img-src; + * and `stream`, which is for media range requests, not images. + */ protocol.registerSchemesAsPrivileged([ - { scheme: 'fate-local', privileges: { bypassCSP: true, supportFetchAPI: true, secure: true, standard: true, stream: true } } + { scheme: 'fate-local', privileges: { secure: true, standard: true } } ]); const isDev = process.env.NODE_ENV === 'development'; @@ -1047,7 +1092,14 @@ function createWindow() { webPreferences: { preload: path.join(__dirname, 'preload.cjs'), nodeIntegration: false, - contextIsolation: true + contextIsolation: true, + /* + * Always true: the `spellcheck` SETTING is applied to the session (see electron/spellcheck.cjs, + * which also explains why this flag cannot be what turns it off). A window created with + * false never marks misspellings, even once the session is enabled, so the setting could + * only take effect after a restart. + */ + spellcheck: true }, autoHideMenuBar: true }); @@ -1066,11 +1118,19 @@ function createWindow() { * `closeConfirmed` is what lets the second close() through; this handler runs again for it. The * timeout is the escape hatch for a renderer that never answers (mid-crash, or a stuck dialog); * without it the window would be unclosable. + * + * `closeWalkDone` (the user answered the walk) and `closing` (a close is under way) are for the + * renderer's beforeunload guard; see 'will-prevent-unload' below. */ let closeConfirmed = false; + let closeWalkDone = false; + let closing = false; let closeHandoffTimer = null; mainWindow.on('close', (e) => { - if (closeConfirmed || !documentEdited) return; + if (closeConfirmed || !documentEdited) { + closing = true; + return; + } e.preventDefault(); clearTimeout(closeHandoffTimer); closeHandoffTimer = setTimeout(() => { @@ -1087,20 +1147,71 @@ function createWindow() { clearTimeout(closeHandoffTimer); if (!proceed) return; closeConfirmed = true; + closeWalkDone = true; documentEdited = false; if (mainWindow && !mainWindow.isDestroyed()) mainWindow.close(); }); + /* + * The renderer's beforeunload guard asked to keep the page: Chromium would show its own + * "Leave site?" box, which Electron replaces with this event. Without a handler the unload is + * simply cancelled, so a reload (DevTools, a dev-server restart) silently does nothing. + * + * After the close walk the user has already said what to do with every dirty tab, so the close + * goes ahead without asking twice. Anything else asks, and must ask synchronously: the answer is + * read when this handler returns. The wording follows what is being lost: a window closing + * without the walk (the 60-second escape hatch, or a close that raced the renderer's dirty + * flag), or a reload, which is the only other way this page unloads now that navigation is + * locked to the app's own URL and the menu has no Reload. + */ + mainWindow.webContents.on('will-prevent-unload', (event) => { + const wasClosing = closing; + closing = false; + if (closeWalkDone) { + event.preventDefault(); + return; + } + const choice = dialog.showMessageBoxSync(mainWindow, { + type: 'warning', + buttons: ['Discard changes', 'Cancel'], + defaultId: 1, + cancelId: 1, + title: 'Unsaved changes', + message: wasClosing ? 'Discard unsaved changes and close?' : 'Discard unsaved changes and reload?', + detail: 'Your edits have not been saved.' + }); + if (choice === 0) event.preventDefault(); + else closeConfirmed = false; + }); + /* * Right-click menu. Electron ships none, so right-clicking did nothing anywhere in FATE: no Copy * in the reading view, no Cut or Paste in the editor. The roles run Chromium's own editing * commands, so Copy fires the same 'copy' event as Ctrl+C (the preview's clean copy still * applies; see src/previewClipboard.js), and the edit flags grey out whatever doesn't apply where * the click landed. No Undo/Redo: CodeMirror keeps its own history, which those would bypass. + * + * On a misspelled word (only while the spellcheck setting is on) the dictionary's suggestions + * come first, as in every editor: a click replaces the word in place, through the same editing + * command a typed correction would use, so CodeMirror sees an ordinary edit (and can undo it). */ mainWindow.webContents.on('context-menu', (_event, params) => { - const { editFlags, isEditable, selectionText, linkURL } = params; + const { editFlags, isEditable, selectionText, linkURL, misspelledWord, dictionarySuggestions } = params; const groups = []; + if (misspelledWord) { + const wc = mainWindow.webContents; + const suggestions = (dictionarySuggestions || []).slice(0, 8).map((word) => ({ + label: appMenu.escapeMenuLabel(word), + click: () => wc.replaceMisspelling(word) + })); + groups.push([ + ...(suggestions.length ? suggestions : [{ label: 'No Spelling Suggestions', enabled: false }]), + { + label: 'Add to Dictionary', + click: () => wc.session.addWordToSpellCheckerDictionary(misspelledWord) + } + ]); + } if (/^https?:/i.test(linkURL)) { groups.push([{ label: 'Copy Link Address', click: () => clipboard.writeText(linkURL) }]); } @@ -1121,53 +1232,348 @@ function createWindow() { Menu.buildFromTemplate(template).popup({ window: mainWindow }); }); - // SECURITY: Prevent inner navigation and force external links to open in default browser + /* + * SECURITY: the window never becomes a browser. New-window requests (target=_blank links, + * window.open) and navigations both go through openExternalLink: web links after the user + * confirms (until 1.13.4 a target=_blank link skipped the confirmation a plain click got), mail + * links straight to the mail client, everything else refused. + */ mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http')) { - shell.openExternal(url); - } + openExternalLink(url, mainWindow); return { action: 'deny' }; }); - mainWindow.webContents.on('will-navigate', async (event, url) => { + mainWindow.webContents.on('will-navigate', (event, url) => { /* - * The window may only ever navigate to ITSELF (dev server or the packaged dist). The old - * check allowed any file:// URL, which meant a file dropped outside a drop handler made - * Chromium NAVIGATE the whole app to that file: the "drag & drop doesn't work" bug. The - * renderer now preventDefault()s all drops; this is the second line of defence. + * The window may only ever navigate to ITSELF: its exact entry document (dev: the Vite + * server). Up to 1.13.4 this allowed anything under dist/, so a relative link in a document + * (`[x](CONTRIBUTING.md)` resolves against the PAGE, to dist/CONTRIBUTING.md) replaced the + * whole app with an error page and every unsaved edit with it; see isAllowedNavigation. + * Before that it allowed any file:// URL, and a file dropped outside a drop handler navigated + * the app to the file. The renderer handles link clicks and drops itself; this is the second + * line of defence. */ - const appOrigin = isDev - ? 'http://localhost:5173' - : pathToFileURL(path.join(__dirname, '..', 'dist')).toString(); - if (url.startsWith(appOrigin) || url.startsWith('devtools://')) return; - + if (isAllowedNavigation(url, APP_ENTRY)) return; event.preventDefault(); + openExternalLink(url, mainWindow); + }); + + if (isDev) { + mainWindow.loadURL(APP_ENTRY.devServerUrl); + } else { + mainWindow.loadFile(APP_ENTRY_FILE); + } + + /* + * 'app-ready': the renderer is listening for 'open-file' from here on. Open the launch + * arguments (once per run, not again after a reload), then any command lines second instances + * handed over while it was loading. A reload makes the page deaf again until it re-announces. + */ + rendererReady = false; + let launchArgsOpened = false; + ipcMain.removeAllListeners('app-ready'); // createWindow can run again (macOS 'activate') + ipcMain.on('app-ready', (event) => { + if (BrowserWindow.fromWebContents(event.sender) !== mainWindow) return; + rendererReady = true; + if (!launchArgsOpened) { + launchArgsOpened = true; + handleArgs(process.argv, process.cwd()); + } + for (const { argv, cwd } of pendingSecondInstanceArgs.splice(0)) handleArgs(argv, cwd); + }); + // 'did-navigate', not 'did-start-navigation': the latter also fires for the navigations + // will-navigate then refuses, and the page that stays is still listening. + mainWindow.webContents.on('did-navigate', () => { + rendererReady = false; + }); + + // The page's renderer usually starts while the spellcheck dictionary is still loading, and then + // never receives it; re-send it now that the page is up (see electron/spellcheck.cjs). + mainWindow.webContents.on('did-finish-load', () => { + if (spellcheck) spellcheck.refresh(); + }); +} + +/* + * ── Where the main window may go ────────────────────────────────────────────────────────────── + * Its own entry document and nothing else (see will-navigate above). + */ +const APP_ENTRY_FILE = path.join(__dirname, '..', 'dist', 'index.html'); +const APP_ENTRY = isDev + ? { devServerUrl: 'http://localhost:5173' } + : { entryUrl: pathToFileURL(APP_ENTRY_FILE).toString() }; - if (url.startsWith('http://') || url.startsWith('https://')) { - const { response } = await dialog.showMessageBox(mainWindow, { +/** + * Hand a link to the operating system, under the one policy every route shares: the main + * window's navigations and new-window requests, the print preview's, and the renderer's + * 'open-external' (link clicks in the preview). + * + * http(s) after the confirmation dialog: a document's link text can say anything, and this + * shows the address that will actually open. + * mailto: straight to the mail client, which only opens a compose window. + * other refused: file: would open or RUN the target, custom schemes start other apps with + * arguments the document chose (see classifyExternalUrl). + * + * Resolves { ok: true } once handed over, else { ok: false, reason: 'refused' | 'canceled' | + * 'pending' | 'failed' }. 'pending': the same link is already waiting on its dialog, so a + * double-click does not stack two. + */ +const pendingLinkPrompts = new Set(); +async function openExternalLink(rawUrl, parentWindow) { + const link = classifyExternalUrl(rawUrl); + if (!link) return { ok: false, reason: 'refused' }; + + if (link.kind === 'web') { + if (pendingLinkPrompts.has(link.url)) return { ok: false, reason: 'pending' }; + pendingLinkPrompts.add(link.url); + try { + // A runaway URL still has to fit on screen; the browser gets the whole thing. + const shown = link.url.length > 600 ? `${link.url.slice(0, 600)}…` : link.url; + const options = { type: 'warning', buttons: ['Cancel', 'Open Browser'], defaultId: 1, cancelId: 0, title: 'External Link', - message: `You are about to open an external link:\n${url}\n\nDo you want to continue?` - }); - if (response === 1) { - shell.openExternal(url); - } + message: `You are about to open an external link:\n${shown}\n\nDo you want to continue?` + }; + const parent = parentWindow && !parentWindow.isDestroyed() ? parentWindow : null; + const { response } = parent ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options); + if (response !== 1) return { ok: false, reason: 'canceled' }; + } finally { + pendingLinkPrompts.delete(link.url); } - // Anything else (stray file:// navigations included) is silently refused. - }); + } - if (isDev) { - mainWindow.loadURL('http://localhost:5173'); - } else { - mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); + try { + await shell.openExternal(link.url); + return { ok: true }; + } catch (err) { + console.error('Could not open link:', err.message); + return { ok: false, reason: 'failed' }; + } +} + +/* ════════════════════════════════════════════════════════════════════════════════════════════ + APPLICATION SHELL + The application menu, spellcheck, "check for updates", and the IPC the window chrome uses + (links, tab menu, file-manager reveal). Started once from app.whenReady, before the window. + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/** The renderer's shortcut bindings as 'update-menu' last reported them. Labels only. */ +let menuShortcuts = {}; +/** The recents list 'fate-recents-changed' last carried ([{ path, openedAt }]); null: read the store. */ +let menuRecents = null; +let menuRebuildTimer = null; +/** { set(enabled), refresh(), isEnabled() } from electron/spellcheck.cjs, once startAppShell has run. */ +let spellcheck = null; + +/** + * (Re)build the application menu (see electron/appMenu.cjs) from the current bindings and recent + * files: the list 'fate-recents-changed' last carried, or the store's. Neither is checked for + * existence here: a stat per entry on every rebuild is exactly what freezes the main thread when + * one of them is on a disconnected network drive. Open Recent checks the one file clicked. + * + * Menu.setApplicationMenu re-menus EVERY open window, so the print preview gets its own back. + */ +function installAppMenu() { + clearTimeout(menuRebuildTimer); + menuRebuildTimer = null; + const template = appMenu.buildAppMenuTemplate( + { + shortcuts: menuShortcuts, + recentFiles: (menuRecents || store.get('recentFiles') || []).map((entry) => entry && entry.path), + isPackaged: app.isPackaged, + platform: process.platform, + homeDir: app.getPath('home') + }, + { + command: sendMenuCommand, + openRecent: openRecentFromMenu, + clearRecent: clearRecentFromMenu, + checkForUpdates: checkForUpdatesNow, + openUrl: (url) => shell.openExternal(url).catch(() => {}) + } + ); + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + if (previewWindow && !previewWindow.isDestroyed()) { + previewWindow.setMenu(Menu.buildFromTemplate(appMenu.buildPreviewMenuTemplate())); + } +} + +/** Coalesces bursts (a session restore records every file it opens) into one rebuild. */ +function scheduleAppMenuRebuild() { + if (!menuRebuildTimer) menuRebuildTimer = setTimeout(installAppMenu, 50); +} + +/** A menu item the renderer implements; the ids are listed in preload.cjs (onMenuCommand). */ +function sendMenuCommand(id) { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('menu-command', id); +} + +/** + * File → Open Recent. The home screen greys out a recent file that has gone; a menu cannot, so a + * click on one says so and drops it from the list, as the home screen's own open does. The check is + * asynchronous so a file on an unreachable share cannot freeze the app. Any other failure + * (permissions, snap confinement) is left to openAndWatchFile, which explains it. + */ +async function openRecentFromMenu(filePath) { + try { + await fs.promises.access(filePath); + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { + const remaining = (store.get('recentFiles') || []).filter((e) => watchKey(e.path) !== watchKey(filePath)); + store.set('recentFiles', remaining); + app.emit('fate-recents-changed', remaining); + const options = { + type: 'info', + title: 'File not found', + message: `Can't open ${path.basename(filePath)}: it was moved or deleted.`, + detail: filePath + }; + if (mainWindow && !mainWindow.isDestroyed()) dialog.showMessageBox(mainWindow, options); + else dialog.showMessageBox(options); + return; + } + } + openAndWatchFile(filePath); +} + +/** + * File → Open Recent → Clear Recently Opened. The home screen's list refreshes the next time it + * is shown (there is no push channel for recents). + */ +function clearRecentFromMenu() { + store.set('recentFiles', []); + app.emit('fate-recents-changed', []); +} + +function sendUpdateMessage(message, action = null) { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('update-message', message, action); +} + +/** + * "Check for updates": the status-bar button and the palette (over 'check-for-updates') and + * Help → Check for Updates. The answer arrives through the autoUpdater events, which post to the + * status bar. The promises are caught (M13): electron-updater emits 'error' and THEN rejects, so + * an offline check left an unhandled rejection behind; the download it starts rejects the same way. + */ +function checkForUpdatesNow() { + /* + * Store builds: electron-updater cannot update an AppX; the Store owns that pipeline, and + * the previous behaviour ("Checking for updates…" followed by silence or an error) looked + * broken because it was. Route to the Store's own updates page instead. + */ + if (isWindowsStore) { + shell.openExternal('ms-windows-store://downloadsandupdates').catch(() => {}); + return; + } + // Flatpak / Snap / apt / dnf own updates for this install (see detectUpdateSource). The + // button still does something useful: it shows what the newest release contains. + if (updateSource.managed) { + shell.openExternal('https://github.com/VagueDustin/FATE/releases/latest').catch(() => {}); + return; + } + // Both of these used to do nothing at all, silently, which read as a broken button. + if (isDev || !app.isPackaged) { + sendUpdateMessage('Update checks run only in installed builds.'); + return; } + if (!store.get('autoUpdatesEnabled')) { + sendUpdateMessage('Automatic updates are off. Turn them on in Settings to check for updates.'); + return; + } + autoUpdater + .checkForUpdates() + .then((result) => result?.downloadPromise) + .catch(() => {}); +} + +/** Everything in this section that has to exist before the window does. */ +function startAppShell() { + // Before any window: the language list decides whether a dictionary download starts at all. + spellcheck = createSpellcheckController(session.defaultSession, { + enabled: resolveSpellcheckSetting(store.get('spellcheck'), process.platform), + preferredLanguages: [app.getLocale(), ...app.getPreferredSystemLanguages()], + platform: process.platform, + warn: (message) => console.warn(message) + }); + + installAppMenu(); + // The file I/O code emits this whenever the recent-files list changes, with the new list. + app.on('fate-recents-changed', (list) => { + menuRecents = Array.isArray(list) ? list : null; + scheduleAppMenuRebuild(); + }); - ipcMain.once('app-ready', () => { - handleArgs(process.argv); + /** The renderer's current bindings, for the menu's shortcut labels. Sent on load and on rebinds. */ + ipcMain.on('update-menu', (event, state) => { + if (!mainWindow || event.sender !== mainWindow.webContents) return; + const next = appMenu.sanitizeMenuShortcuts(state && state.shortcuts); + if (JSON.stringify(next) === JSON.stringify(menuShortcuts)) return; + menuShortcuts = next; + scheduleAppMenuRebuild(); + }); + + /** Settings → spellcheck. The renderer stores the choice itself (store.set('spellcheck')). */ + ipcMain.handle('set-spellcheck', (_event, enabled) => { + if (typeof enabled !== 'boolean') return { ok: false, error: 'Spellcheck can only be on or off' }; + spellcheck.set(enabled); + return { ok: true, enabled }; + }); + + /** Link clicks in the preview: the same policy as a navigation. See openExternalLink. */ + ipcMain.handle('open-external', (event, url) => openExternalLink(url, BrowserWindow.fromWebContents(event.sender))); + + /** + * Tab menu → Open Containing Folder. Only an absolute path to an existing regular file: this + * hands the path to the file manager, and the renderer is the one asking. + */ + ipcMain.handle('show-item-in-folder', async (_event, filePath) => { + if (typeof filePath !== 'string' || !filePath || filePath.includes('\0') || !path.isAbsolute(filePath)) { + return { ok: false, error: 'Not a file path' }; + } + const name = path.basename(filePath); + try { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile()) return { ok: false, error: `Can't show ${name}: it is not a file.` }; + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { + return { ok: false, error: `Can't show ${name}: it was moved or deleted.` }; + } + return { ok: false, error: describeFsError(err, filePath, 'open').short }; + } + shell.showItemInFolder(filePath); + return { ok: true }; }); + + /** + * A tab's right-click menu. Resolves the chosen action ('copyPath', 'reveal', 'close', + * 'closeOthers', 'closeRight') or null when dismissed; the renderer carries it out. A chosen + * item's click runs before the menu's close callback, and setImmediate keeps it that way even + * if a platform delivered them in the other order. + */ + ipcMain.handle('show-tab-context-menu', (event, info) => + new Promise((resolve) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) { + resolve(null); + return; + } + let settled = false; + const finish = (action) => { + if (settled) return; + settled = true; + resolve(action); + }; + const hasPath = !!info && typeof info.path === 'string' && info.path !== ''; + const menu = Menu.buildFromTemplate(appMenu.buildTabMenuTemplate({ hasPath }, (action) => () => finish(action))); + menu.popup({ window: win, callback: () => setImmediate(() => finish(null)) }); + }) + ); + + // Temp files a crashed run left behind (tempCleanup.cjs). Later, off the startup path. + setTimeout(() => removeStaleTempFiles(app.getPath('temp')), 10000); } /** @@ -1297,16 +1703,47 @@ async function openAndWatchFile(filePath, opts = {}) { } } -function handleArgs(argv) { +/* + * ── Opening files from a command line ───────────────────────────────────────────────────────── + * Selecting several files in a file manager and choosing Open with FATE passes them all, so every + * openable argument opens, in order (up to 1.13.4 only the first did). Relative paths resolve + * against the working directory of the process that received them; see launchArgs.cjs. + * + * `rendererReady`: 'open-file' sent before the renderer has registered its listener is lost, so a + * second instance's command line that arrives while the window is still loading waits in + * `pendingSecondInstanceArgs` until 'app-ready' (createWindow). + */ +let rendererReady = false; +const pendingSecondInstanceArgs = []; +let openArgsQueue = Promise.resolve(); + +function handleArgs(argv, cwd) { // argv also carries the exe path, the app path in dev, and Chromium switches; see // isOpenableArg for why this is a shape check and not an extension list. - const filePath = argv.slice(1).find(isOpenableArg); - if (filePath) { - openAndWatchFile(filePath); - } + const seen = new Set(); + const files = candidateFilePaths(argv, { cwd }).filter((filePath) => { + const key = watchKey(filePath); + if (seen.has(key) || !isOpenableArg(filePath)) return false; + seen.add(key); + return true; + }); + // One at a time, and one command line after another: the tabs open in the order given, and the + // last file named is the one left in front. + openArgsQueue = openArgsQueue.then(async () => { + for (const filePath of files) { + try { + await openAndWatchFile(filePath); + } catch (err) { + console.error('Could not open', filePath, err); + } + } + }); + return openArgsQueue; } app.whenReady().then(() => { + if (!gotTheLock) return; // a second instance: it handed its files over and is quitting (top of file) + // Register custom protocol for local images /* * Local images referenced from a markdown file. URL shape: fate-local://local/ { * path that never existed. Every local image failed with net::ERR_FILE_NOT_FOUND while the * attribute still read `fate-local:///C:/…`. Verified on Electron 42.3.3 and 42.11.3 * alike; it was never a Chromium regression, just the URL shape. + * + * What it will serve (absolute local paths to image files, never a network path) is decided in + * resolveLocalImagePath, which explains each refusal. Network paths are refused BEFORE anything + * touches the file system: even a stat of `\\host\share\x.png` makes Windows connect to the host. */ - protocol.handle('fate-local', (request) => { - let urlPath; + protocol.handle('fate-local', async (request) => { + const target = resolveLocalImagePath(request.url, process.platform); + if (!target.ok) return new Response(target.reason, { status: target.status }); try { - urlPath = decodeURIComponent(new URL(request.url).pathname); + const stat = await fs.promises.stat(target.filePath); + if (!stat.isFile()) return new Response('Not found', { status: 404 }); } catch { - return new Response('Bad fate-local URL', { status: 400 }); + return new Response('Not found', { status: 404 }); } - // '/C:/Users/…' → 'C:/Users/…'. POSIX paths keep their leading slash. - if (process.platform === 'win32' && /^\/[a-zA-Z]:/.test(urlPath)) urlPath = urlPath.slice(1); - return net.fetch(pathToFileURL(urlPath).toString()); + return net.fetch(pathToFileURL(target.filePath).toString()); }); + startAppShell(); // spellcheck, the application menu and their IPC; before the window exists createWindow(); ipcMain.handle('get-app-version', () => app.getVersion()); @@ -1529,15 +1971,18 @@ app.whenReady().then(() => { * via GDI+ (one hidden PowerShell call, ~300 names) and cached; font installs mid-session are * rare enough that a restart picking them up is fine. Purely local: the list never leaves the * process, matching the privacy posture. + * + * The cache holds the PROMISE, so callers that arrive while the enumeration is still running + * share it instead of each starting another PowerShell or fc-list (the old cache only filled in + * once the first run finished). An empty result is not kept, so a run that failed (no fc-list, + * a timeout) is retried by the next caller. */ - let systemFontsCache = null; + let systemFontsPromise = null; const finishFontList = (resolve, names) => { const clean = names.filter((n) => typeof n === 'string' && n.trim()).map((n) => n.trim()); - systemFontsCache = [...new Set(clean)].sort((a, b) => a.localeCompare(b)); - resolve(systemFontsCache); + resolve([...new Set(clean)].sort((a, b) => a.localeCompare(b))); }; - ipcMain.handle('get-system-fonts', () => { - if (systemFontsCache) return systemFontsCache; + const enumerateSystemFonts = () => { if (process.platform !== 'win32') { /* * Linux/macOS: fontconfig's `fc-list : family` prints one line per face, and a line can @@ -1581,6 +2026,15 @@ app.whenReady().then(() => { } ); }); + }; + ipcMain.handle('get-system-fonts', () => { + if (!systemFontsPromise) { + systemFontsPromise = enumerateSystemFonts().then((fonts) => { + if (fonts.length === 0) systemFontsPromise = null; + return fonts; + }); + } + return systemFontsPromise; }); /* @@ -1621,25 +2075,9 @@ app.whenReady().then(() => { }) ); + // The status-bar button and the palette; Help → Check for Updates runs the same function. ipcMain.handle('check-for-updates', () => { - /* - * Store builds: electron-updater cannot update an AppX; the Store owns that pipeline, and - * the previous behaviour ("Checking for updates…" followed by silence or an error) looked - * broken because it was. Route to the Store's own updates page instead. - */ - if (isWindowsStore) { - shell.openExternal('ms-windows-store://downloadsandupdates'); - return; - } - // Flatpak / Snap / apt / dnf own updates for this install (see detectUpdateSource). The - // button still does something useful: it shows what the newest release contains. - if (updateSource.managed) { - shell.openExternal('https://github.com/VagueDustin/FATE/releases/latest'); - return; - } - if (!isDev && store.get('autoUpdatesEnabled')) { - autoUpdater.checkForUpdates(); - } + checkForUpdatesNow(); }); ipcMain.handle('install-update', () => { @@ -1667,7 +2105,8 @@ app.whenReady().then(() => { }); if (!isDev && !updateSource.managed) { - autoUpdater.checkForUpdatesAndNotify(); + // Offline launches reject here; the 'error' handler above has already told the status bar. + autoUpdater.checkForUpdatesAndNotify().catch(() => {}); } ensureWindowsRegistration(); @@ -1701,16 +2140,19 @@ app.on('activate', () => { } }); -const gotTheLock = app.requestSingleInstanceLock() - -if (!gotTheLock) { - app.quit() -} else { - app.on('second-instance', (event, commandLine, workingDirectory) => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore() - mainWindow.focus() - handleArgs(commandLine) +/* + * Another launch while FATE runs (a file double-clicked, Open with FATE): the lock at the top of + * this file turned it away, and its command line arrives here. Its relative paths are relative to + * ITS working directory, not this process's. A launch that lands while the window is still + * loading is queued for 'app-ready' rather than sent to a renderer that is not listening yet. + */ +if (gotTheLock) { + app.on('second-instance', (_event, commandLine, workingDirectory) => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); } - }) + if (rendererReady) handleArgs(commandLine, workingDirectory); + else pendingSecondInstanceArgs.push({ argv: commandLine, cwd: workingDirectory }); + }); } diff --git a/electron/spellcheck.cjs b/electron/spellcheck.cjs new file mode 100644 index 0000000..e6e7227 --- /dev/null +++ b/electron/spellcheck.cjs @@ -0,0 +1,119 @@ +'use strict'; +/** + * spellcheck.cjs: the `spellcheck` setting, applied to an Electron session. + * + * ── Why this is not just setSpellCheckerEnabled ─────────────────────────────────────────────── + * Electron's spellchecker is on by default, and on Linux it backs onto Hunspell dictionaries it + * DOWNLOADS from Google's servers (redirector.gvt1.com) the first time a window opens: a fresh + * FATE profile on Linux had `Dictionaries/en-US-10-1.bdic` in it, contradicting PRIVACY.md. + * Measured on Electron 42: neither `session.setSpellCheckerEnabled(false)` nor + * `webPreferences.spellcheck: false` (nor both) stops that download. The session loads a + * dictionary for every language in its list whether or not checking is enabled. What stops it is + * an EMPTY language list, set before the first window exists. So "off" here means disabled AND no + * languages, and "on" puts the user's language back. + * + * Windows defaults to on: Chromium uses the Windows spell checker there, which needs no download. + * + * ── Two more Electron behaviours this works around (both measured) ──────────────────────────── + * 1. A window created with `webPreferences.spellcheck: false` never marks misspellings, even after + * the session is enabled, so turning the setting on would need a restart. main.cjs creates the + * window with it true and lets the session decide, which is what makes the toggle live. + * 2. A renderer that starts while its dictionary is still loading never receives it, so checking + * silently does nothing for the whole session. That is the normal case at launch (measured: + * the dictionary is ready ~50 ms after the window is created, ~125 ms before the page has + * loaded) and after a first-time download. Re-applying "enabled" re-sends the dictionary to + * every renderer, so refresh() runs once a dictionary reports itself initialized, and main.cjs + * calls it again whenever the window's page finishes loading. Either alone misses a case. + * + * Free of Electron imports; test/spellcheck.test.cjs drives it with a fake session. + */ + +/** The setting's default when the user has never chosen: on where it costs nothing (Windows). */ +function defaultSpellcheckEnabled(platform) { + return platform === 'win32'; +} + +/** The stored setting, or the platform default when there is none (or it is not a boolean). */ +function resolveSpellcheckSetting(stored, platform) { + return typeof stored === 'boolean' ? stored : defaultSpellcheckEnabled(platform); +} + +/** + * The dictionary to check with: the first of the user's languages that Chromium has one for. + * Exact matches first (`en-GB`), then the bare language (`de-AT` → `de`), then any regional one + * (`es-CO` → the first `es-…`), then US English. Null when nothing at all is available. + */ +function pickSpellcheckLanguage(preferred, available) { + const byLower = new Map(); + for (const code of available || []) { + if (typeof code === 'string' && code) byLower.set(code.toLowerCase(), code); + } + const wanted = (preferred || []) + .filter((code) => typeof code === 'string' && code) + .map((code) => code.replace(/_/g, '-').toLowerCase()); + + for (const code of wanted) { + if (byLower.has(code)) return byLower.get(code); + } + for (const code of wanted) { + const lang = code.split('-')[0]; + if (byLower.has(lang)) return byLower.get(lang); + for (const [lower, original] of byLower) { + if (lower.startsWith(`${lang}-`)) return original; + } + } + return byLower.get('en-us') ?? null; +} + +/** + * Apply the setting to `session` now and whenever `set(enabled)` is called. Returns + * { set(enabled), refresh(), isEnabled() }; refresh() is workaround 2 above. + * + * `preferredLanguages` is what the OS says the user reads (app.getLocale() first, then + * app.getPreferredSystemLanguages()). macOS ignores language lists (it uses the system checker), + * so they are only set elsewhere. + */ +function createSpellcheckController(session, { enabled, preferredLanguages = [], platform = process.platform, warn = () => {} }) { + const setsLanguages = platform !== 'darwin'; + let on = false; + + const setLanguages = (languages) => { + if (!setsLanguages) return; + try { + session.setSpellCheckerLanguages(languages); + } catch (err) { + warn(`Spellcheck languages not applied: ${err.message}`); + } + }; + + function set(next) { + on = next === true; + if (on) { + const language = pickSpellcheckLanguage(preferredLanguages, session.availableSpellCheckerLanguages); + setLanguages(language ? [language] : []); + session.setSpellCheckerEnabled(true); + } else { + session.setSpellCheckerEnabled(false); + setLanguages([]); // no languages, no dictionary, no download: see the header + } + } + + // Workaround 2 above. Toggling the enable flag re-initialises every renderer's spellchecker with + // whatever dictionaries have loaded by now. A no-op while off: there is nothing to send. + function refresh() { + if (!on) return; + session.setSpellCheckerEnabled(false); + session.setSpellCheckerEnabled(true); + } + session.on('spellcheck-dictionary-initialized', refresh); + + set(enabled); + return { set, refresh, isEnabled: () => on }; +} + +module.exports = { + defaultSpellcheckEnabled, + resolveSpellcheckSetting, + pickSpellcheckLanguage, + createSpellcheckController +}; diff --git a/electron/tempCleanup.cjs b/electron/tempCleanup.cjs new file mode 100644 index 0000000..09c9d36 --- /dev/null +++ b/electron/tempCleanup.cjs @@ -0,0 +1,54 @@ +'use strict'; +/** + * tempCleanup.cjs: remove temp files a previous FATE run left behind. + * + * FATE writes three kinds of temp file and deletes each one itself when it is done: the print + * preview's PDF (when the preview closes), the PowerShell scripts behind the Windows association + * checks (when PowerShell exits), and the self-heal's .reg import (when reg.exe exits). A crash, a + * kill or a power cut between the write and the delete leaves the file in the temp folder for good, + * one per run. At launch, anything matching those names and older than a day goes. + * + * Only these exact name shapes, only regular files (never a symlink), and only old ones: the temp + * folder is shared with every other program (and, on Linux, every user), and a preview another + * FATE has open right now is younger than a day. Errors are ignored file by file: another user's + * file, one Windows still has open, or one already gone are all fine to skip. + * + * Free of Electron imports; test/tempCleanup.test.cjs runs it against a scratch folder. + */ + +const fs = require('fs'); +const path = require('path'); + +const STALE_TEMP_FILE_PATTERNS = Object.freeze([ + /^FATE-preview-\d+\.pdf$/, // showPrintPreview in main.cjs + /^fate-ps-\d+-\d+\.ps1$/, // runPowerShell in main.cjs + /^fate-registration-\d+\.reg$/ // ensureWindowsRegistration in main.cjs +]); + +const STALE_AFTER_MS = 24 * 60 * 60 * 1000; + +/** Resolves the names it removed. Never rejects. */ +async function removeStaleTempFiles(dir, { now = Date.now(), maxAgeMs = STALE_AFTER_MS } = {}) { + let names; + try { + names = await fs.promises.readdir(dir); + } catch { + return []; + } + const removed = []; + for (const name of names) { + if (!STALE_TEMP_FILE_PATTERNS.some((pattern) => pattern.test(name))) continue; + const file = path.join(dir, name); + try { + const stat = await fs.promises.lstat(file); + if (!stat.isFile() || now - stat.mtimeMs < maxAgeMs) continue; + await fs.promises.unlink(file); + removed.push(name); + } catch { + /* not ours to delete, still open, or already gone */ + } + } + return removed; +} + +module.exports = { removeStaleTempFiles, STALE_TEMP_FILE_PATTERNS, STALE_AFTER_MS }; diff --git a/test/appMenu.test.cjs b/test/appMenu.test.cjs new file mode 100644 index 0000000..217a46b --- /dev/null +++ b/test/appMenu.test.cjs @@ -0,0 +1,279 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + MENU_COMMANDS, + RELEASES_URL, + ISSUES_URL, + bindingToAccelerator, + sanitizeMenuShortcuts, + escapeMenuLabel, + recentFileLabel, + buildAppMenuTemplate, + buildTabMenuTemplate, + buildPreviewMenuTemplate +} = require('../electron/appMenu.cjs'); + +/* ── bindingToAccelerator ────────────────────────────────────────────────────────────────────── */ + +test('accelerators: FATE bindings translate to Electron syntax', () => { + const cases = { + 'Control+S': 'Ctrl+S', + 'Control+Shift+S': 'Ctrl+Shift+S', + 'Control+t': 'Ctrl+T', + 'Control+\\': 'Ctrl+\\', + 'Control+,': 'Ctrl+,', + 'Control+Tab': 'Ctrl+Tab', + 'Control+Shift+Tab': 'Ctrl+Shift+Tab', + 'Alt+Home': 'Alt+Home', + 'Escape': 'Esc', + 'F5': 'F5', + 'Shift+F12': 'Shift+F12', + 'Control+F24': 'Ctrl+F24', + 'Control+ArrowUp': 'Ctrl+Up', + 'Control+PageDown': 'Ctrl+PageDown', + 'Control+Space': 'Ctrl+Space', + 'Control+Enter': 'Ctrl+Enter', + 'Meta+K': 'Super+K', + 'Control+Alt+Shift+Meta+X': 'Ctrl+Alt+Shift+Super+X', + 'Control+Shift+?': 'Ctrl+Shift+?', + 'Control+1': 'Ctrl+1', + 'Control+=': 'Ctrl+=', + 'Control+-': 'Ctrl+-', + 'Control+Plus': 'Ctrl+Plus' + }; + for (const [binding, accelerator] of Object.entries(cases)) { + assert.equal(bindingToAccelerator(binding), accelerator, binding); + } +}); + +test('accelerators: the + key, recorded as a trailing "++", becomes Plus', () => { + assert.equal(bindingToAccelerator('Control++'), 'Ctrl+Plus'); + assert.equal(bindingToAccelerator('Control+Shift++'), 'Ctrl+Shift+Plus'); + assert.equal(bindingToAccelerator('+'), 'Plus'); +}); + +test('accelerators: anything Electron cannot show gets no label', () => { + for (const binding of [ + '', + null, + undefined, + 42, + 'Control+', + 'Control++Shift', + '+Control', + 'Control', + 'Control+Shift', + 'Control+Control+S', + 'Ctrl+S', // not FATE's format + 'Hyper+S', + 'Control+é', + 'Control+ß', + 'Control+Dead', + 'Control+Unidentified', + 'Control+F25', + 'Control+F0', + 'Control+AudioVolumeUp', + 'Control+ContextMenu', + `Control+${'X'.repeat(80)}` + ]) { + assert.equal(bindingToAccelerator(binding), null, String(binding)); + } +}); + +test('sanitizeMenuShortcuts keeps known ids with short string values', () => { + assert.deepEqual( + sanitizeMenuShortcuts({ save: 'Control+S', nextTab: 'Control+Tab', palette: 7, find: 'x'.repeat(65), gotoLine: 'Control+G' }), + { save: 'Control+S', gotoLine: 'Control+G' } + ); + assert.deepEqual(sanitizeMenuShortcuts(null), {}); + assert.deepEqual(sanitizeMenuShortcuts(['Control+S']), {}); + assert.deepEqual(sanitizeMenuShortcuts('Control+S'), {}); +}); + +/* ── labels ──────────────────────────────────────────────────────────────────────────────────── */ + +test('labels: ampersands from file names are escaped, not mnemonics', () => { + assert.equal(escapeMenuLabel('R&D notes.md'), 'R&&D notes.md'); + assert.equal(recentFileLabel('/home/me/R&D/a&b.md', { homeDir: '/home/me', platform: 'linux' }), '~/R&&D/a&&b.md'); +}); + +test('labels: home is ~ on Linux only, and only for whole path segments', () => { + assert.equal(recentFileLabel('/home/me/notes.md', { homeDir: '/home/me', platform: 'linux' }), '~/notes.md'); + assert.equal(recentFileLabel('/home/meg/notes.md', { homeDir: '/home/me', platform: 'linux' }), '/home/meg/notes.md'); + assert.equal(recentFileLabel('C:\\Users\\me\\notes.md', { homeDir: 'C:\\Users\\me', platform: 'win32' }), 'C:\\Users\\me\\notes.md'); +}); + +test('labels: long paths keep their start and the file name', () => { + const long = `/home/me/${'deeply/'.repeat(20)}nested/important-file-name.md`; + const label = recentFileLabel(long, { homeDir: '/home/me', platform: 'linux', maxLength: 60 }); + assert.equal(label.length, 60); + assert.ok(label.startsWith('~/deeply'), label); + assert.ok(label.endsWith('important-file-name.md'), label); + assert.ok(label.includes('…'), label); +}); + +/* ── the application menu ────────────────────────────────────────────────────────────────────── */ + +function flatten(items, out = []) { + for (const item of items) { + out.push(item); + if (Array.isArray(item.submenu)) flatten(item.submenu, out); + } + return out; +} + +function makeActions() { + const calls = []; + return { + calls, + actions: { + command: (id) => calls.push(['command', id]), + openRecent: (p) => calls.push(['openRecent', p]), + clearRecent: () => calls.push(['clearRecent']), + checkForUpdates: () => calls.push(['checkForUpdates']), + openUrl: (url) => calls.push(['openUrl', url]) + } + }; +} + +test('menu: File, Edit, View, Help', () => { + const { actions } = makeActions(); + const template = buildAppMenuTemplate({ isPackaged: true, platform: 'win32' }, actions); + assert.deepEqual(template.map((m) => m.label), ['&File', '&Edit', '&View', '&Help']); +}); + +test('menu: no Reload or Force Reload anywhere (C2), packaged or not', () => { + for (const isPackaged of [true, false]) { + const { actions } = makeActions(); + const items = flatten(buildAppMenuTemplate({ isPackaged, platform: 'linux' }, actions)); + for (const item of items) { + assert.ok(!/reload/i.test(item.role || ''), `role ${item.role}`); + assert.ok(!/reload/i.test(item.label || ''), `label ${item.label}`); + assert.ok(!/\bR$/.test(item.accelerator || '') || item.registerAccelerator === false, `accelerator ${item.accelerator}`); + } + } +}); + +test('menu: Toggle Developer Tools only when not packaged', () => { + const { actions } = makeActions(); + const packaged = flatten(buildAppMenuTemplate({ isPackaged: true }, actions)); + const dev = flatten(buildAppMenuTemplate({ isPackaged: false }, actions)); + assert.equal(packaged.some((i) => /devtools/i.test(i.role || '')), false); + assert.equal(dev.filter((i) => /devtools/i.test(i.role || '')).length, 1); +}); + +test('menu: every renderer command is display-only and sends its id', () => { + const { actions, calls } = makeActions(); + const items = flatten(buildAppMenuTemplate({ isPackaged: true }, actions)); + const commandItems = items.filter((i) => MENU_COMMANDS.includes(i.id)); + // Every id in the contract has exactly one menu item. + assert.deepEqual(commandItems.map((i) => i.id).sort(), [...MENU_COMMANDS].sort()); + for (const item of commandItems) { + assert.equal(item.registerAccelerator, false, item.id); + assert.equal(item.role, undefined, item.id); + item.click(); + } + assert.deepEqual(calls.map((c) => c[1]).sort(), [...MENU_COMMANDS].sort()); +}); + +test('menu: shortcut labels follow the renderer, with fixed ones as fallback', () => { + const { actions } = makeActions(); + const shortcuts = { save: 'Control+Alt+S', newFile: 'Control+N', palette: 'Control+é', gotoLine: 'Control+G' }; + const items = flatten(buildAppMenuTemplate({ shortcuts, isPackaged: true }, actions)); + const byId = Object.fromEntries(items.filter((i) => i.id).map((i) => [i.id, i])); + assert.equal(byId.save.accelerator, 'Ctrl+Alt+S'); + assert.equal(byId.newFile.accelerator, 'Ctrl+N'); + assert.equal(byId.palette.accelerator, undefined); // untranslatable: no label + assert.equal(byId.gotoLine.accelerator, 'Ctrl+G'); + assert.equal(byId.gotoSymbol.accelerator, undefined); // no binding: no label + assert.equal(byId.undo.accelerator, 'Ctrl+Z'); + assert.equal(byId.redo.accelerator, 'Ctrl+Y'); + assert.equal(byId.find.accelerator, 'Ctrl+F'); + // A renderer binding for a fixed id wins. + const rebound = flatten(buildAppMenuTemplate({ shortcuts: { find: 'Control+Shift+F' } }, actions)); + assert.equal(rebound.find((i) => i.id === 'find').accelerator, 'Ctrl+Shift+F'); +}); + +test('menu: Cut, Copy, Paste and Select All are roles that never register their keys', () => { + const { actions } = makeActions(); + const items = flatten(buildAppMenuTemplate({ isPackaged: true }, actions)); + for (const role of ['cut', 'copy', 'paste', 'selectAll']) { + const item = items.find((i) => i.role === role); + assert.ok(item, role); + assert.equal(item.registerAccelerator, false, role); + } + // No undo/redo roles: CodeMirror owns history. + assert.equal(items.some((i) => i.role === 'undo' || i.role === 'redo'), false); +}); + +test('menu: zoom, full screen and Exit are real roles', () => { + const { actions } = makeActions(); + const items = flatten(buildAppMenuTemplate({ isPackaged: true }, actions)); + const roles = items.map((i) => i.role).filter(Boolean); + for (const role of ['resetZoom', 'zoomIn', 'zoomOut', 'togglefullscreen', 'quit']) assert.ok(roles.includes(role), role); + const hiddenZoom = items.find((i) => i.id === 'zoomInEquals'); + assert.equal(hiddenZoom.visible, false); + assert.equal(hiddenZoom.accelerator, 'CommandOrControl+='); +}); + +test('menu: Open Recent lists the files and clears them', () => { + const { actions, calls } = makeActions(); + const recentFiles = ['/home/me/a.md', '/home/me/b&c.txt', null, '']; + const template = buildAppMenuTemplate({ recentFiles, homeDir: '/home/me', platform: 'linux' }, actions); + const recent = template[0].submenu.find((i) => i.id === 'openRecent').submenu; + assert.deepEqual(recent.map((i) => i.label || i.type), ['~/a.md', '~/b&&c.txt', 'separator', 'Clear Recently Opened']); + recent[1].click(); + recent[3].click(); + assert.deepEqual(calls, [['openRecent', '/home/me/b&c.txt'], ['clearRecent']]); +}); + +test('menu: Open Recent with nothing in it says so', () => { + const { actions } = makeActions(); + const template = buildAppMenuTemplate({ recentFiles: [] }, actions); + const recent = template[0].submenu.find((i) => i.id === 'openRecent').submenu; + assert.equal(recent[0].label, 'No Recent Files'); + assert.equal(recent[0].enabled, false); + assert.equal(recent.find((i) => i.id === 'clearRecent').enabled, false); +}); + +test('menu: Help links and the update check', () => { + const { actions, calls } = makeActions(); + const items = flatten(buildAppMenuTemplate({}, actions)); + items.find((i) => i.id === 'releaseNotes').click(); + items.find((i) => i.id === 'reportIssue').click(); + items.find((i) => i.id === 'checkForUpdates').click(); + assert.deepEqual(calls, [['openUrl', RELEASES_URL], ['openUrl', ISSUES_URL], ['checkForUpdates']]); + assert.match(RELEASES_URL, /^https:\/\/github\.com\/VagueDustin\/FATE\//); + assert.match(ISSUES_URL, /^https:\/\/github\.com\/VagueDustin\/FATE\//); +}); + +test('menu: ids are unique', () => { + const { actions } = makeActions(); + const ids = flatten(buildAppMenuTemplate({ recentFiles: ['/a', '/b'], isPackaged: false }, actions)).map((i) => i.id).filter(Boolean); + assert.equal(new Set(ids).size, ids.length); +}); + +/* ── tab and preview menus ───────────────────────────────────────────────────────────────────── */ + +test('tab menu: actions in the contract, path items need a path', () => { + const picked = []; + const pick = (action) => () => picked.push(action); + const withPath = buildTabMenuTemplate({ hasPath: true }, pick); + assert.deepEqual(withPath.map((i) => i.id || i.type), ['copyPath', 'reveal', 'separator', 'close', 'closeOthers', 'closeRight']); + assert.deepEqual(withPath.map((i) => i.label).filter(Boolean), [ + 'Copy Full Path', 'Open Containing Folder', 'Close', 'Close Others', 'Close Tabs to the Right' + ]); + withPath.filter((i) => i.click).forEach((i) => i.click()); + assert.deepEqual(picked, ['copyPath', 'reveal', 'close', 'closeOthers', 'closeRight']); + + const untitled = buildTabMenuTemplate({ hasPath: false }, pick); + assert.equal(untitled.find((i) => i.id === 'copyPath').enabled, false); + assert.equal(untitled.find((i) => i.id === 'reveal').enabled, false); + assert.notEqual(untitled.find((i) => i.id === 'close').enabled, false); +}); + +test('preview menu: only Close Preview', () => { + const items = flatten(buildPreviewMenuTemplate()); + assert.deepEqual(items.map((i) => i.role).filter(Boolean), ['close']); +}); diff --git a/test/launchArgs.test.cjs b/test/launchArgs.test.cjs new file mode 100644 index 0000000..24e3485 --- /dev/null +++ b/test/launchArgs.test.cjs @@ -0,0 +1,47 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { candidateFilePaths } = require('../electron/launchArgs.cjs'); + +test('every argument after the executable, flags skipped', () => { + assert.deepEqual( + candidateFilePaths(['/opt/FATE/fate', '--no-sandbox', '/home/me/a.md', '--original-process-start-time=1', '/home/me/b.txt'], { + cwd: '/home/me', + platform: 'linux' + }), + ['/home/me/a.md', '/home/me/b.txt'] + ); +}); + +test('relative paths resolve against the given working directory, not ours', () => { + assert.deepEqual(candidateFilePaths(['fate', 'notes.md', '../x/y.md', './z.md'], { cwd: '/srv/project/docs', platform: 'linux' }), [ + '/srv/project/docs/notes.md', + '/srv/project/x/y.md', + '/srv/project/docs/z.md' + ]); +}); + +test('Windows: drive paths, relative paths and UNC paths', () => { + assert.deepEqual( + candidateFilePaths(['C:\\Program Files\\FATE\\FATE.exe', 'notes.md', '..\\other\\a.txt', 'D:\\x\\b.md', '\\\\server\\share\\c.md'], { + cwd: 'C:\\Users\\me\\Documents', + platform: 'win32' + }), + ['C:\\Users\\me\\Documents\\notes.md', 'C:\\Users\\me\\other\\a.txt', 'D:\\x\\b.md', '\\\\server\\share\\c.md'] + ); +}); + +test('file: URIs from desktop entries (%U) become paths', () => { + assert.deepEqual(candidateFilePaths(['fate', 'file:///home/me/My%20Notes/a%23b.md'], { cwd: '/', platform: 'linux' }), [ + '/home/me/My Notes/a#b.md' + ]); + assert.deepEqual(candidateFilePaths(['FATE.exe', 'file:///C:/Users/me/a.md'], { cwd: 'C:\\', platform: 'win32' }), ['C:\\Users\\me\\a.md']); + // A remote host has no local path on Linux; it is skipped, not guessed at. + assert.deepEqual(candidateFilePaths(['fate', 'file://server/share/a.md'], { cwd: '/', platform: 'linux' }), []); +}); + +test('junk is skipped', () => { + assert.deepEqual(candidateFilePaths(['fate', '', 'a\0b.md', null, 7], { cwd: '/tmp', platform: 'linux' }), []); + assert.deepEqual(candidateFilePaths(undefined, { cwd: '/tmp', platform: 'linux' }), []); + assert.deepEqual(candidateFilePaths(['fate'], { cwd: '/tmp', platform: 'linux' }), []); +}); diff --git a/test/linkSecurity.test.cjs b/test/linkSecurity.test.cjs new file mode 100644 index 0000000..e67165a --- /dev/null +++ b/test/linkSecurity.test.cjs @@ -0,0 +1,197 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + isAllowedNavigation, + classifyExternalUrl, + resolveLocalImagePath, + MAX_EXTERNAL_URL_LENGTH +} = require('../electron/linkSecurity.cjs'); + +/* ── isAllowedNavigation ─────────────────────────────────────────────────────────────────────── */ + +const LINUX_ENTRY = { entryUrl: 'file:///opt/FATE/resources/app.asar/dist/index.html' }; +const WIN_ENTRY = { entryUrl: 'file:///C:/Program%20Files/FATE/resources/app.asar/dist/index.html' }; + +test('navigation: the exact entry document is allowed, with or without a #hash', () => { + assert.equal(isAllowedNavigation('file:///opt/FATE/resources/app.asar/dist/index.html', LINUX_ENTRY, 'linux'), true); + assert.equal(isAllowedNavigation('file:///opt/FATE/resources/app.asar/dist/index.html#/x', LINUX_ENTRY, 'linux'), true); +}); + +test('navigation: a relative link resolved against the page (C1) is refused', () => { + // [Contributing](CONTRIBUTING.md) in a document becomes dist/CONTRIBUTING.md. + assert.equal(isAllowedNavigation('file:///opt/FATE/resources/app.asar/dist/CONTRIBUTING.md', LINUX_ENTRY, 'linux'), false); + assert.equal(isAllowedNavigation('file:///opt/FATE/resources/app.asar/dist/', LINUX_ENTRY, 'linux'), false); + assert.equal(isAllowedNavigation('file:///opt/FATE/resources/app.asar/dist/index.html/x', LINUX_ENTRY, 'linux'), false); + assert.equal(isAllowedNavigation('file:///opt/FATE/resources/app.asar/dist/assets/index.js', LINUX_ENTRY, 'linux'), false); +}); + +test('navigation: a query string, another file, another scheme or garbage is refused', () => { + assert.equal(isAllowedNavigation('file:///opt/FATE/resources/app.asar/dist/index.html?x=1', LINUX_ENTRY, 'linux'), false); + assert.equal(isAllowedNavigation('file:///home/me/notes.md', LINUX_ENTRY, 'linux'), false); + assert.equal(isAllowedNavigation('https://example.com/', LINUX_ENTRY, 'linux'), false); + assert.equal(isAllowedNavigation('devtools://devtools/bundled/inspector.html', LINUX_ENTRY, 'linux'), false); + assert.equal(isAllowedNavigation('javascript:alert(1)', LINUX_ENTRY, 'linux'), false); + assert.equal(isAllowedNavigation('not a url', LINUX_ENTRY, 'linux'), false); + assert.equal(isAllowedNavigation(undefined, LINUX_ENTRY, 'linux'), false); +}); + +test('navigation: Linux paths compare case-sensitively', () => { + assert.equal(isAllowedNavigation('file:///opt/fate/resources/app.asar/dist/index.html', LINUX_ENTRY, 'linux'), false); +}); + +test('navigation: Windows compares decoded paths case-insensitively', () => { + // Chromium's encoding of the same path need not match pathToFileURL's byte for byte. + assert.equal(isAllowedNavigation('file:///c:/program files/fate/resources/app.asar/dist/INDEX.html', WIN_ENTRY, 'win32'), true); + assert.equal(isAllowedNavigation('file:///C:/Program%20Files/FATE/resources/app.asar/dist/index.html#top', WIN_ENTRY, 'win32'), true); + assert.equal(isAllowedNavigation('file:///C:/Program%20Files/FATE/resources/app.asar/dist/README.md', WIN_ENTRY, 'win32'), false); + // Same path on another machine (UNC) is not the app. + assert.equal(isAllowedNavigation('file://server/C:/Program%20Files/FATE/resources/app.asar/dist/index.html', WIN_ENTRY, 'win32'), false); +}); + +test('navigation: non-ASCII install paths match however they are encoded', () => { + const entry = { entryUrl: 'file:///C:/Users/Zo%C3%AB/AppData/Local/Programs/fate/resources/app.asar/dist/index.html' }; + assert.equal(isAllowedNavigation('file:///C:/Users/Zoë/AppData/Local/Programs/fate/resources/app.asar/dist/index.html', entry, 'win32'), true); +}); + +test('navigation: dev allows the dev server origin only', () => { + const dev = { devServerUrl: 'http://localhost:5173' }; + assert.equal(isAllowedNavigation('http://localhost:5173/', dev, 'linux'), true); + assert.equal(isAllowedNavigation('http://localhost:5173/src/App.jsx?t=1', dev, 'linux'), true); + assert.equal(isAllowedNavigation('http://localhost:5174/', dev, 'linux'), false); + assert.equal(isAllowedNavigation('http://127.0.0.1:5173/', dev, 'linux'), false); + assert.equal(isAllowedNavigation('file:///home/me/notes.md', dev, 'linux'), false); +}); + +/* ── classifyExternalUrl ─────────────────────────────────────────────────────────────────────── */ + +test('external: http and https are web links (confirmed before opening)', () => { + assert.deepEqual(classifyExternalUrl('https://example.com/a?b=c#d'), { kind: 'web', url: 'https://example.com/a?b=c#d' }); + assert.deepEqual(classifyExternalUrl('HTTP://Example.com'), { kind: 'web', url: 'http://example.com/' }); +}); + +test('external: mailto opens directly', () => { + assert.deepEqual(classifyExternalUrl('mailto:someone@example.com?subject=Hi'), { + kind: 'mailto', + url: 'mailto:someone@example.com?subject=Hi' + }); +}); + +test('external: everything else is refused', () => { + for (const url of [ + 'file:///C:/Users/me/Downloads/setup.exe', + 'file:///etc/passwd', + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + 'data:text/html,', + 'blob:https://example.com/123', + 'ms-settings:privacy', + 'steam://run/123', + 'vscode://file/C:/x', + 'search-ms:query=x', + 'smb://host/share', + 'ftp://example.com/', + 'fate-local://local/etc/passwd', + 'CONTRIBUTING.md', + '//example.com/x', + 'https://', + '', + null, + undefined, + 42 + ]) { + assert.equal(classifyExternalUrl(url), null, String(url)); + } +}); + +test('external: the canonical form is what gets opened', () => { + // Leading/trailing whitespace and control characters are stripped by the URL parser. + assert.deepEqual(classifyExternalUrl(' https://example.com/ \n'), { kind: 'web', url: 'https://example.com/' }); + assert.equal(classifyExternalUrl('\u0000javascript:alert(1)'), null); +}); + +test('external: absurdly long URLs are refused', () => { + assert.equal(classifyExternalUrl(`https://example.com/${'a'.repeat(MAX_EXTERNAL_URL_LENGTH)}`), null); +}); + +/* ── resolveLocalImagePath ───────────────────────────────────────────────────────────────────── */ + +const ok = (filePath) => ({ ok: true, filePath }); + +test('fate-local: POSIX image paths are served, decoded and normalised', () => { + assert.deepEqual(resolveLocalImagePath('fate-local://local/home/me/docs/img.png', 'linux'), ok('/home/me/docs/img.png')); + assert.deepEqual(resolveLocalImagePath('fate-local://local/home/me/My%20Pics/a%23b.JPG', 'linux'), ok('/home/me/My Pics/a#b.JPG')); + assert.deepEqual(resolveLocalImagePath('fate-local://local/home/me/docs/../img.svg', 'linux'), ok('/home/me/img.svg')); + for (const ext of ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng']) { + assert.equal(resolveLocalImagePath(`fate-local://local/tmp/x.${ext}`, 'linux').ok, true, ext); + } +}); + +test('fate-local: Windows drive paths are served', () => { + assert.deepEqual(resolveLocalImagePath('fate-local://local/C%3A/Users/me/Pictures/shot.png', 'win32'), ok('C:\\Users\\me\\Pictures\\shot.png')); + assert.deepEqual(resolveLocalImagePath('fate-local://local/d:/x/y.PNG', 'win32'), ok('d:\\x\\y.PNG')); +}); + +test('fate-local: network paths are refused before anything touches the disk (C5)', () => { + // ![](//attacker.example/share/x.png) → fate-local://local//attacker.example/share/x.png + for (const [url, platform] of [ + ['fate-local://local//attacker.example/share/x.png', 'win32'], + ['fate-local://local//attacker.example/share/x.png', 'linux'], + ['fate-local://local/%5C%5Cattacker.example%5Cshare%5Cx.png', 'win32'], + ['fate-local://local/%5C%5Cattacker.example%5Cshare%5Cx.png', 'linux'], + ['fate-local://local/%2F%2Fattacker.example/share/x.png', 'linux'], + // Mixed separators are one UNC root on Windows: '/\host/share' is \\host\share. + ['fate-local://local/%5Cattacker.example%5Cshare%5Cx.png', 'win32'], + // Device and long-path roots. + ['fate-local://local/%5C%5C%3F%5CUNC%5Chost%5Cshare%5Cx.png', 'win32'], + ['fate-local://local/%5C%5C.%5Cpipe%5Cx.png', 'win32'], + ['fate-local://local/%5C%5C%3F%5CC%3A%5Cx.png', 'win32'] + ]) { + const result = resolveLocalImagePath(url, platform); + assert.equal(result.ok, false, `${platform} ${url}`); + assert.equal(result.status, 403, `${platform} ${url}`); + } +}); + +test('fate-local: relative and drive-relative paths are refused', () => { + assert.equal(resolveLocalImagePath('fate-local://local/C%3Aimg.png', 'win32').status, 403); + assert.equal(resolveLocalImagePath('fate-local://local/img.png', 'win32').status, 403); // \img.png: current drive + assert.equal(resolveLocalImagePath('fate-local://local/', 'linux').status, 403); +}); + +test('fate-local: only images are served', () => { + for (const url of [ + 'fate-local://local/home/me/.ssh/id_rsa', + 'fate-local://local/etc/passwd', + 'fate-local://local/home/me/notes.md', + 'fate-local://local/home/me/page.html', + 'fate-local://local/home/me/x.png.exe', + 'fate-local://local/home/me/x.png/', + 'fate-local://local/home/me/.png' + ]) { + const result = resolveLocalImagePath(url, 'linux'); + assert.equal(result.ok, false, url); + assert.equal(result.status, 403, url); + } + assert.equal(resolveLocalImagePath('fate-local://local/C%3A/Windows/win.ini', 'win32').status, 403); +}); + +test('fate-local: Windows device names are refused whatever the extension', () => { + for (const name of ['CON.png', 'nul.jpg', 'COM1.png', 'lpt9.gif', 'aux.tar.png', 'COM1 .png']) { + assert.equal(resolveLocalImagePath(`fate-local://local/C%3A/x/${encodeURIComponent(name)}`, 'win32').status, 403, name); + } + assert.equal(resolveLocalImagePath('fate-local://local/C%3A/x/console.png', 'win32').ok, true); + assert.equal(resolveLocalImagePath('fate-local://local/home/me/CON.png', 'linux').ok, true); // just a name on Linux +}); + +test('fate-local: malformed requests are 400', () => { + for (const url of [ + 'fate-local://local/home/me/%E0%A4%A.png', // broken percent-encoding + 'fate-local://local/home/me/a%00.png', // NUL + 'fate-local://elsewhere/home/me/a.png', // not the fixed host + 'https://local/home/me/a.png', + 'garbage' + ]) { + assert.equal(resolveLocalImagePath(url, 'linux').status, 400, url); + } +}); diff --git a/test/spellcheck.test.cjs b/test/spellcheck.test.cjs new file mode 100644 index 0000000..d86fea4 --- /dev/null +++ b/test/spellcheck.test.cjs @@ -0,0 +1,134 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const { + defaultSpellcheckEnabled, + resolveSpellcheckSetting, + pickSpellcheckLanguage, + createSpellcheckController +} = require('../electron/spellcheck.cjs'); + +// What Electron 42 reports on Linux (session.availableSpellCheckerLanguages). +const AVAILABLE = ['af', 'bg', 'ca', 'cs', 'cy', 'da', 'de', 'de-DE', 'el', 'en', 'en-AU', 'en-CA', 'en-GB', + 'en-GB-oxendict', 'en-US', 'es', 'es-419', 'es-AR', 'es-ES', 'es-MX', 'es-US', 'et', 'fa', 'fo', 'fr', + 'fr-FR', 'gl', 'he', 'hi', 'hr', 'hu', 'hy', 'id', 'it', 'it-IT', 'ko', 'lt', 'lv', 'nb', 'nl', 'pl', 'pt', + 'pt-BR', 'pt-PT', 'ro', 'ru', 'sh', 'sk', 'sl', 'sq', 'sr', 'sv', 'ta', 'tg', 'tr', 'uk', 'vi']; + +/** Records what the controller does to the session, in order. */ +function fakeSession({ available = AVAILABLE, throwOnLanguages = false } = {}) { + const ses = new EventEmitter(); + ses.calls = []; + ses.enabled = true; // Electron's default + ses.languages = ['en-US']; + ses.availableSpellCheckerLanguages = available; + ses.setSpellCheckerEnabled = (on) => { + ses.calls.push(['enabled', on]); + ses.enabled = on; + }; + ses.setSpellCheckerLanguages = (langs) => { + ses.calls.push(['languages', langs]); + if (throwOnLanguages) throw new Error('Invalid language code provided'); + ses.languages = langs; + }; + return ses; +} + +test('default: on for Windows (its own checker), off elsewhere (dictionaries download)', () => { + assert.equal(defaultSpellcheckEnabled('win32'), true); + assert.equal(defaultSpellcheckEnabled('linux'), false); + assert.equal(defaultSpellcheckEnabled('darwin'), false); +}); + +test('a stored boolean wins; anything else falls back to the default', () => { + assert.equal(resolveSpellcheckSetting(true, 'linux'), true); + assert.equal(resolveSpellcheckSetting(false, 'win32'), false); + assert.equal(resolveSpellcheckSetting(undefined, 'win32'), true); + assert.equal(resolveSpellcheckSetting(undefined, 'linux'), false); + assert.equal(resolveSpellcheckSetting('yes', 'linux'), false); + assert.equal(resolveSpellcheckSetting(1, 'linux'), false); +}); + +test('language: exact, then bare language, then regional, then US English', () => { + assert.equal(pickSpellcheckLanguage(['en-GB', 'en-US'], AVAILABLE), 'en-GB'); + assert.equal(pickSpellcheckLanguage(['EN_us'], AVAILABLE), 'en-US'); + assert.equal(pickSpellcheckLanguage(['de-AT'], AVAILABLE), 'de'); + assert.equal(pickSpellcheckLanguage(['pt-MZ'], AVAILABLE), 'pt'); + assert.equal(pickSpellcheckLanguage(['es-CO'], ['es-ES', 'es-MX', 'en-US']), 'es-ES'); + assert.equal(pickSpellcheckLanguage(['ja-JP', 'fr-CA'], AVAILABLE), 'fr'); + assert.equal(pickSpellcheckLanguage(['ja-JP'], AVAILABLE), 'en-US'); + assert.equal(pickSpellcheckLanguage([], AVAILABLE), 'en-US'); + assert.equal(pickSpellcheckLanguage(['ja-JP'], ['ko']), null); + assert.equal(pickSpellcheckLanguage([null, 7, ''], AVAILABLE), 'en-US'); + assert.equal(pickSpellcheckLanguage(['en-US'], undefined), null); +}); + +test('off: disabled AND no languages, so no dictionary is loaded or downloaded', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: false, preferredLanguages: ['en-US'], platform: 'linux' }); + assert.equal(ctl.isEnabled(), false); + assert.equal(ses.enabled, false); + assert.deepEqual(ses.languages, []); +}); + +test('on: the user language, then enabled', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-GB'], platform: 'linux' }); + assert.equal(ctl.isEnabled(), true); + assert.deepEqual(ses.calls, [['languages', ['en-GB']], ['enabled', true]]); +}); + +test('runtime toggles restore and clear the language list', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: false, preferredLanguages: ['de-DE'], platform: 'linux' }); + ctl.set(true); + assert.deepEqual([ses.enabled, ses.languages], [true, ['de-DE']]); + ctl.set(false); + assert.deepEqual([ses.enabled, ses.languages], [false, []]); + ctl.set('true'); // only a real boolean true turns it on + assert.equal(ses.enabled, false); +}); + +test('a dictionary that finishes loading is pushed to renderers by re-enabling', () => { + const ses = fakeSession(); + createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-US'], platform: 'linux' }); + ses.calls.length = 0; + ses.emit('spellcheck-dictionary-initialized', {}, 'en-US'); + assert.deepEqual(ses.calls, [['enabled', false], ['enabled', true]]); +}); + +test('refresh() re-sends the state to renderers (called when the page has loaded)', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-US'], platform: 'linux' }); + ses.calls.length = 0; + ctl.refresh(); + assert.deepEqual(ses.calls, [['enabled', false], ['enabled', true]]); + assert.equal(ses.enabled, true); +}); + +test('no re-enabling while the setting is off', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-US'], platform: 'linux' }); + ctl.set(false); + ses.calls.length = 0; + ses.emit('spellcheck-dictionary-initialized', {}, 'en-US'); + ctl.refresh(); + assert.deepEqual(ses.calls, []); + assert.equal(ses.enabled, false); +}); + +test('macOS: languages are never set (the system checker ignores them)', () => { + const ses = fakeSession(); + const ctl = createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-US'], platform: 'darwin' }); + ctl.set(false); + assert.equal(ses.calls.some(([kind]) => kind === 'languages'), false); +}); + +test('a language Electron rejects is reported, not thrown', () => { + const warnings = []; + const ses = fakeSession({ throwOnLanguages: true }); + const ctl = createSpellcheckController(ses, { enabled: true, preferredLanguages: ['en-US'], platform: 'linux', warn: (m) => warnings.push(m) }); + assert.equal(ctl.isEnabled(), true); + assert.equal(ses.enabled, true); + assert.equal(warnings.length, 1); +}); diff --git a/test/tempCleanup.test.cjs b/test/tempCleanup.test.cjs new file mode 100644 index 0000000..5dc8d5f --- /dev/null +++ b/test/tempCleanup.test.cjs @@ -0,0 +1,75 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { removeStaleTempFiles, STALE_AFTER_MS } = require('../electron/tempCleanup.cjs'); + +function scratchDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fate-tempclean-test-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function touch(dir, name, ageMs) { + const file = path.join(dir, name); + fs.writeFileSync(file, 'x'); + const when = new Date(Date.now() - ageMs); + fs.utimesSync(file, when, when); + return file; +} + +const DAY = 24 * 60 * 60 * 1000; + +test('removes FATE temp files older than a day', async (t) => { + const dir = scratchDir(t); + touch(dir, 'FATE-preview-1234.pdf', 2 * DAY); + touch(dir, 'fate-ps-1234-7.ps1', 2 * DAY); + touch(dir, 'fate-registration-1234.reg', 2 * DAY); + const removed = await removeStaleTempFiles(dir); + assert.deepEqual(removed.sort(), ['FATE-preview-1234.pdf', 'fate-ps-1234-7.ps1', 'fate-registration-1234.reg']); + assert.deepEqual(fs.readdirSync(dir), []); +}); + +test('keeps young files: another FATE may have that preview open right now', async (t) => { + const dir = scratchDir(t); + touch(dir, 'FATE-preview-99.pdf', 60 * 1000); + touch(dir, 'fate-ps-99-0.ps1', STALE_AFTER_MS - 60 * 1000); + assert.deepEqual(await removeStaleTempFiles(dir), []); + assert.equal(fs.readdirSync(dir).length, 2); +}); + +test('keeps anything that is not exactly one of our names', async (t) => { + const dir = scratchDir(t); + for (const name of [ + 'FATE-preview-.pdf', + 'FATE-preview-12.pdf.bak', + 'fate-preview-12.pdf', // case matters: we write FATE-preview + 'my-FATE-preview-12.pdf', + 'fate-ps-12.ps1', + 'fate-registration-x.reg', + 'report.pdf', + 'notes.md' + ]) { + touch(dir, name, 10 * DAY); + } + assert.deepEqual(await removeStaleTempFiles(dir), []); + assert.equal(fs.readdirSync(dir).length, 8); +}); + +test('never follows or removes a symlink, and never a folder', async (t) => { + const dir = scratchDir(t); + const target = touch(dir, 'precious.txt', 10 * DAY); + fs.symlinkSync(target, path.join(dir, 'FATE-preview-1.pdf')); + fs.mkdirSync(path.join(dir, 'fate-registration-2.reg')); + const old = new Date(Date.now() - 10 * DAY); + fs.utimesSync(path.join(dir, 'fate-registration-2.reg'), old, old); + assert.deepEqual(await removeStaleTempFiles(dir), []); + assert.ok(fs.existsSync(target)); + assert.ok(fs.lstatSync(path.join(dir, 'FATE-preview-1.pdf')).isSymbolicLink()); +}); + +test('a missing folder is not an error', async () => { + assert.deepEqual(await removeStaleTempFiles(path.join(os.tmpdir(), 'fate-no-such-dir-' + process.pid)), []); +}); From a11dbf231c6f23bed980a06961a1d3a6df738d21 Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 07:37:58 -0700 Subject: [PATCH 11/28] Editor: a real dirty baseline, save snapshots, minimal reloads, large files CodeEditor measures "dirty" against a baseline, the text last known to be on disk, instead of whatever it was mounted with: - `savedContent` prop: the baseline when it differs from the initial text. A Markdown tab coming back into Edit mode with unsaved text now starts dirty and says so at mount (C4), so isDirty(), getSavedContent(), the close guards and the quit walk agree. - getDocSnapshot()/markSaved(snapshot): a save marks exactly the document it wrote as clean; keys typed while the write is in flight stay dirty (M4). - replaceContent(text, baseline?) replaces only the span that changed (src/textSpan.js), and nothing at all for identical text, so a reload keeps the caret, folds and undo history (M5). setBaseline() and scrollToEnd() serve the changed-on-disk choices and log following. - `largeFile` drops lint, bracket matching, autocompletion and selection-match highlighting; `plainText` never loads a language. - The syntax-error linter really stops at 200 diagnostics, and only runs for languages chosen by file name, not sniffed from content (M8). Co-Authored-By: Claude Opus 5.5 --- src/components/CodeEditor.jsx | 192 ++++++++++++++++++++++++++-------- src/textSpan.js | 51 +++++++++ 2 files changed, 199 insertions(+), 44 deletions(-) create mode 100644 src/textSpan.js diff --git a/src/components/CodeEditor.jsx b/src/components/CodeEditor.jsx index eb9a53b..be87b84 100644 --- a/src/components/CodeEditor.jsx +++ b/src/components/CodeEditor.jsx @@ -14,6 +14,7 @@ import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; import { detectLanguage } from '../languageDetect.js'; import { tokenHighlightStyle } from '../editorTheme.js'; +import { changedSpan } from '../textSpan.js'; /** * CodeEditor: the code-file counterpart to the markdown viewer. @@ -48,8 +49,18 @@ import { tokenHighlightStyle } from '../editorTheme.js'; * handle at the bottom. Per-keystroke state (dirty flag transitions, cursor position) never * touches React state except on actual dirty-flag *changes*; the Ln/Col readout is written * straight to a status-bar DOM node, following the same rule as the scroll progress bar. + * + * ── Dirty baseline (1.14.0) ─────────────────────────────────────────────────────────────────── + * "Dirty" means the buffer differs from the BASELINE: the text last known to be on disk. It is + * `savedContent` when the parent passes one, else the initial content. Up to 1.13.4 it was always + * the initial content, so a Markdown tab coming back into Edit mode with unsaved text took that + * text as saved: Ctrl+W closed it without a prompt, quitting skipped it, and Diff found nothing. + * Every path that moves the baseline (save, reload, the changed-on-disk choices) goes through + * the handle, so isDirty(), getSavedContent() and the guards built on them always agree. */ +const MAX_SYNTAX_DIAGNOSTICS = 200; + /** * Structural syntax diagnostics from the language parser itself. * @@ -62,43 +73,76 @@ import { tokenHighlightStyle } from '../editorTheme.js'; * * Zero-length error nodes (very common: "something is missing here") are widened by a character * so the underline has somewhere to live. + * + * The walk is a plain cursor loop so the cap really ends it. It used to be `iterate()` with an + * early `return`, which only skips the current node's children: past 200 errors the linter still + * visited every remaining node of the tree on each pass (M8). */ const syntaxErrorLinter = linter( (view) => { const diagnostics = []; - syntaxTree(view.state) - .cursor() - .iterate((node) => { - if (!node.type.isError || diagnostics.length >= 200) return; - const from = node.from === node.to ? Math.max(0, node.from - 1) : node.from; - const to = node.from === node.to ? Math.min(view.state.doc.length, node.to + 1) : node.to; - diagnostics.push({ - from, - to, - severity: 'error', - message: 'Syntax error: unexpected or missing token' - }); + const cursor = syntaxTree(view.state).cursor(); + do { + if (!cursor.type.isError) continue; + const from = cursor.from === cursor.to ? Math.max(0, cursor.from - 1) : cursor.from; + const to = cursor.from === cursor.to ? Math.min(view.state.doc.length, cursor.to + 1) : cursor.to; + diagnostics.push({ + from, + to, + severity: 'error', + message: 'Syntax error: unexpected or missing token' }); + if (diagnostics.length >= MAX_SYNTAX_DIAGNOSTICS) break; + } while (cursor.next()); return diagnostics; }, { delay: 400 } ); +/* + * Syntax checking runs only where it can be right: the setting is on, the file is not in + * large-file mode, and the language came from the FILE NAME. A language guessed from the content + * can be wrong (a log whose lines start with "[" sniffs as JSON), and then every line of it was + * underlined as an error (M8). + */ +const lintExtensions = (lint, largeFile, nameMatched) => + lint && !largeFile && nameMatched ? [syntaxErrorLinter, lintGutter()] : []; + +/* + * Props beyond the obvious: + * savedContent the baseline text when it differs from initialContent (see "Dirty baseline") + * largeFile large-file mode, fixed at mount: no lint, bracket matching, autocompletion or + * selection-match highlighting, the extensions whose cost grows with the file + * plainText never load a language (a Markdown file too big to render opens as plain text) + */ const CodeEditor = forwardRef(function CodeEditor( - { fileName, initialContent, wrap, tabSize, onDirtyChange, onSave, onDocChanged, cursorLabelRef, isActive = true, lint = true }, + { + fileName, initialContent, savedContent, wrap, tabSize, onDirtyChange, onSave, onDocChanged, + cursorLabelRef, isActive = true, lint = true, largeFile = false, plainText = false + }, ref ) { const hostRef = useRef(null); const viewRef = useRef(null); - /** The document as of the last save (a CM Text). Dirty = current doc !== this. */ + /** The baseline (a CM Text): the text last known to be on disk. Dirty = current doc ≠ this. */ const savedDocRef = useRef(null); + /** getSavedContent's string, cached per baseline (the hot-exit backups ask for it repeatedly). */ + const savedTextRef = useRef({ doc: null, text: '' }); const dirtyRef = useRef(false); + /** True while replaceContent dispatches: it sets the baseline itself, right after. */ + const replacingRef = useRef(false); /* * Tabs: several editors stay mounted at once, but the status bar has ONE Ln/Col node. Only the * visible tab may write to it; a background tab receiving a live-reload must not clobber the * readout of the tab the user is looking at. */ const isActiveRef = useRef(isActive); + /** Fixed at mount like largeFile; read by setLanguage after a Save As. */ + const plainTextRef = useRef(plainText); + /** Did the file NAME pick the language (not a content sniff)? Gates syntax checking. */ + const nameMatchedRef = useRef(false); + /** The lint inputs as last rendered, for setLanguage to reapply them under a new name. */ + const lintPropsRef = useRef({ lint, largeFile }); // Latest callbacks, readable from extensions without rebuilding the editor state. const onSaveRef = useRef(onSave); @@ -123,11 +167,19 @@ const CodeEditor = forwardRef(function CodeEditor( } }; + /** Point the baseline at `text`, sharing the live doc when they are equal (cheap comparisons). */ + const setBaselineText = (view, text) => { + const doc = view.state.doc; + const next = view.state.toText(text); + savedDocRef.current = doc.eq(next) ? doc : next; + setDirty(!doc.eq(savedDocRef.current)); + }; + /* * One EditorView per mount. The parent keys this component on its open counter, so opening a * file (including re-opening the same path) gets a clean editor with fresh undo history. - * `initialContent`/`fileName` are read once here by design, hence their absence from the - * dependency list. + * `initialContent`/`savedContent`/`fileName`/`largeFile`/`plainText` are read once here by + * design, hence their absence from the dependency list. */ const writeCursor = useCallback( (state) => { @@ -151,6 +203,7 @@ const CodeEditor = forwardRef(function CodeEditor( }, [isActive, writeCursor]); useEffect(() => { + nameMatchedRef.current = !plainText && !!detectLanguage(fileName); const state = EditorState.create({ doc: initialContent, @@ -165,15 +218,13 @@ const CodeEditor = forwardRef(function CodeEditor( EditorState.allowMultipleSelections.of(true), indentOnInput(), syntaxHighlighting(tokenHighlightStyle), - bracketMatching(), - closeBrackets(), - autocompletion(), + ...(largeFile ? [] : [bracketMatching(), closeBrackets(), autocompletion()]), rectangularSelection(), crosshairCursor(), highlightActiveLine(), - highlightSelectionMatches(), + ...(largeFile ? [] : [highlightSelectionMatches()]), languageCompartment.of([]), - lintCompartment.of(lint ? [syntaxErrorLinter, lintGutter()] : []), + lintCompartment.of(lintExtensions(lint, largeFile, nameMatchedRef.current)), wrapCompartment.of(wrap ? EditorView.lineWrapping : []), tabSizeCompartment.of([ EditorState.tabSize.of(tabSize), @@ -188,17 +239,17 @@ const CodeEditor = forwardRef(function CodeEditor( return true; } }, - ...closeBracketsKeymap, + ...(largeFile ? [] : closeBracketsKeymap), ...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap, - ...completionKeymap, + ...(largeFile ? [] : completionKeymap), indentWithTab ]), EditorView.updateListener.of((update) => { if (update.docChanged) { - setDirty(!update.state.doc.eq(savedDocRef.current)); + if (!replacingRef.current) setDirty(!update.state.doc.eq(savedDocRef.current)); onDocChangedRef.current?.(); } if (update.selectionSet || update.docChanged) { @@ -210,12 +261,16 @@ const CodeEditor = forwardRef(function CodeEditor( const view = new EditorView({ state, parent: hostRef.current }); viewRef.current = view; - savedDocRef.current = state.doc; + // The baseline. A different savedContent (unsaved text coming back into Edit mode, a restored + // backup) means the editor starts dirty, and says so at once. + savedDocRef.current = + savedContent == null || savedContent === initialContent ? state.doc : state.toText(savedContent); + if (!state.doc.eq(savedDocRef.current)) setDirty(true); writeCursor(state); // Load the language for this filename asynchronously; plain text until (and unless) it lands. // The content rides along so an unknown extension can still be sniffed (web.config → XML). - const langDesc = detectLanguage(fileName, initialContent); + const langDesc = plainText ? null : detectLanguage(fileName, initialContent); let cancelled = false; if (langDesc) { langDesc.load().then( @@ -255,10 +310,11 @@ const CodeEditor = forwardRef(function CodeEditor( }, [tabSize, tabSizeCompartment]); useEffect(() => { + lintPropsRef.current = { lint, largeFile }; viewRef.current?.dispatch({ - effects: lintCompartment.reconfigure(lint ? [syntaxErrorLinter, lintGutter()] : []) + effects: lintCompartment.reconfigure(lintExtensions(lint, largeFile, nameMatchedRef.current)) }); - }, [lint, lintCompartment]); + }, [lint, largeFile, lintCompartment]); useImperativeHandle( ref, @@ -269,29 +325,71 @@ const CodeEditor = forwardRef(function CodeEditor( /** Current buffer contents: what Save writes to disk. */ getContent: () => viewRef.current?.state.doc.toString() ?? '', - /** The buffer as of the last save (or open), the baseline "diff unsaved changes" compares against. */ - getSavedContent: () => savedDocRef.current?.toString() ?? '', + /** + * The document itself (an immutable CM Text), for a save to snapshot BEFORE it awaits the + * write: whatever is typed while the write is in flight is not in this snapshot (M4). + */ + getDocSnapshot: () => viewRef.current?.state.doc ?? null, + + /** The baseline as a CM Text. Identity changes exactly when the baseline moves. */ + getSavedDoc: () => savedDocRef.current, - /** Call after a successful save: the current doc becomes the clean reference point. */ - markSaved: () => { - if (!viewRef.current) return; - savedDocRef.current = viewRef.current.state.doc; - setDirty(false); + /** The baseline as text: what "Diff unsaved changes" compares against. */ + getSavedContent: () => { + const doc = savedDocRef.current; + if (!doc) return ''; + if (savedTextRef.current.doc !== doc) savedTextRef.current = { doc, text: doc.toString() }; + return savedTextRef.current.text; }, /** - * Replace the buffer with content reloaded from disk (external change, clean editor only; - * the caller checks). A whole-document change rather than a remount, so the undo history - * survives and the selection is mapped instead of reset. + * A save landed. The baseline becomes `snapshot` (the doc as it was when the save read it) + * or, without one, the current doc. Keys pressed during the write leave the tab dirty. */ - replaceContent: (text) => { + markSaved: (snapshot) => { const view = viewRef.current; if (!view) return; - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: text } - }); - savedDocRef.current = view.state.doc; - setDirty(false); + savedDocRef.current = snapshot ?? view.state.doc; + setDirty(!view.state.doc.eq(savedDocRef.current)); + }, + + /** Move the baseline without touching the buffer (keep my version, file deleted, …). */ + setBaseline: (text) => { + const view = viewRef.current; + if (view) setBaselineText(view, text); + }, + + /** + * Put `text` in the buffer: a reload from disk, a restored backup, a reopened encoding. + * Only the span that differs is replaced (identical text is not touched at all), so the + * caret, selection and folds map through the change, and undo history survives (M5). + * The baseline becomes `baseline`, by default the new text itself (a reload: clean). + */ + replaceContent: (text, baseline) => { + const view = viewRef.current; + if (!view) return; + const span = changedSpan(view.state.doc.toString(), text); + if (span) { + replacingRef.current = true; + try { + view.dispatch({ changes: span }); + } finally { + replacingRef.current = false; + } + } + if (baseline === undefined) { + savedDocRef.current = view.state.doc; + setDirty(false); + } else { + setBaselineText(view, baseline); + } + }, + + /** Log following: bring the end of the document into view. */ + scrollToEnd: () => { + const view = viewRef.current; + if (!view) return; + view.dispatch({ effects: EditorView.scrollIntoView(view.state.doc.length, { y: 'end' }) }); }, isDirty: () => dirtyRef.current, @@ -303,6 +401,12 @@ const CodeEditor = forwardRef(function CodeEditor( * buffer, cursor and undo history all survive the rename. */ setLanguage: (newFileName) => { + if (plainTextRef.current) return; + nameMatchedRef.current = !!detectLanguage(newFileName); + const { lint: lintOn, largeFile: large } = lintPropsRef.current; + viewRef.current?.dispatch({ + effects: lintCompartment.reconfigure(lintExtensions(lintOn, large, nameMatchedRef.current)) + }); const langDesc = detectLanguage(newFileName, viewRef.current?.state.doc.sliceString(0, 2048)); if (!langDesc) { viewRef.current?.dispatch({ effects: languageCompartment.reconfigure([]) }); diff --git a/src/textSpan.js b/src/textSpan.js new file mode 100644 index 0000000..0bbf2d9 --- /dev/null +++ b/src/textSpan.js @@ -0,0 +1,51 @@ +/** + * textSpan.js: the smallest single edit that turns one text into another. + * + * Reloading a file from disk used to replace the WHOLE document, so every reload put the caret + * on line 1, dropped the folds, and made following a growing log impossible. A change limited to + * the span that actually differs (common prefix and suffix left alone) lets CodeMirror map the + * selection and folds through it like any other edit. An appended log line is an insertion at + * the end; everything above it stays put. + * + * Positions are UTF-16 offsets, like CodeMirror's. A boundary never splits a surrogate pair, so + * an emoji that changed is replaced whole rather than half of it. + */ + +const isHigh = (c) => c >= 0xd800 && c <= 0xdbff; +const isLow = (c) => c >= 0xdc00 && c <= 0xdfff; + +/** + * `null` when the texts are identical; otherwise `{ from, to, insert }`, replacing + * `oldText.slice(from, to)` with `insert`. + */ +export function changedSpan(oldText, newText) { + if (oldText === newText) return null; + const oldLen = oldText.length; + const newLen = newText.length; + const max = Math.min(oldLen, newLen); + + let start = 0; + while (start < max && oldText.charCodeAt(start) === newText.charCodeAt(start)) start++; + // Don't end the shared prefix between the halves of a surrogate pair. + if (start > 0 && isHigh(oldText.charCodeAt(start - 1))) start--; + + let oldEnd = oldLen; + let newEnd = newLen; + while (oldEnd > start && newEnd > start && oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) { + oldEnd--; + newEnd--; + } + // Nor start the shared suffix on the low half of one: take that half into the change too. + if (oldEnd < oldLen && isLow(oldText.charCodeAt(oldEnd))) { + oldEnd++; + newEnd++; + } + + return { from: start, to: oldEnd, insert: newText.slice(start, newEnd) }; +} + +/** Apply a span from changedSpan to `text` (tests, and anything that needs the result as a string). */ +export function applySpan(text, span) { + if (!span) return text; + return text.slice(0, span.from) + span.insert + text.slice(span.to); +} From d23cc93f20fab6c13c2831b3a977f83b4c09bcba Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 07:37:59 -0700 Subject: [PATCH 12/28] Crash safety: .txt opens as text, safe Markdown rendering, error boundary - fileKinds: .txt is no longer Markdown; it opens in the editor. Markdown over 2 MB opens as plain text (kindForOpen) and large-file mode starts at 5 MB. couldBeMarkdown() says when "Render as Markdown" is offered. - renderSafely(): rendering that never throws; a failure degrades to the escaped text in a
 (H3: a 10 MB log overflowed marked's stack
  inside a state update and blanked the window).
- A root error boundary shows a recovery screen instead of a blank
  window. As it catches, it writes every unsaved buffer to the hot-exit
  backups (hotExit.flushForCrash), so Reload loses nothing, and it lets
  the window close without the unmounted app's quit walk.

Co-Authored-By: Claude Opus 5.5 
---
 src/components/ErrorBoundary.jsx |  80 ++++++++++++++++++++++
 src/fileKinds.js                 |  44 ++++++++++++-
 src/hotExit.js                   | 110 +++++++++++++++++++++++++++++++
 src/main.jsx                     |  10 ++-
 src/renderSafely.js              |  43 ++++++++++++
 5 files changed, 284 insertions(+), 3 deletions(-)
 create mode 100644 src/components/ErrorBoundary.jsx
 create mode 100644 src/hotExit.js
 create mode 100644 src/renderSafely.js

diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx
new file mode 100644
index 0000000..47ea256
--- /dev/null
+++ b/src/components/ErrorBoundary.jsx
@@ -0,0 +1,80 @@
+import { Component } from 'react';
+import { flushForCrash } from '../hotExit.js';
+
+/**
+ * Root error boundary (1.14.0, H3).
+ *
+ * Without one, any exception thrown while React renders unmounts the whole tree: the window goes
+ * blank and stays that way, with every unsaved buffer gone from the screen and no way back short
+ * of killing the app. This catches it and shows a recovery screen instead.
+ *
+ * Recovery leans on hot exit. As the error is caught, every dirty tab's backup is written at once
+ * (flushForCrash; App registers the writer), so Reload brings the buffers back through the normal
+ * startup restore, including whatever was typed since the last periodic backup. The screen says
+ * plainly whether that copy was made.
+ *
+ * Closing the window from here must not hang: the quit walk that normally answers the main
+ * process lived in the App that just unmounted. The boundary takes over 'request-close': with the
+ * backups written it lets the window close (the next launch restores them); without them it asks
+ * first.
+ */
+class ErrorBoundary extends Component {
+  constructor(props) {
+    super(props);
+    this.state = { error: null, kept: 'saving' };
+    this.flushed = null;
+  }
+
+  static getDerivedStateFromError(error) {
+    return { error };
+  }
+
+  componentDidCatch(error, info) {
+    console.error('FATE hit an error it could not recover from:', error, info?.componentStack);
+    this.flushed = flushForCrash();
+    this.flushed.then((ok) => this.setState({ kept: ok ? 'saved' : 'failed' }));
+
+    const api = window.electronAPI;
+    api?.onRequestClose?.(async () => {
+      const ok = await this.flushed;
+      if (ok) {
+        api.confirmedClose(true);
+        return;
+      }
+      const discard = await api.confirmDiscard?.('Close FATE without your unsaved changes?').catch(() => true);
+      api.confirmedClose(discard !== false);
+    });
+  }
+
+  reload = () => {
+    // The app's unsaved-changes guard went with it; nothing else should stand in the way.
+    window.onbeforeunload = null;
+    window.location.reload();
+  };
+
+  render() {
+    const { error, kept } = this.state;
+    if (!error) return this.props.children;
+
+    const message = {
+      saving: 'Keeping a copy of your unsaved changes…',
+      saved: 'Any unsaved changes were kept, and they will be back in their tabs after you reload.',
+      failed: "FATE couldn't keep a copy of your unsaved changes, so reloading loses them."
+    }[kept];
+
+    return (
+      
+
+

FATE ran into a problem

+

{message}

+
{String(error?.message || error)}
+ +
+
+ ); + } +} + +export default ErrorBoundary; diff --git a/src/fileKinds.js b/src/fileKinds.js index 5580d22..741de5e 100644 --- a/src/fileKinds.js +++ b/src/fileKinds.js @@ -15,8 +15,31 @@ * the per-type document icons (scripts/generate-file-icons.mjs). */ -/** These render through the markdown pipeline. Everything else FATE opens goes to the code editor. */ -export const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'txt']; +/** + * These render through the markdown pipeline. Everything else FATE opens goes to the code editor. + * + * `.txt` left this list in 1.14.0. Plain text is mostly logs, exports and notes that were never + * written as Markdown: the pipeline reflowed them (single line breaks, `#` and `*` read as markup), + * and a 10 MB log overflowed the parser's stack and blanked the window. A .txt that IS Markdown is + * one "Render as Markdown" away (see couldBeMarkdown). electron/main.cjs keeps `txt` in its own + * list: there it only drives the open dialog's filter and the Windows registration. + */ +export const MARKDOWN_EXTENSIONS = ['md', 'markdown']; + +/* + * Size routing, in characters (UTF-16 units, close enough to bytes for the purpose). + * + * MARKDOWN_RENDER_LIMIT: a Markdown file larger than this opens in the editor as plain text, with + * a notice offering "Render as Markdown". Rendering runs on the UI thread in one go (marked, KaTeX, + * highlight.js and the sanitiser), so past a couple of megabytes the window freezes for seconds + * before showing anything, and pathological input can exhaust the stack. + * + * LARGE_FILE_LIMIT: past this the code editor runs in large-file mode, without the extensions + * whose cost grows with the document or the selection (syntax-error lint, bracket matching, + * selection-match highlighting, autocompletion). Big logs open and scroll like small ones. + */ +export const MARKDOWN_RENDER_LIMIT = 2 * 1024 * 1024; +export const LARGE_FILE_LIMIT = 5 * 1024 * 1024; /** The curated "Code files" filter, and the types FATE registers for on Windows. */ export const CODE_EXTENSIONS = [ @@ -44,6 +67,23 @@ export function fileKindForName(name) { return MARKDOWN_EXTENSIONS.includes(extensionOf(name)) ? 'markdown' : 'code'; } +/** Which surface a file opens in, given its name AND its size (see MARKDOWN_RENDER_LIMIT). */ +export function kindForOpen(name, length) { + return fileKindForName(name) === 'markdown' && length <= MARKDOWN_RENDER_LIMIT ? 'markdown' : 'code'; +} + +/** + * Could this code tab be Markdown, so "Render as Markdown" is worth offering? Markdown files that + * opened as plain text for their size, `.txt`, and extensionless names (README, NOTES). Dotfiles + * (.env, .gitignore) have no extension either, but they are configuration, never prose. + */ +export function couldBeMarkdown(name) { + const base = (name || '').replace(/^.*[\\/]/, ''); + if (base.startsWith('.')) return false; + const ext = extensionOf(base); + return ext === '' || ext === 'txt' || MARKDOWN_EXTENSIONS.includes(ext); +} + /** * The renderer-side twin of the main process's binary sniff: a NUL in the first 8 K characters. * Text FATE can read (UTF-8, ASCII) never contains one; executables, images and archives do, diff --git a/src/hotExit.js b/src/hotExit.js new file mode 100644 index 0000000..7a83954 --- /dev/null +++ b/src/hotExit.js @@ -0,0 +1,110 @@ +/** + * hotExit.js: the renderer's half of hot exit (1.14.0). + * + * Every tab with unsaved changes is copied into the app's data folder (main's + * `backups.write(id, data)`, one JSON file per tab) about a second after it changes, and the copy + * is removed when the tab is saved, closed, discarded or becomes clean again. A session that ends + * cleanly therefore leaves nothing behind; a crash, a kill or a power cut leaves exactly the + * unsaved buffers, which the next launch puts back. Untitled buffers included: before this they + * existed nowhere but in memory. + * + * This module holds the pure parts (ids, payloads, restore planning; covered by `node --test`) + * plus the crash-flush hook the root error boundary calls. The timing and the IPC live in App.jsx. + */ + +/** Main validates ids against exactly this. */ +export const BACKUP_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +/** A fresh backup id for a tab: stable for the tab's life, unique across sessions. */ +export function newBackupId() { + const c = globalThis.crypto; + if (c && typeof c.randomUUID === 'function') return c.randomUUID(); + return `b${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`; +} + +/** + * The payload for backups.write (the contract in electron/preload.cjs): + * { kind, name, path, content, format, savedContent, untitled, editMode? }. + */ +export function backupPayload(doc, content, savedContent) { + const data = { + kind: doc.kind === 'markdown' ? 'markdown' : 'code', + name: doc.name, + path: doc.path || null, + content, + format: doc.format || null, + savedContent: typeof savedContent === 'string' ? savedContent : null, + untitled: !doc.path + }; + if (doc.kind === 'markdown') data.editMode = !!doc.editMode; + return data; +} + +/** A listed backup FATE can actually restore. */ +export function isRestorable(b) { + return !!b && typeof b.id === 'string' && BACKUP_ID_RE.test(b.id) && typeof b.content === 'string'; +} + +/** + * Sort a `backups.list()` result into what to do with it. + * byPath: Map pathKey → backup, the newest per file (applied once that file's tab opens) + * untitled: backups without a path, oldest first (they become Untitled tabs in their old order) + * drop: ids to delete: unreadable entries, and older copies of a file that has a newer one + */ +export function planRestore(list, keyOf) { + const byPath = new Map(); + const untitled = []; + const drop = []; + const entries = Array.isArray(list) ? list : []; + const newestFirst = [...entries].sort((a, b) => (Number(b?.savedAt) || 0) - (Number(a?.savedAt) || 0)); + for (const b of newestFirst) { + if (!isRestorable(b)) { + if (b && typeof b.id === 'string' && BACKUP_ID_RE.test(b.id)) drop.push(b.id); + continue; + } + if (b.path) { + const key = keyOf(b.path); + if (byPath.has(key)) drop.push(b.id); + else byPath.set(key, b); + } else { + untitled.push(b); + } + } + untitled.reverse(); + return { byPath, untitled, drop }; +} + +/** The N of an "Untitled-N" name, or 0. Restored Untitled tabs push the counter past theirs. */ +export function untitledNumber(name) { + const m = /^Untitled-(\d+)$/.exec(name || ''); + return m ? Number(m[1]) : 0; +} + +/** Shallow identity comparison of two backup signatures (arrays of the things a backup holds). */ +export function sameSignature(a, b) { + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +/* + * ── Crash flush ────────────────────────────────────────────────────────────────────────────── + * The root error boundary (main.jsx) cannot see App's buffers. App registers a function here that + * writes every dirty tab's backup at once, and the boundary calls it as it catches an error, so + * the recovery screen's Reload loses nothing typed since the last periodic write. Resolves true + * when every write landed. + */ +let crashFlusher = null; + +export function setCrashFlusher(fn) { + crashFlusher = typeof fn === 'function' ? fn : null; +} + +export function flushForCrash() { + if (!crashFlusher) return Promise.resolve(false); + try { + return Promise.resolve(crashFlusher()).catch(() => false); + } catch { + return Promise.resolve(false); + } +} diff --git a/src/main.jsx b/src/main.jsx index c9d7bc2..5f55dce 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -30,9 +30,17 @@ import '@fontsource/inter/latin-700.css' import './brand.css' import './index.css' import App from './App.jsx' +import ErrorBoundary from './components/ErrorBoundary.jsx' +/* + * The error boundary wraps everything: an exception while rendering shows a recovery screen + * instead of a blank window, after writing every unsaved buffer to the hot-exit backups (see + * components/ErrorBoundary.jsx). + */ createRoot(document.getElementById('root')).render( - + + + , ) diff --git a/src/renderSafely.js b/src/renderSafely.js new file mode 100644 index 0000000..516d19e --- /dev/null +++ b/src/renderSafely.js @@ -0,0 +1,43 @@ +/** + * renderSafely.js: Markdown rendering that cannot take the app down with it (1.14.0, H3). + * + * Up to 1.13.4 App.jsx called renderMarkdown inside setDocs updaters. Those run while React + * renders, so the RangeError a 10 MB .txt raised (marked's lexer recursing past the stack limit) + * unmounted the whole tree: a blank window and every unsaved buffer gone. Rendering now happens + * BEFORE any state update, through this wrapper, and a failure degrades to the text itself, + * escaped, in a
. The caller tells the user (a status message); the tab stays usable.
+ *
+ * `render` is injected (App passes renderMarkdown) so the fallback is testable under node.
+ */
+
+const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+
+export function escapeHtml(text) {
+  return String(text).replace(/[&<>"']/g, (c) => ESCAPES[c]);
+}
+
+/** Same reading-time rule as renderMarkdown (220 words a minute), for the fallback. */
+function readMinutes(text) {
+  const words = text.trim().split(/\s+/).filter(Boolean).length;
+  return Math.max(1, Math.round(words / 220));
+}
+
+/**
+ * `render(content, fPath, options)` → its result plus `renderFailed: false`, or the plain-text
+ * fallback (same shape) with `renderFailed: true` and the error.
+ */
+export function renderSafely(render, content, fPath, options) {
+  try {
+    return { remoteImageCount: 0, ...render(content, fPath, options), renderFailed: false };
+  } catch (error) {
+    return {
+      html: `
${escapeHtml(content)}
`, + toc: [], + readMins: readMinutes(content), + hasMermaid: false, + remoteImageCount: 0, + renderFailed: true, + error + }; + } +} From 5d39e61ac4b59714aa586a037d191741308fa29a Mon Sep 17 00:00:00 2001 From: Dustin Szabo Date: Sun, 4 Oct 2026 07:37:59 -0700 Subject: [PATCH 13/28] Keep unsaved work safe across disk changes, crashes, saves and closes App.jsx (renderer-docs workstream, 1.14.0): - C3: a dirty tab keeps its text when its file changes on disk or is opened again, in Reading view too, and shows a changed-on-disk bar: Reload, Keep mine, Compare (diff with file names for highlighting). A deleted file gets "Keep editing" / "Close tab"; Save recreates it. - C4: Markdown tabs track savedSource (the text on disk) and hand it to the editor as its baseline; leaving Edit mode keeps the dirty state. - H3: no rendering inside state updaters; failures fall back to plain text with a status message. "Render as Markdown" for .txt, extensionless and oversized .md tabs; Follow (status bar) for logs. - H6: docsRef/activeIdRef/splitIdRef update synchronously with state, so two opens of one file before a commit make one tab; session paths are de-duplicated when saved and when restored. - Hot exit: each dirty tab is backed up about a second after it changes and the backup removed on save, close, discard or becoming clean; startup restores backups (even with session restore off), file ones as each file's tab opens, Untitled ones as tabs. An unload guard blocks reloads over unsaved work; the quit walk stands it down. - Line endings and encodings: tabs carry `format` (from meta.format, or the default for Untitled), pass it on every save and adopt the result. Status bar: EOL toggle, encoding menu (Save with / Reopen with encoding), UNENCODABLE errors; a format-only disk change is followed. - M3: file drags are handled in the capture phase, so CodeMirror no longer pastes a dropped file into the editor. M4 save snapshots. - L14: Save As onto a path open in a dirty tab asks first; keeping keeps that tab as an unsaved copy. L15: closing a dirty tab offers Save / Don't save / Cancel. L17: Edit mode prints the rendered document. - Application menu commands, updateMenu({ shortcuts }), and the tab right-click menu (copy path, reveal, close, close others/to the right). - saveFile's NOT_OPEN (a restored tab whose file was gone) falls back to Save As; Save As onto another tab's path never sends closeFile for it. New: components/DocBar.jsx, components/FormatStatus.jsx, docFormat.js, and node --test coverage in test/rendererDocs.test.mjs. Co-Authored-By: Claude Opus 5.5 --- src/App.css | 273 ++++ src/App.jsx | 2066 ++++++++++++++++++++++++------- src/components/DocBar.jsx | 46 + src/components/FormatStatus.jsx | 168 +++ src/docFormat.js | 85 ++ test/rendererDocs.test.mjs | 250 ++++ 6 files changed, 2409 insertions(+), 479 deletions(-) create mode 100644 src/components/DocBar.jsx create mode 100644 src/components/FormatStatus.jsx create mode 100644 src/docFormat.js create mode 100644 test/rendererDocs.test.mjs diff --git a/src/App.css b/src/App.css index ddafe0f..50b8239 100644 --- a/src/App.css +++ b/src/App.css @@ -2403,6 +2403,279 @@ kbd { display: none; } +/* ════════════════════════════════════════════════════════════════════════════════════════════ + 1.14.0 DOCUMENT SAFETY SURFACES (renderer-docs) + Bars over a pane (changed on disk, deleted, notices), the status bar's encoding / line-ending + items and their menu, Follow, the Markdown print snapshot, the render fallback, and the crash + screen. Kept together; every colour is a token, so every theme carries them. + ════════════════════════════════════════════════════════════════════════════════════════════ */ + +/* ── Pane bars (components/DocBar.jsx) ─────────────────────────────────────────────────────── */ +.doc-bar { + flex-shrink: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35rem 0.6rem; + padding: 0.4rem 0.6rem 0.4rem 0.85rem; + background: var(--accent-subtle); + border-bottom: 1px solid var(--border-default); + color: var(--text-primary); + font-size: 0.8rem; +} + +.doc-bar-warning { + background: color-mix(in srgb, var(--accent-default) 14%, var(--surface-raised)); + border-bottom-color: var(--border-emphasis); +} + +.doc-bar-danger { + background: color-mix(in srgb, var(--status-danger) 14%, var(--surface-raised)); + border-bottom-color: color-mix(in srgb, var(--status-danger) 50%, transparent); +} + +.doc-bar-icon { + flex-shrink: 0; + color: var(--text-accent); +} + +.doc-bar-danger .doc-bar-icon { + color: var(--status-danger); +} + +.doc-bar-message { + flex: 1; + min-width: 12rem; + font-weight: 600; + overflow-wrap: anywhere; +} + +.doc-bar-actions { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex-shrink: 0; +} + +.doc-bar-btn { + height: 24px; + padding: 0 0.65rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border-default); + background: var(--surface-raised); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: border-color 0.16s var(--ease), color 0.16s var(--ease), background 0.16s var(--ease); +} + +.doc-bar-btn:hover { + border-color: var(--border-emphasis); + color: var(--text-accent); +} + +.doc-bar-btn-primary { + background: var(--accent-default); + border-color: var(--accent-default); + color: var(--text-inverse); +} + +.doc-bar-btn-primary:hover { + background: var(--accent-hover); + color: var(--text-inverse); +} + +.doc-bar-dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.doc-bar-dismiss:hover { + color: var(--text-accent); + border-color: var(--border-default); +} + +.doc-bar-btn:focus-visible, +.doc-bar-dismiss:focus-visible, +.format-menu-item:focus-visible { + outline: 2px solid var(--border-emphasis); + outline-offset: 1px; +} + +/* ── Status bar: encoding, line endings, Follow (components/FormatStatus.jsx) ──────────────── */ +.status-format { + font-family: var(--font-mono); + font-weight: 500; + font-size: 0.7rem; + flex-shrink: 0; +} + +.status-format.open { + color: var(--text-accent); + background: var(--accent-subtle); + border-color: var(--border-default); +} + +.status-follow { + flex-shrink: 0; +} + +/* Fixed and portalled to : the status bar clips overflow, so it can't hold a popover. */ +.format-menu { + position: fixed; + z-index: 1300; + display: flex; + flex-direction: column; + padding: 0.3rem; + background: var(--surface-overlay); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + box-shadow: var(--shadow-panel); + font-family: var(--font-sans); +} + +.format-menu-heading { + padding: 0.35rem 0.55rem 0.2rem; + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); +} + +.format-menu-hint { + padding: 0 0.55rem 0.25rem; + font-size: 0.72rem; + color: var(--text-muted); +} + +.format-menu-item { + display: flex; + align-items: center; + gap: 0.45rem; + width: 100%; + padding: 0.32rem 0.55rem; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.8rem; + text-align: left; + cursor: pointer; +} + +.format-menu-item:hover:not(:disabled), +.format-menu-item:focus-visible { + background: var(--accent-subtle); + color: var(--text-accent); +} + +.format-menu-item:disabled { + color: var(--text-faint); + cursor: default; +} + +.format-menu-check { + display: inline-flex; + width: 12px; + flex-shrink: 0; + color: var(--text-accent); +} + +.format-menu-sep { + height: 1px; + margin: 0.3rem 0.2rem; + background: var(--border-default); +} + +/* ── Markdown that couldn't render: shown as its text, wrapped like prose ──────────────────── */ +.markdown-body pre.md-plain-fallback { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* ── Markdown in Edit mode prints RENDERED: this snapshot, never visible on screen ──────────── */ +.md-print-body { + display: none; +} + +@media print { + .md-print-body { + display: block !important; + } + + .doc-bar, + .format-menu { + display: none !important; + } +} + +/* ── Crash screen (components/ErrorBoundary.jsx) ────────────────────────────────────────────── */ +.crash-screen { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 1.5rem; + background: var(--surface-base); + color: var(--text-primary); + font-family: var(--font-sans); +} + +.crash-card { + display: grid; + gap: 0.85rem; + width: min(560px, 100%); + padding: 1.5rem 1.75rem; + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-panel); +} + +.crash-title { + margin: 0; + font-size: 1.15rem; + font-weight: 700; +} + +.crash-text { + margin: 0; + color: var(--text-muted); + line-height: 1.5; +} + +.crash-detail { + margin: 0; + max-height: 9rem; + overflow: auto; + padding: 0.6rem 0.75rem; + background: var(--surface-sunken); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.75rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.crash-reload { + justify-self: start; +} + /* ════════════════════════════════════════════════════════════════════════════════════════════ 1.11.0 SURFACES: palette, split view, diff, markdown edit mode, focus mode, custom themes ════════════════════════════════════════════════════════════════════════════════════════════ */ diff --git a/src/App.jsx b/src/App.jsx index 8080f4d..d6ce820 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,9 +1,12 @@ import { useState, useEffect, useRef, useCallback } from 'react'; +import { EditorView } from '@codemirror/view'; +import { undo as undoEdit, redo as redoEdit } from '@codemirror/commands'; +import { openSearchPanel } from '@codemirror/search'; import { UploadSimple, FileText, FileCode, CircleNotch, Gear, X, Plus, House, FilePlus, Printer, FilePdf, FloppyDisk, FolderOpen, ClockCounterClockwise, CheckCircle, Trash, Warning, PencilSimple, Eye, SquareSplitHorizontal, GitDiff, Palette, Keyboard, - ArrowsOutSimple, MagnifyingGlass + ArrowsOutSimple, MagnifyingGlass, MarkdownLogo, ArrowLineDown } from '@phosphor-icons/react'; import fateLogo from './assets/FATE-Square-Icon.png'; import Starfield from './components/Starfield.jsx'; @@ -13,10 +16,20 @@ import MarkdownEditView from './components/MarkdownEditView.jsx'; import SettingsModal from './components/SettingsModal.jsx'; import CommandPalette from './components/CommandPalette.jsx'; import DiffView from './components/DiffView.jsx'; +import DocBar from './components/DocBar.jsx'; +import FormatStatus from './components/FormatStatus.jsx'; import { renderMarkdown } from './markdown.js'; +import { renderSafely } from './renderSafely.js'; import { installPreviewClipboard, selectPreviewDocument } from './previewClipboard.js'; import { detectLanguage } from './languageDetect.js'; -import { fileKindForName, looksBinary } from './fileKinds.js'; +import { + fileKindForName, kindForOpen, couldBeMarkdown, extensionOf, looksBinary, + MARKDOWN_RENDER_LIMIT, LARGE_FILE_LIMIT +} from './fileKinds.js'; +import { defaultFormat, formatChanged, toggledEol } from './docFormat.js'; +import { + newBackupId, backupPayload, planRestore, untitledNumber, sameSignature, setCrashFlusher +} from './hotExit.js'; import { resolveFonts, applyFonts, editorFontFor, DEFAULT_FONTS } from './fonts.js'; import { DEFAULT_THEME, resolveTheme, THEMES, SHORTCUT_ACTIONS, DEFAULT_SHORTCUTS, resolveShortcuts } from './settingsMeta.js'; import { resolveCustomTheme, applyCustomTheme } from './themeCustom.js'; @@ -55,6 +68,27 @@ function pathKey(p) { return CASE_INSENSITIVE_PATHS ? s.replace(/\//g, '\\').toLowerCase() : s; } +/** Unique by key, first occurrence kept (session paths: duplicates there compounded per launch). */ +function uniqueBy(list, keyOf) { + const seen = new Set(); + return list.filter((item) => { + const k = keyOf(item); + if (seen.has(k)) return false; + seen.add(k); + return true; + }); +} + +/** File name from a path, either separator. */ +function baseName(p) { + return (p || '').replace(/^.*[\\/]/, ''); +} + +const megabytes = (chars) => (chars / 1048576).toFixed(1); + +/** Wall-clock milliseconds, for timers that run outside render (the hot-exit tick). */ +const nowMs = () => Date.now(); + /* * Display helpers for shortcut bindings. Every tooltip and keycap in the UI renders the LIVE * binding through these; a hardcoded "(Ctrl+N)" in a title is a lie the moment the user rebinds. @@ -67,14 +101,74 @@ function kbdChips(binding) { return (binding || '').split('+').map((k) => (k === 'Control' ? 'Ctrl' : k)); } +/** The format a new Untitled buffer is saved with (UTF-8, the platform's line break). */ +const UNTITLED_FORMAT = defaultFormat(window.electronAPI?.platform); + +/* + * Hot-exit timing. A dirty tab is backed up once its text has held still for a tick, so roughly a + * second after typing pauses, and during a long burst of typing at least every few seconds. + */ +const BACKUP_TICK_MS = 500; +const BACKUP_MAX_WAIT_MS = 4000; +/** A backup write that failed (disk full, main busy) is tried again after this long. */ +const BACKUP_RETRY_MS = 5000; +/** How long a path-backed backup waits for its file's tab before it is restored without one. */ +const RESTORE_TIMEOUT_MS = 15000; + +/** Labels for the app menu's fixed shortcuts (CodeMirror's own keys; not rebindable). */ +const FIXED_MENU_SHORTCUTS = { find: 'Control+F', undo: 'Control+Z', redo: 'Control+Y' }; + +/** + * Every doc object goes through here on its way into state: `dirty` (what the tab dot, title, + * Save button and every guard read) is derived, never set directly. A tab is dirty when its text + * differs from the baseline on disk (`textDirty`, mirrored from the editor) OR its format was + * switched and not yet saved (an EOL or encoding change alone is still an unsaved change). + */ +function finalizeDoc(d) { + const dirty = !!d.textDirty || formatChanged(d); + return d.dirty === dirty ? d : { ...d, dirty }; +} + +/** + * Render fields for a Markdown doc. Never throws and never runs inside a state updater (H3): a + * render that fails yields the text itself, escaped, in a
, and `failed` tells the caller to
+ * say so in the status bar.
+ */
+function renderFields(content, fPath) {
+  const r = renderSafely(renderMarkdown, content, fPath);
+  if (r.renderFailed) console.error('Markdown render failed; showing plain text instead:', r.error);
+  return {
+    failed: r.renderFailed,
+    fields: { html: r.html, toc: r.toc, readMins: r.readMins, hasMermaid: r.hasMermaid, remoteImageCount: r.remoteImageCount ?? 0 }
+  };
+}
+
+const renderFailedMessage = (name) => `Couldn't render ${name} as Markdown, so it's shown as plain text`;
+
+/** Save As's suggested name: Untitled buffers gain an extension, everything else keeps its name. */
+function suggestedSaveName(doc) {
+  if (!doc.untitled || extensionOf(doc.name)) return doc.name;
+  return `${doc.name}.${doc.kind === 'markdown' ? 'md' : 'txt'}`;
+}
+
 function App() {
   /*
    * ── Tabs ────────────────────────────────────────────────────────────────────────────────────
-   * `docs` is the open-tab list, in tab-strip order. A doc is either
-   *   { id, kind:'markdown', name, path, source, html, toc, readMins, hasMermaid, dirty, editMode }
-   *   { id, kind:'code', name, path, codeContent, langName, dirty, untitled? }
+   * `docs` is the open-tab list, in tab-strip order. Every doc has
+   *   { id, kind, name, path, untitled?, dirty, textDirty, format, savedFormat, backupId,
+   *     diskChange, diskDeleted, notice }
+   * and then either
+   *   kind:'markdown'  { source, savedSource, html, toc, readMins, hasMermaid, remoteImageCount, editMode }
+   *   kind:'code'      { codeContent, savedContent?, langName, plainText, largeFile, follow }
    * (codeContent/source seed the CodeMirror instance, which owns the buffer after mount.)
    *
+   * Since 1.14.0 a doc knows its BASELINE, the text last known to be on disk: `savedSource` for
+   * Markdown (set on open, save and reload), the editor's own baseline for code (`savedContent`
+   * only seeds it when it differs from the initial text, e.g. a restored backup). `format` is how
+   * the next save writes the file, `savedFormat` how it is stored now (see docFormat.js).
+   * `diskChange` ({ content, format }) and `diskDeleted` drive the bars over a tab whose file
+   * changed or vanished underneath unsaved work; `notice` is an informational bar.
+   *
    * `activeId === null` with docs open = home screen behind the tab strip. `splitId` pins a second
    * doc into a right-hand pane; `diffData` (a snapshot) swaps the split for a side-by-side diff.
    * Every pane stays MOUNTED while its tab is open; that is what preserves scroll position,
@@ -86,6 +180,9 @@ function App() {
   const [diffData, setDiffData] = useState(null);
   const [focusMode, setFocusMode] = useState(false);
   const [showPalette, setShowPalette] = useState(false);
+  /** What the palette opens with (':' go to line, '@' go to symbol), and a key to open it fresh. */
+  const [paletteQuery, setPaletteQuery] = useState('');
+  const [paletteKey, setPaletteKey] = useState(0);
 
   const [appVersion, setAppVersion] = useState('');
   const [updateStatus, setUpdateStatus] = useState('');
@@ -99,10 +196,16 @@ function App() {
   const [isPrinting, setIsPrinting] = useState(false);
   const [statusError, setStatusError] = useState('');
   const [isLoading, setIsLoading] = useState(false);
-  /** Snapshot of a code buffer rendered into a print-only 
; see runPrintJob. */
-  const [codePrintText, setCodePrintText] = useState('');
+  /**
+   * What prints in place of a live editor; see runPrintJob. { kind:'code', text } for a code
+   * buffer, { kind:'markdown', markup } for a Markdown tab in Edit mode (printed RENDERED).
+   */
+  const [printSnapshot, setPrintSnapshot] = useState(null);
 
   const [showSettings, setShowSettings] = useState(false);
+  /** Settings page to open on (the menu's Keyboard Shortcuts and About), and a key to open it fresh. */
+  const [settingsSection, setSettingsSection] = useState(null);
+  const [settingsKey, setSettingsKey] = useState(0);
   const [settings, setSettings] = useState({
     theme: DEFAULT_THEME,
     autoUpdatesEnabled: true,
@@ -127,6 +230,12 @@ function App() {
    *                                       markdown tabs in edit mode)
    *   docsRef / activeIdRef / splitIdRef  current values for once-registered IPC callbacks and
    *                                       keyboard closures, which must never go stale
+   *
+   * docsRef, activeIdRef and splitIdRef are written SYNCHRONOUSLY, in the same call as the state
+   * update (commitDocs, activate, setSplit), never mirrored by an effect after React commits. The
+   * mirror lagged: two opens of one file inside a single commit (session restore plus a launch
+   * argument) both saw the old tab list, and the file opened twice, then three times on the next
+   * launch as the session saved both (H6).
    */
   const progressBarRef = useRef(null);
   const progressLabelRef = useRef(null);
@@ -148,18 +257,24 @@ function App() {
    */
   const explicitOpenRef = useRef(false);
   const restoreActiveKeyRef = useRef(null);
-
-  useEffect(() => {
-    docsRef.current = docs;
-  }, [docs]);
-
-  useEffect(() => {
-    activeIdRef.current = activeId;
-  }, [activeId]);
-
-  useEffect(() => {
-    splitIdRef.current = splitId;
-  }, [splitId]);
+  /*
+   * Hot exit (see hotExit.js):
+   *   liveEditorsRef     docId → { handle, view }: unlike editorRefs, NOT cleared when React
+   *                      detaches refs, so the crash flush can still read a buffer while the tree
+   *                      is being torn down by an error
+   *   pendingRestoresRef pathKey → backup waiting for its file's tab to open
+   *   backupStateRef     backupId → what was last written ({ written, seen, pendingSince })
+   *   backupQueueRef     backupId → promise chain, so a remove can never overtake a write
+   *   quittingRef        set once the quit walk is done: no more backups, no unload guard
+   */
+  const liveEditorsRef = useRef(new Map());
+  const pendingRestoresRef = useRef(new Map());
+  const backupStateRef = useRef(new Map());
+  const backupQueueRef = useRef(new Map());
+  const quittingRef = useRef(false);
+  const restoreStartedRef = useRef(false);
+  /** The current render's operations, for listeners registered once (IPC, menu, keys, timers). */
+  const opsRef = useRef(null);
 
   const activeDoc = docs.find((d) => d.id === activeId) || null;
 
@@ -180,27 +295,366 @@ function App() {
     });
   };
 
+  /* ── Doc state ───────────────────────────────────────────────────────────────────────────── */
+
+  /** The one way the tab list changes: state and docsRef move together (see the refs above). */
+  const commitDocs = (next) => {
+    const prev = docsRef.current;
+    const value = typeof next === 'function' ? next(prev) : next;
+    if (value === prev) return;
+    docsRef.current = value;
+    setDocs(value);
+  };
+
+  /** Merge `patch` (an object, or a function of the doc) into one doc. No-op when nothing changes. */
+  const patchDoc = (id, patch) => {
+    commitDocs((ds) => {
+      let changed = false;
+      const out = ds.map((d) => {
+        if (d.id !== id) return d;
+        const p = typeof patch === 'function' ? patch(d) : patch;
+        if (Object.keys(p).every((k) => d[k] === p[k])) return d;
+        changed = true;
+        return finalizeDoc({ ...d, ...p });
+      });
+      return changed ? out : ds;
+    });
+  };
+
+  const getDoc = (id) => docsRef.current.find((d) => d.id === id) || null;
+  const findDocByPath = (p) => {
+    const key = pathKey(p);
+    return docsRef.current.find((d) => d.path && pathKey(d.path) === key) || null;
+  };
+
+  const activate = (id) => {
+    activeIdRef.current = id;
+    setActiveId(id);
+  };
+
+  const setSplit = (id) => {
+    splitIdRef.current = id;
+    setSplitId(id);
+  };
+
+  /** Does closing this tab lose anything? The live editor is the authority when one is mounted. */
+  const isDocDirty = (doc) => {
+    if (!doc) return false;
+    const editor = editorRefs.current[doc.id];
+    const textDirty = editor ? editor.isDirty() : !!doc.textDirty;
+    return textDirty || formatChanged(doc);
+  };
+
+  /** Text content of a doc as it stands right now (live buffer if an editor is mounted). */
+  const docText = (doc) => {
+    const editor = editorRefs.current[doc.id];
+    if (editor) return editor.getContent();
+    if (doc.kind === 'markdown') return doc.source;
+    return doc.codeContent;
+  };
+
+  /** The doc's baseline: the text last known to be on disk. */
+  const docBaseline = (doc) => {
+    const editor = editorRefs.current[doc.id];
+    if (editor) return editor.getSavedContent();
+    if (doc.kind === 'markdown') return doc.savedSource;
+    return doc.savedContent ?? doc.codeContent;
+  };
+
+  /**
+   * Ref callback for a doc's CodeEditor (code tab, or a Markdown tab's Edit mode). React calls
+   * these with null and then the handle on every render (they are inline); only editorRefs
+   * follows that churn. liveEditorsRef keeps the last handle until the tab closes or leaves the
+   * editor, for the crash flush.
+   */
+  const editorRefFor = (docId) => (el) => {
+    if (el) {
+      editorRefs.current[docId] = el;
+      const live = liveEditorsRef.current.get(docId);
+      if (live?.handle === el) live.view = el.getView() ?? live.view;
+      else liveEditorsRef.current.set(docId, { handle: el, view: el.getView() });
+    } else if (editorRefs.current[docId]) {
+      delete editorRefs.current[docId];
+    }
+  };
+
+  /** The editor for this doc is going away for good (tab closing, or leaving the editor). */
+  const forgetEditor = (docId) => {
+    delete editorRefs.current[docId];
+    liveEditorsRef.current.delete(docId);
+  };
+
+  /* ── Hot exit: backups of unsaved buffers ────────────────────────────────────────────────── */
+
+  /**
+   * Run backup IPC for one id strictly in order: a remove must never overtake its write.
+   * Resolves true when the operation succeeded (main resolves { ok: false, error } on failure).
+   */
+  const queueBackupOp = (id, op) => {
+    const prev = backupQueueRef.current.get(id) || Promise.resolve();
+    const next = prev
+      .then(op)
+      .then((res) => {
+        if (res && res.ok === false) throw new Error(res.error || res.code || 'failed');
+        return true;
+      })
+      .catch((err) => {
+        console.warn('Hot-exit backup failed:', err?.message || err);
+        return false;
+      });
+    backupQueueRef.current.set(id, next);
+    next.then(() => {
+      if (backupQueueRef.current.get(id) === next) backupQueueRef.current.delete(id);
+    });
+    return next;
+  };
+
+  /** Delete a doc's backup, if one was ever written (or adopted from a restore). */
+  const dropBackup = (doc) => {
+    const id = doc?.backupId;
+    if (!id || !backupStateRef.current.has(id)) return Promise.resolve();
+    backupStateRef.current.delete(id);
+    const api = window.electronAPI?.backups;
+    if (!api?.remove) return Promise.resolve();
+    return queueBackupOp(id, () => api.remove(id));
+  };
+
+  /** Delete a backup by id (one superseded or found clean during restore). */
+  const dropBackupId = (id) => {
+    backupStateRef.current.delete(id);
+    const api = window.electronAPI?.backups;
+    if (api?.remove) queueBackupOp(id, () => api.remove(id));
+  };
+
+  /** A restored tab adopts its backup's file: mark it written so it is replaced or removed later. */
+  const adoptBackup = (id) => {
+    backupStateRef.current.set(id, { written: ['restored'], seen: null, pendingSince: 0, retryAt: 0 });
+  };
+
+  /** Identity of everything a backup holds; any change means the backup is out of date. */
+  const backupSignature = (doc, editor) => [
+    editor ? editor.getDocSnapshot() : doc.kind === 'markdown' ? doc.source : doc.codeContent,
+    editor ? editor.getSavedDoc() : doc.kind === 'markdown' ? doc.savedSource : doc.savedContent,
+    doc.format, doc.name, doc.path, doc.kind, !!doc.editMode
+  ];
+
+  const writeBackup = (doc, editor) => {
+    const api = window.electronAPI?.backups;
+    const content = editor ? editor.getContent() : doc.kind === 'markdown' ? doc.source : doc.codeContent;
+    const saved = doc.path ? docBaseline(doc) : null;
+    const data = backupPayload(doc, content, saved);
+    return queueBackupOp(doc.backupId, () => api.write(doc.backupId, data));
+  };
+
+  /**
+   * Every BACKUP_TICK_MS: back up dirty tabs whose content moved and then held still for a tick
+   * (or has waited BACKUP_MAX_WAIT_MS), and drop the backups of tabs that are clean again.
+   * Comparing identities (CodeMirror's immutable docs, the source strings) keeps an idle tick
+   * O(tabs); text is only serialised for a write.
+   */
+  const backupTick = () => {
+    if (!window.electronAPI?.backups?.write || quittingRef.current) return;
+    const now = nowMs();
+    for (const doc of docsRef.current) {
+      const editor = editorRefs.current[doc.id];
+      const live = liveEditorsRef.current.get(doc.id);
+      if (live && editor) live.view = editor.getView() ?? live.view;
+
+      let st = backupStateRef.current.get(doc.backupId);
+      if (!isDocDirty(doc)) {
+        if (st) dropBackup(doc);
+        continue;
+      }
+      if (!st) {
+        st = { written: null, seen: null, pendingSince: 0, retryAt: 0 };
+        backupStateRef.current.set(doc.backupId, st);
+      }
+      if (now < st.retryAt) continue;
+      const sig = backupSignature(doc, editor);
+      if (sameSignature(sig, st.written)) {
+        st.seen = sig;
+        st.pendingSince = 0;
+        continue;
+      }
+      if (!st.pendingSince) st.pendingSince = now;
+      const stable = sameSignature(sig, st.seen);
+      st.seen = sig;
+      if (!stable && now - st.pendingSince < BACKUP_MAX_WAIT_MS) continue;
+      st.written = sig;
+      st.pendingSince = 0;
+      writeBackup(doc, editor).then((ok) => {
+        // Not on disk after all: forget it was written, and try again in a while.
+        if (!ok && st.written === sig) {
+          st.written = null;
+          st.retryAt = nowMs() + BACKUP_RETRY_MS;
+        }
+      });
+    }
+  };
+
+  /**
+   * Write every dirty tab's backup NOW (the error boundary's crash flush). Reads buffers through
+   * liveEditorsRef, which survives React detaching the editors' refs. Resolves true when every
+   * write landed (or nothing was dirty).
+   */
+  const flushBackupsNow = () => {
+    const api = window.electronAPI?.backups;
+    const dirty = docsRef.current.filter((d) => d.dirty);
+    if (!dirty.length) return Promise.resolve(true);
+    if (!api?.write) return Promise.resolve(false);
+    return Promise.all(
+      dirty.map((doc) => {
+        const live = liveEditorsRef.current.get(doc.id);
+        const view = live?.view ?? live?.handle?.getView?.() ?? null;
+        const content = view ? view.state.doc.toString() : doc.kind === 'markdown' ? doc.source : doc.codeContent;
+        let saved = null;
+        if (doc.path) {
+          saved = live ? live.handle.getSavedContent() : doc.kind === 'markdown' ? doc.savedSource : doc.savedContent ?? doc.codeContent;
+        }
+        return api.write(doc.backupId, backupPayload(doc, content, saved)).then(() => true, () => false);
+      })
+    ).then((results) => results.every(Boolean));
+  };
+
   /* ── Opening ─────────────────────────────────────────────────────────────────────────────── */
 
-  const makeMarkdownDoc = (id, name, fPath, content) => ({
-    id,
-    kind: 'markdown',
-    name,
-    path: fPath || null,
-    source: content,
-    dirty: false,
-    editMode: false,
-    ...renderMarkdown(content, fPath)
-  });
+  /**
+   * Build a doc. `baseline` is the text on disk when it differs from `content` (restored
+   * backups); `kind` is decided by the caller (name AND size, see kindForOpen).
+   */
+  const createDoc = ({
+    kind, name, path = null, content, baseline, format = null, savedFormat = null, untitled = false,
+    editMode = false, backupId, plainText = false, notice = null, diskChange = null, diskDeleted = false
+  }) => {
+    const id = docIdRef.current++;
+    const hasBaseline = typeof baseline === 'string';
+    const common = {
+      id, name, path: path || null, untitled, format, savedFormat,
+      backupId: backupId || newBackupId(),
+      textDirty: hasBaseline && baseline !== content,
+      notice, diskChange, diskDeleted
+    };
+    if (kind === 'markdown') {
+      const { fields, failed } = renderFields(content, path);
+      if (failed) setStatusError(renderFailedMessage(name));
+      return finalizeDoc({
+        ...common, kind: 'markdown', source: content, savedSource: hasBaseline ? baseline : content, editMode, ...fields
+      });
+    }
+    return finalizeDoc({
+      ...common,
+      kind: 'code',
+      codeContent: content,
+      savedContent: hasBaseline && baseline !== content ? baseline : undefined,
+      langName: plainText ? 'Plain text' : detectLanguage(name, content)?.name ?? 'Plain text',
+      plainText,
+      largeFile: content.length > LARGE_FILE_LIMIT,
+      follow: false
+    });
+  };
+
+  /** A doc for a file just read from disk. Big Markdown opens as plain text, with a notice. */
+  const docFromDisk = (content, name, fPath, format) => {
+    const kind = kindForOpen(name, content.length);
+    const tooBig = kind === 'code' && fileKindForName(name) === 'markdown';
+    return createDoc({
+      kind, name, path: fPath, content,
+      format: format ?? (fPath ? null : UNTITLED_FORMAT),
+      savedFormat: format ?? null,
+      plainText: tooBig,
+      notice: tooBig
+        ? { text: `${name} is ${megabytes(content.length)} MB, so it opened as plain text.`, renderAction: true }
+        : null
+    });
+  };
+
+  /**
+   * A doc rebuilt from a hot-exit backup of a file. `disk` is the file's text now (null when it
+   * could not be read: `missing` says whether it is gone). The baseline is what is on disk NOW,
+   * so the tab is dirty exactly when Save would change the file; if the file moved on since the
+   * edits were made, the changed-on-disk bar says so.
+   */
+  const docFromBackup = (b, { name, path, disk, diskFormat, missing }) => {
+    const kind = b.kind === 'markdown' && b.content.length <= MARKDOWN_RENDER_LIMIT ? 'markdown' : 'code';
+    const known = typeof disk === 'string';
+    adoptBackup(b.id);
+    return createDoc({
+      kind, name, path, content: b.content,
+      baseline: known ? disk : missing ? '' : (b.savedContent ?? ''),
+      format: b.format ?? diskFormat ?? null,
+      savedFormat: known ? diskFormat ?? null : null,
+      editMode: kind === 'markdown' && !!b.editMode,
+      backupId: b.id,
+      plainText: kind === 'code' && fileKindForName(name) === 'markdown',
+      diskChange: known && typeof b.savedContent === 'string' && b.savedContent !== disk
+        ? { content: disk, format: diskFormat ?? null }
+        : null,
+      diskDeleted: !!missing
+    });
+  };
+
+  /**
+   * A restored backup takes the focus when nothing else has a claim to it: no explicit open, no
+   * active tab remembered by the session, nothing selected yet. Unsaved work you were in the
+   * middle of beats the home screen.
+   */
+  const focusRestored = (id) => {
+    if (!explicitOpenRef.current && restoreActiveKeyRef.current === null && activeIdRef.current === null) {
+      activate(id);
+    }
+  };
+
+  /** Does a backup hold anything the file on disk doesn't? */
+  const backupDiffersFromDisk = (b, disk, diskFormat) =>
+    b.content !== disk || (!!b.format && !!diskFormat && formatChanged({ format: b.format, savedFormat: diskFormat }));
+
+  /**
+   * A backup for a file whose tab already exists (it opened before the backups were listed).
+   * A clean tab takes the backed-up text as a dirty buffer; a tab with edits of its own keeps
+   * them, and the backup becomes a separate recovered copy rather than being lost or merged.
+   */
+  const applyBackupToOpenDoc = (doc, b, disk, diskFormat) => {
+    if (typeof disk === 'string' && !backupDiffersFromDisk(b, disk, diskFormat)) {
+      dropBackupId(b.id);
+      applyDiskContent(doc, disk, diskFormat);
+      return;
+    }
+    if (isDocDirty(doc)) {
+      restoreUntitled({ ...b, name: `Recovered ${b.name || baseName(b.path)}`, path: null });
+      return;
+    }
+    const baseline = typeof disk === 'string' ? disk : docBaseline(doc);
+    const editor = editorRefs.current[doc.id];
+    if (editor) editor.replaceContent(b.content, baseline);
+    let rendered = {};
+    if (doc.kind === 'markdown' && !doc.editMode) {
+      const { fields, failed } = renderFields(b.content, doc.path);
+      if (failed) setStatusError(renderFailedMessage(doc.name));
+      rendered = fields;
+    }
+    dropBackup(doc);
+    adoptBackup(b.id);
+    patchDoc(doc.id, {
+      backupId: b.id,
+      format: b.format ?? doc.format,
+      textDirty: b.content !== baseline,
+      ...(doc.kind === 'markdown' ? { source: b.content, savedSource: baseline, ...rendered } : {}),
+      ...(doc.kind === 'code' && !editor ? { codeContent: b.content, savedContent: baseline } : {}),
+      diskChange: typeof disk === 'string' && typeof b.savedContent === 'string' && b.savedContent !== disk
+        ? { content: disk, format: diskFormat ?? null }
+        : null
+    });
+  };
 
   /**
    * Route an opened file into a tab. All open paths funnel through here: dialog, recents, drag &
    * drop, file association, second instance, session restore. A path that is already open just
-   * activates (and, for a clean buffer, refreshes) its existing tab instead of duplicating it.
+   * activates its existing tab instead of duplicating it, and takes the disk text only if the tab
+   * has no unsaved changes; otherwise the changed-on-disk bar offers the choice (C3).
    */
   const openDocument = (content, name, fPath, meta = {}) => {
-    const kind = fileKindForName(name);
     const key = fPath ? pathKey(fPath) : null;
+    const format = meta.format ?? null;
 
     /** Should this open take the user to the tab? Restored tabs only do so if they were active. */
     const shouldFocus = () => {
@@ -211,300 +665,484 @@ function App() {
       return !explicitOpenRef.current && !!key && key === restoreActiveKeyRef.current;
     };
 
-    const existing = key && docsRef.current.find((d) => d.path && pathKey(d.path) === key);
+    const restore = key ? pendingRestoresRef.current.get(key) : null;
+    if (restore) pendingRestoresRef.current.delete(key);
+
+    const existing = key ? findDocByPath(fPath) : null;
     if (existing) {
-      if (shouldFocus()) setActiveId(existing.id);
-      if (existing.kind === 'markdown' && !existing.editMode) {
-        setDocs((ds) =>
-          ds.map((d) => (d.id === existing.id ? { ...d, source: content, ...renderMarkdown(content, fPath) } : d))
-        );
-      } else {
-        const editor = editorRefs.current[existing.id];
-        if (editor && !editor.isDirty()) editor.replaceContent(content);
-      }
+      if (shouldFocus()) activate(existing.id);
+      if (restore) applyBackupToOpenDoc(existing, restore, content, format);
+      else applyDiskContent(existing, content, format);
+      setIsLoading(false);
       return;
     }
 
-    const id = docIdRef.current++;
-    const doc =
-      kind === 'markdown'
-        ? makeMarkdownDoc(id, name, fPath, content)
-        : {
-            id,
-            kind,
-            name,
-            path: fPath || null,
-            codeContent: content,
-            langName: detectLanguage(name, content)?.name ?? 'Plain text',
-            dirty: false
-          };
-    setDocs((ds) => [...ds, doc]);
-    if (shouldFocus()) setActiveId(id);
+    let doc;
+    const restoring = !!restore && backupDiffersFromDisk(restore, content, format);
+    if (restoring) {
+      doc = docFromBackup(restore, { name, path: fPath, disk: content, diskFormat: format, missing: false });
+    } else {
+      if (restore) dropBackupId(restore.id); // nothing in it that isn't on disk already
+      doc = docFromDisk(content, name, fPath, format);
+    }
+    commitDocs((ds) => [...ds, doc]);
+    if (shouldFocus()) activate(doc.id);
+    else if (restoring) focusRestored(doc.id);
     setIsLoading(false);
   };
 
   /** A fresh, empty, unsaved buffer. Saving offers every supported format (Save As). */
-  const newFile = useCallback(() => {
-    const id = docIdRef.current++;
+  const newFile = () => {
     const n = untitledCounterRef.current++;
-    setDocs((ds) => [
-      ...ds,
-      {
-        id,
-        kind: 'code',
-        name: `Untitled-${n}`,
-        path: null,
-        codeContent: '',
-        langName: 'Plain text',
-        dirty: false,
-        untitled: true
-      }
-    ]);
-    setActiveId(id);
-  }, []);
+    const doc = createDoc({
+      kind: 'code', name: `Untitled-${n}`, content: '', baseline: '', untitled: true, format: UNTITLED_FORMAT
+    });
+    commitDocs((ds) => [...ds, doc]);
+    activate(doc.id);
+  };
 
-  /* ── Closing / navigation ────────────────────────────────────────────────────────────────── */
+  /**
+   * An Untitled tab from a backup (or a recovered copy that has nowhere else to go). `focus`: it
+   * was the active tab when the last session ended, and nothing was opened explicitly since.
+   */
+  const restoreUntitled = (b, { focus = false } = {}) => {
+    const n = untitledNumber(b.name);
+    if (n >= untitledCounterRef.current) untitledCounterRef.current = n + 1;
+    const kind = b.kind === 'markdown' && b.content.length <= MARKDOWN_RENDER_LIMIT ? 'markdown' : 'code';
+    adoptBackup(b.id);
+    const doc = createDoc({
+      kind,
+      name: b.name || `Untitled-${untitledCounterRef.current++}`,
+      content: b.content,
+      baseline: '',
+      untitled: true,
+      format: b.format ?? UNTITLED_FORMAT,
+      editMode: kind === 'markdown' && !!b.editMode,
+      backupId: b.id
+    });
+    commitDocs((ds) => [...ds, doc]);
+    if (focus && !explicitOpenRef.current) activate(doc.id);
+    else focusRestored(doc.id);
+  };
 
-  const closeDoc = useCallback(async (docId) => {
-    const doc = docsRef.current.find((d) => d.id === docId);
-    if (!doc) return;
+  /**
+   * A path-backed backup whose file never opened: gone from disk ('missing'), or it failed to
+   * open or took too long ('unopened'). The text comes back anyway, as a tab on that path. Main
+   * never opened the path, so Save goes through Save As (see saveDoc's NOT_OPEN fallback).
+   */
+  const restoreWithoutFile = (key, reason) => {
+    const b = pendingRestoresRef.current.get(key);
+    if (!b) return;
+    pendingRestoresRef.current.delete(key);
+    const existing = findDocByPath(b.path);
+    if (existing) {
+      applyBackupToOpenDoc(existing, b, null, null);
+      return;
+    }
+    const doc = docFromBackup(b, {
+      name: b.name || baseName(b.path), path: b.path, disk: null, diskFormat: null, missing: reason === 'missing'
+    });
+    commitDocs((ds) => [...ds, doc]);
+    focusRestored(doc.id);
+  };
 
-    const editor = editorRefs.current[docId];
-    const dirty = editor ? editor.isDirty() : doc.dirty;
-    if (dirty) {
-      const discard = window.electronAPI
-        ? await window.electronAPI.confirmDiscard(`Discard unsaved changes and close ${doc.name}?`)
-        : window.confirm('Discard unsaved changes?');
-      if (!discard) return;
+  /**
+   * Startup: last session's tabs (when that setting is on) and hot-exit backups (always: they only
+   * exist after a session that did not end cleanly). Backups of files are applied as each file's
+   * tab opens through the normal open path; Untitled ones become tabs straight away.
+   */
+  const restoreSession = (session, backups) => {
+    if (restoreStartedRef.current) return; // StrictMode runs the boot effect twice in development
+    restoreStartedRef.current = true;
+    const api = window.electronAPI;
+    const hasSession = !!session && Array.isArray(session.paths);
+    // Known before anything is restored, so restored tabs never take the focus from it.
+    if (hasSession) restoreActiveKeyRef.current = session.active ? pathKey(session.active) : null;
+
+    const plan = planRestore(backups, pathKey);
+    for (const id of plan.drop) dropBackupId(id);
+    for (const [key, b] of plan.byPath) pendingRestoresRef.current.set(key, b);
+    for (const b of plan.untitled) {
+      if (b.content) restoreUntitled(b, { focus: !!session?.activeBackup && b.id === session.activeBackup });
+      else dropBackupId(b.id); // an empty Untitled buffer: nothing to bring back
     }
 
-    if (doc.path && window.electronAPI) window.electronAPI.closeFile(doc.path);
-    delete editorRefs.current[docId];
+    const opening = new Set();
+    const openForRestore = (p) => {
+      const key = pathKey(p);
+      if (opening.has(key)) return;
+      opening.add(key);
+      api
+        .openRecentFile(p, { fromRestore: true })
+        .then((res) => {
+          // 'missing': the file is gone. 'error': it would not open, and main said why.
+          if (!res?.ok) restoreWithoutFile(key, res?.reason === 'missing' ? 'missing' : 'unopened');
+        })
+        .catch(() => restoreWithoutFile(key, 'unopened'));
+    };
 
-    // Computed outside the setDocs updater; updaters must stay pure (StrictMode runs them twice).
-    const ds = docsRef.current;
-    const idx = ds.findIndex((d) => d.id === docId);
-    const next = ds.filter((d) => d.id !== docId);
-    if (splitIdRef.current === docId) {
-      setSplitId(null);
-      setDiffData(null);
-    }
-    if (activeIdRef.current === docId) {
-      setActiveId(next.length ? next[Math.min(idx, next.length - 1)].id : null);
+    /*
+     * Session restore: reopen last session's saved-to-disk tabs (untitled buffers come back
+     * through their backups instead). Runs through the main process like every open, so watchers,
+     * recents and same-path dedupe (against e.g. a file-association argv arriving in parallel) all
+     * apply. Paths are de-duplicated here too, in case an older version saved a doubled list.
+     */
+    if (hasSession) {
+      const paths = uniqueBy(session.paths.filter((p) => typeof p === 'string' && p), pathKey);
+      for (const p of paths) openForRestore(p);
     }
-    setDocs(next);
-  }, []);
+    for (const b of plan.byPath.values()) openForRestore(b.path);
 
-  /** Cycle the active tab by offset. From the home screen, enter the strip at either end. */
-  const cycleTab = useCallback((dir) => {
-    const ds = docsRef.current;
-    if (ds.length === 0) return;
-    const idx = ds.findIndex((d) => d.id === activeIdRef.current);
-    if (idx === -1) {
-      setActiveId(dir > 0 ? ds[0].id : ds[ds.length - 1].id);
-    } else {
-      setActiveId(ds[(idx + dir + ds.length) % ds.length].id);
+    if (plan.byPath.size) {
+      setTimeout(() => {
+        for (const key of [...pendingRestoresRef.current.keys()]) restoreWithoutFile(key, 'unopened');
+      }, RESTORE_TIMEOUT_MS);
     }
-  }, []);
+  };
 
-  /* ── Recents / default-app plumbing ──────────────────────────────────────────────────────── */
+  /* ── Disk changes ────────────────────────────────────────────────────────────────────────── */
 
-  const refreshRecentFiles = useCallback(() => {
-    if (!window.electronAPI?.getRecentFiles) return;
-    window.electronAPI.getRecentFiles().then(setRecentFiles).catch(() => {});
-  }, []);
+  /**
+   * The disk's format is now `format`: record it, and make it the tab's too unless the user
+   * picked a different one for this tab (an EOL or encoding switch not yet saved).
+   */
+  const followDiskFormat = (doc, format) => {
+    if (!format) return {};
+    return formatChanged(doc) ? { savedFormat: format } : { savedFormat: format, format };
+  };
 
-  const refreshDefaultAppStatus = useCallback(() => {
-    if (!window.electronAPI?.getDefaultAppStatus) return;
-    window.electronAPI.getDefaultAppStatus().then(setDefaultAppStatus).catch(() => {});
-  }, []);
+  /** Replace a doc's text with `content` from disk: it becomes the baseline, the tab is clean. */
+  const reloadDoc = (doc, content, format) => {
+    const editor = editorRefs.current[doc.id];
+    if (editor) editor.replaceContent(content);
+    const patch = {
+      textDirty: false,
+      diskChange: null,
+      diskDeleted: false,
+      savedFormat: format ?? doc.savedFormat,
+      format: format ?? doc.savedFormat ?? doc.format
+    };
+    if (doc.kind === 'markdown') {
+      patch.source = content;
+      patch.savedSource = content;
+      if (!doc.editMode) {
+        const { fields, failed } = renderFields(content, doc.path);
+        if (failed) setStatusError(renderFailedMessage(doc.name));
+        Object.assign(patch, fields);
+      }
+    } else if (!editor) {
+      patch.codeContent = content;
+      patch.savedContent = undefined;
+    }
+    patchDoc(doc.id, patch);
+    if (doc.follow) editor?.scrollToEnd();
+    dropBackup(doc);
+  };
 
-  const requestDefaultApp = useCallback(() => {
-    if (!window.electronAPI?.requestDefaultApp) return;
-    setStatusError('');
-    window.electronAPI
-      .requestDefaultApp()
-      .then((res) => {
-        if (res && res.ok === false) setStatusError(res.error || 'Could not open Windows Settings');
-      })
-      .catch((err) => setStatusError(err?.message || 'Could not open Windows Settings'));
-  }, []);
+  /**
+   * New text for a doc's file arrived from disk: a watch event, or the path opened again. A clean
+   * tab reloads (caret and folds stay put; see CodeEditor.replaceContent). A tab with unsaved
+   * changes KEEPS them, whatever mode it is in, and shows the changed-on-disk bar (C3: a Markdown
+   * tab in Reading view used to be overwritten here, dirty dot and all, and Save then wrote the
+   * disk text back).
+   */
+  const applyDiskContent = (doc, content, format) => {
+    const editor = editorRefs.current[doc.id];
+    if (content === docBaseline(doc)) {
+      /*
+       * Identical to what we already know is on disk: NOT a change. Windows fires watch events
+       * for more than real writes (Defender scans, indexing touch the file); a re-render on those
+       * visibly resets post-render DOM work like mermaid diagrams for no reason. The format may
+       * still have moved on its own (another tool converted CRLF to LF): that is a format update,
+       * and the tab follows it unless the user switched the tab's format themselves.
+       */
+      patchDoc(doc.id, { diskDeleted: false, diskChange: null, ...followDiskFormat(doc, format) });
+      return;
+    }
+    if (content === docText(doc)) {
+      // The buffer already holds exactly what is on disk now (saved elsewhere, edits undone).
+      if (editor) editor.setBaseline(content);
+      patchDoc(doc.id, {
+        ...(doc.kind === 'markdown' ? { savedSource: content } : editor ? {} : { savedContent: undefined, codeContent: content }),
+        textDirty: editor ? editor.isDirty() : false,
+        ...followDiskFormat(doc, format),
+        diskChange: null,
+        diskDeleted: false
+      });
+      return;
+    }
+    if (!isDocDirty(doc)) {
+      reloadDoc(doc, content, format);
+      return;
+    }
+    patchDoc(doc.id, { diskChange: { content, format: format ?? null }, diskDeleted: false });
+  };
 
-  /* ── Boot: stored settings + IPC listeners ───────────────────────────────────────────────── */
+  /** Live-reload on external disk change, routed to the owning tab by path. */
+  const onDiskChange = (content, changedPath, meta = {}) => {
+    const doc = findDocByPath(changedPath);
+    if (doc) applyDiskContent(doc, content, meta.format ?? null);
+  };
 
-  useEffect(() => {
-    if (window.electronAPI) {
-      Promise.all([
-        window.electronAPI.store.get('theme'),
-        window.electronAPI.store.get('autoUpdatesEnabled'),
-        window.electronAPI.store.get('sidebarWidth'),
-        window.electronAPI.store.get('shortcuts'),
-        window.electronAPI.store.get('printPageSize'),
-        window.electronAPI.store.get('printLandscape'),
-        window.electronAPI.store.get('editorWrap'),
-        window.electronAPI.store.get('editorTabSize'),
-        window.electronAPI.store.get('editorLint'),
-        window.electronAPI.store.get('fonts'),
-        window.electronAPI.store.get('restoreSession'),
-        window.electronAPI.store.get('customTheme'),
-        window.electronAPI.store.get('session')
-      ]).then(([theme, autoUpdatesEnabled, savedSidebarWidth, shortcuts, printPageSize, printLandscape, editorWrap, editorTabSize, editorLint, fonts, restoreSession, customTheme, session]) => {
-        const resolvedCustom = resolveCustomTheme(customTheme);
-        const resolvedTheme = resolveTheme(theme, !!resolvedCustom);
-        const resolvedFonts = resolveFonts(fonts);
-        const newSettings = {
-          theme: resolvedTheme,
-          autoUpdatesEnabled: autoUpdatesEnabled !== false,
-          sidebarWidth: savedSidebarWidth || 300,
-          printPageSize: printPageSize || 'Letter',
-          printLandscape: !!printLandscape,
-          editorWrap: !!editorWrap,
-          editorTabSize: [2, 4, 8].includes(editorTabSize) ? editorTabSize : 4,
-          editorLint: editorLint !== false,
-          fonts: resolvedFonts,
-          restoreSession: restoreSession !== false,
-          customTheme: resolvedCustom,
-          shortcuts: resolveShortcuts(shortcuts)
-        };
-        setSettings(newSettings);
-        setSidebarWidth(newSettings.sidebarWidth);
-        applyCustomTheme(resolvedCustom);
-        document.documentElement.setAttribute('data-theme', resolvedTheme);
-        applyFonts(resolvedFonts);
-
-        // Persist the migration so the legacy value isn't re-resolved on every launch.
-        if (theme !== resolvedTheme) {
-          window.electronAPI.store.set('theme', resolvedTheme);
-        }
+  /**
+   * The file behind a tab is gone. Its text now exists only here, so the baseline becomes empty
+   * (the tab is dirty unless it is empty too, and closing it prompts) and a bar says what
+   * happened. Saving writes the file again.
+   */
+  const markDeleted = (deletedPath) => {
+    const doc = findDocByPath(deletedPath);
+    if (!doc) return;
+    const editor = editorRefs.current[doc.id];
+    if (editor) editor.setBaseline('');
+    patchDoc(doc.id, {
+      diskDeleted: true,
+      diskChange: null,
+      ...(doc.kind === 'markdown' ? { savedSource: '' } : editor ? {} : { savedContent: '' }),
+      textDirty: editor ? editor.isDirty() : docText(doc) !== ''
+    });
+  };
 
-        /*
-         * Session restore: reopen last session's saved-to-disk tabs (untitled buffers are gone by
-         * definition). Runs through the main process like every open, so watchers, recents and
-         * same-path dedupe (against e.g. a file-association argv arriving in parallel) all apply.
-         */
-        if (newSettings.restoreSession && Array.isArray(session?.paths)) {
-          restoreActiveKeyRef.current = session.active ? pathKey(session.active) : null;
-          for (const p of session.paths) {
-            window.electronAPI.openRecentFile(p, { fromRestore: true }).catch(() => {});
-          }
-        }
-      });
+  /** Changed-on-disk bar → Reload: take the disk version, discarding the tab's unsaved edits. */
+  const reloadFromDisk = (id) => {
+    const doc = getDoc(id);
+    if (doc?.diskChange) reloadDoc(doc, doc.diskChange.content, doc.diskChange.format);
+  };
 
-      window.electronAPI.getRuntimeInfo?.().then(setRuntimeInfo).catch(() => {});
-      window.electronAPI.getSystemFonts?.().then(setSystemFonts).catch(() => {});
+  /**
+   * Changed-on-disk bar → Keep mine. The disk text becomes the baseline, so the tab stays dirty
+   * for exactly as long as it differs from the file, and Save overwrites the file with this tab.
+   */
+  const keepMine = (id) => {
+    const doc = getDoc(id);
+    if (!doc?.diskChange) return;
+    const { content, format } = doc.diskChange;
+    const editor = editorRefs.current[id];
+    if (editor) editor.setBaseline(content);
+    patchDoc(id, {
+      diskChange: null,
+      ...followDiskFormat(doc, format),
+      ...(doc.kind === 'markdown' ? { savedSource: content } : editor ? {} : { savedContent: content }),
+      textDirty: editor ? editor.isDirty() : docText(doc) !== content
+    });
+  };
 
-      window.electronAPI.onOpenFile((content, name, path, meta) => {
-        openDocument(content, name, path, meta);
-      });
+  /** Changed-on-disk bar → Compare: the diff view, the file on disk against this tab. */
+  const compareWithDisk = (id) => {
+    const doc = getDoc(id);
+    if (!doc?.diskChange) return;
+    setDiffData({
+      leftText: doc.diskChange.content,
+      rightText: docText(doc),
+      leftName: `${doc.name} (on disk)`,
+      rightName: `${doc.name} (your version)`,
+      leftFileName: doc.name,
+      rightFileName: doc.name,
+      docIds: [doc.id]
+    });
+  };
 
-      /*
-       * Live-reload on external disk change, routed to the owning tab by path. Registered once;
-       * everything it touches is a ref or a setter. Never clobbers unsaved edits: a dirty buffer
-       * keeps the user's version and notes the change in the status bar.
-       */
-      window.electronAPI.onFileChanged((content, changedPath) => {
-        const key = pathKey(changedPath);
-        const doc = docsRef.current.find((d) => d.path && pathKey(d.path) === key);
-        if (!doc) return;
-
-        const editor = editorRefs.current[doc.id];
-        if (editor) {
-          // Code tab, or a markdown tab in edit mode.
-          if (content === editor.getContent()) return; // spurious watch event, nothing changed
-          if (editor.isDirty()) {
-            setStatusError(`${doc.name} changed on disk, but your unsaved edits were kept`);
-          } else {
-            editor.replaceContent(content);
-            if (doc.kind === 'markdown') {
-              setDocs((ds) => ds.map((d) => (d.id === doc.id ? { ...d, source: content } : d)));
-            }
-          }
-        } else if (doc.kind === 'markdown') {
-          /*
-           * Identical content is a NO-OP, not a refresh. Windows fires watch events for more than
-           * real writes (Defender scans, indexing touch the file); re-rendering on those visibly
-           * resets post-render DOM work like mermaid diagrams for no reason.
-           */
-          if (content === doc.source) return;
-          setDocs((ds) =>
-            ds.map((d) => (d.id === doc.id ? { ...d, source: content, ...renderMarkdown(content, doc.path) } : d))
-          );
-        }
-      });
+  /* ── File format: line endings and encoding ──────────────────────────────────────────────── */
 
-      window.electronAPI.getAppVersion().then((version) => setAppVersion(version));
+  const toggleEol = (id) => {
+    const doc = getDoc(id);
+    if (!doc?.format) return;
+    patchDoc(id, { format: { ...doc.format, eol: toggledEol(doc.format.eol) } });
+  };
 
-      window.electronAPI.onUpdateMessage((message, action) => {
-        setUpdateStatus(message);
-        setUpdateAction(action);
-      });
+  /** "Save with encoding…": applies on the next save; until then the tab is dirty. */
+  const setEncoding = (id, choice) => {
+    const doc = getDoc(id);
+    if (!doc?.format) return;
+    patchDoc(id, { format: { ...doc.format, encoding: choice.encoding, bom: choice.bom } });
+  };
 
-      refreshRecentFiles();
-      refreshDefaultAppStatus();
-      window.electronAPI.appReady();
-    } else {
-      // Browser dev mode (`npm run dev` without Electron): no store to read from.
-      document.documentElement.setAttribute('data-theme', DEFAULT_THEME);
-      applyFonts(DEFAULT_FONTS);
+  /** "Reopen with encoding…": reread the file decoded another way. Clean tabs only. */
+  const reopenWithEncoding = async (id, encoding) => {
+    const doc = getDoc(id);
+    const api = window.electronAPI;
+    if (!doc?.path || isDocDirty(doc) || !api?.reopenWithEncoding) return;
+    let res;
+    try {
+      res = await api.reopenWithEncoding(doc.path, encoding);
+    } catch (err) {
+      res = { ok: false, error: err?.message };
     }
-    /*
-     * openDocument is deliberately not a dependency: the IPC listeners are registered once, and
-     * everything openDocument touches is either a setter (stable) or a ref (always current).
-     */
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [refreshRecentFiles, refreshDefaultAppStatus]);
+    if (!res?.ok || typeof res.content !== 'string') {
+      setStatusError(res?.error || `Couldn't reopen ${doc.name}`);
+      return;
+    }
+    const latest = getDoc(id);
+    if (!latest || isDocDirty(latest)) return; // edited while the file was read: leave it be
+    reloadDoc(latest, res.content, res.format ?? null);
+  };
 
-  /*
-   * One place owns the window title and the mirrored any-tab-dirty flag. Since 1.11.0 the title is
-   * just the active document's filename (the full app name shows on the home screen only); the
-   * main process composes it; see composeTitle in main.cjs.
+  /* ── Large files, logs and Markdown in the editor ────────────────────────────────────────── */
+
+  /** Follow: while on, each reload from disk (clean tab) scrolls to the end, like `tail -f`. */
+  const toggleFollow = (id) => {
+    const doc = getDoc(id);
+    if (!doc || doc.kind !== 'code') return;
+    patchDoc(id, { follow: !doc.follow });
+    if (!doc.follow) editorRefs.current[id]?.scrollToEnd();
+  };
+
+  /**
+   * "Render as Markdown": turn a code tab that could be Markdown (.txt, extensionless, or a .md
+   * too big to render on open) into a Markdown reading tab, unsaved changes and all. Rendering
+   * goes through the same safety net as every other render.
    */
-  useEffect(() => {
-    if (!window.electronAPI) return;
-    const anyDirty = docs.some((d) => d.dirty);
-    window.electronAPI.setEdited(anyDirty);
-    window.electronAPI.setTitle(activeDoc ? activeDoc.name : null, !!activeDoc?.dirty);
-  }, [docs, activeDoc]);
+  const renderAsMarkdown = (id) => {
+    const doc = getDoc(id);
+    if (!doc || doc.kind !== 'code' || !couldBeMarkdown(doc.name)) return;
+    const editor = editorRefs.current[id];
+    const text = editor ? editor.getContent() : doc.codeContent;
+    const saved = docBaseline(doc);
+    const textDirty = editor ? editor.isDirty() : !!doc.textDirty;
+    const { fields, failed } = renderFields(text, doc.path);
+    if (failed) setStatusError(renderFailedMessage(doc.name));
+    forgetEditor(id);
+    patchDoc(id, {
+      kind: 'markdown', source: text, savedSource: saved, textDirty, editMode: false, ...fields,
+      notice: null, codeContent: undefined, savedContent: undefined, langName: undefined,
+      plainText: false, largeFile: false, follow: false
+    });
+  };
 
-  /*
-   * Persist the session (paths only) whenever the tab set or active tab changes, debounced.
-   *
-   * With "reopen last session's tabs" off, the stored list is CLEARED rather than merely ignored
-   * at launch. Someone who turns that off is saying they want FATE to start empty; leaving a list
-   * of everything they had open sitting in the config file is not that.
+  /* ── Closing / navigation ────────────────────────────────────────────────────────────────── */
+
+  /**
+   * Take a tab out NOW: no prompt (callers asked already). `unwatch: false` when another tab owns
+   * the path from here on (Save As onto it), so its watcher must stay.
    */
-  useEffect(() => {
-    if (!window.electronAPI) return;
-    clearTimeout(sessionSaveTimerRef.current);
-    if (!settings.restoreSession) {
-      window.electronAPI.store.set('session', { paths: [], active: null });
-      return;
+  const removeDocNow = (docId, { unwatch = true } = {}) => {
+    const doc = getDoc(docId);
+    if (!doc) return;
+    if (unwatch && doc.path && window.electronAPI) window.electronAPI.closeFile(doc.path);
+    forgetEditor(docId);
+    dropBackup(doc);
+
+    const ds = docsRef.current;
+    const idx = ds.findIndex((d) => d.id === docId);
+    const next = ds.filter((d) => d.id !== docId);
+    if (splitIdRef.current === docId) {
+      setSplit(null);
+      setDiffData(null);
     }
-    sessionSaveTimerRef.current = setTimeout(() => {
-      window.electronAPI.store.set('session', {
-        paths: docs.filter((d) => d.path).map((d) => d.path),
-        active: activeDoc?.path ?? null
-      });
-    }, 400);
-    return () => clearTimeout(sessionSaveTimerRef.current);
-  }, [docs, activeDoc, settings.restoreSession]);
+    // A diff of this tab (Compare, Diff unsaved changes) has nothing left to show.
+    setDiffData((dd) => (dd?.docIds?.includes(docId) ? null : dd));
+    if (activeIdRef.current === docId) {
+      activate(next.length ? next[Math.min(idx, next.length - 1)].id : null);
+    }
+    commitDocs(next);
+  };
 
+  /**
+   * Close a tab. Unsaved changes get Save / Don't save / Cancel (L15; it used to be Discard or
+   * Cancel, with no way to keep the work short of cancelling and saving by hand). Resolves true
+   * when the tab closed.
+   */
+  const closeDoc = async (docId) => {
+    const doc = getDoc(docId);
+    if (!doc) return false;
+
+    if (isDocDirty(doc)) {
+      const api = window.electronAPI;
+      const answer = api?.confirmSaveOnClose
+        ? await api.confirmSaveOnClose(doc.name).catch(() => 'cancel')
+        : window.confirm('Discard unsaved changes?')
+          ? 'discard'
+          : 'cancel';
+      if (answer === 'cancel') return false;
+      if (answer === 'save' && !(await saveDoc(docId))) return false;
+    }
 
+    removeDocNow(docId);
+    return true;
+  };
 
-  /*
-   * Re-check the default-app association when the window regains focus. Coming back from
-   * Windows Settings is the exact moment the answer may have changed.
+  /** Close several tabs in order, stopping at the first one the user keeps. */
+  const closeMany = async (ids) => {
+    for (const id of ids) {
+      if (!(await closeDoc(id))) return false;
+    }
+    return true;
+  };
+
+  /** Cycle the active tab by offset. From the home screen, enter the strip at either end. */
+  const cycleTab = (dir) => {
+    const ds = docsRef.current;
+    if (ds.length === 0) return;
+    const idx = ds.findIndex((d) => d.id === activeIdRef.current);
+    if (idx === -1) {
+      activate(dir > 0 ? ds[0].id : ds[ds.length - 1].id);
+    } else {
+      activate(ds[(idx + dir + ds.length) % ds.length].id);
+    }
+  };
+
+  /**
+   * A tab's right-click: the native menu (main builds it) picks the action, the renderer does it.
+   * Closing goes through closeDoc, so every dirty tab still gets its prompt.
    */
-  useEffect(() => {
-    const onFocus = () => refreshDefaultAppStatus();
-    window.addEventListener('focus', onFocus);
-    return () => window.removeEventListener('focus', onFocus);
-  }, [refreshDefaultAppStatus]);
+  const openTabMenu = async (e, doc) => {
+    e.preventDefault();
+    const api = window.electronAPI;
+    if (!api?.showTabContextMenu) return;
+    let action;
+    try {
+      action = await api.showTabContextMenu({ path: doc.path || null, name: doc.name });
+    } catch {
+      return;
+    }
+    const ids = docsRef.current.map((d) => d.id);
+    const idx = ids.indexOf(doc.id);
+    if (idx === -1) return;
+    const current = getDoc(doc.id);
+    switch (action) {
+      case 'copyPath':
+        if (current?.path) {
+          navigator.clipboard.writeText(current.path).catch(() => setStatusError("Couldn't copy the path"));
+        }
+        break;
+      case 'reveal':
+        if (current?.path) Promise.resolve(api.showItemInFolder?.(current.path)).catch(() => {});
+        break;
+      case 'close':
+        closeDoc(doc.id);
+        break;
+      case 'closeOthers':
+        closeMany(ids.filter((id) => id !== doc.id));
+        break;
+      case 'closeRight':
+        closeMany(ids.slice(idx + 1));
+        break;
+      default:
+        break;
+    }
+  };
+
+  /* ── Recents / default-app plumbing ──────────────────────────────────────────────────────── */
+
+  const refreshRecentFiles = useCallback(() => {
+    if (!window.electronAPI?.getRecentFiles) return;
+    window.electronAPI.getRecentFiles().then(setRecentFiles).catch(() => {});
+  }, []);
 
-  /** Refresh recents whenever the home screen comes back into view. */
-  useEffect(() => {
-    if (activeId === null) refreshRecentFiles();
-  }, [activeId, refreshRecentFiles]);
+  const refreshDefaultAppStatus = useCallback(() => {
+    if (!window.electronAPI?.getDefaultAppStatus) return;
+    window.electronAPI.getDefaultAppStatus().then(setDefaultAppStatus).catch(() => {});
+  }, []);
+
+  const requestDefaultApp = useCallback(() => {
+    if (!window.electronAPI?.requestDefaultApp) return;
+    setStatusError('');
+    window.electronAPI
+      .requestDefaultApp()
+      .then((res) => {
+        if (res && res.ok === false) setStatusError(res.error || 'Could not open Windows Settings');
+      })
+      .catch((err) => setStatusError(err?.message || 'Could not open Windows Settings'));
+  }, []);
 
   /* ── Printing ────────────────────────────────────────────────────────────────────────────── */
 
@@ -518,180 +1156,278 @@ function App() {
       .catch((err) => setStatusError(err?.message || `${label} failed`))
       .finally(() => {
         setIsPrinting(false);
-        setCodePrintText('');
+        setPrintSnapshot(null);
       });
   }, []);
 
-  const runPrintJob = useCallback(
-    (invoke, label) => {
-      if (typeof invoke !== 'function' || isPrinting) return;
-      const doc = docsRef.current.find((d) => d.id === activeIdRef.current);
-      if (!doc) return;
+  const runPrintJob = (invoke, label) => {
+    if (typeof invoke !== 'function' || isPrinting) return;
+    const doc = getDoc(activeIdRef.current);
+    if (!doc) return;
 
-      /*
-       * Anything currently backed by a live CodeMirror (code tabs, markdown in edit mode) prints
-       * from a snapshot: the editor virtualises long documents, so printing its DOM would emit one
-       * truncated page. The snapshot 
 is display:none on screen and becomes the whole
-       * document under `@media print`; the job runs after React commits it (effect below).
-       */
-      const editor = editorRefs.current[doc.id];
-      if (editor) {
-        pendingPrintRef.current = () => executePrintJob(invoke, label, doc.name);
-        setCodePrintText(editor.getContent());
-        return;
-      }
+    /*
+     * Anything currently backed by a live CodeMirror (code tabs, markdown in edit mode) prints
+     * from a snapshot: the editor virtualises long documents, so printing its DOM would emit one
+     * truncated page. The snapshot is display:none on screen and becomes the whole document under
+     * `@media print`; the job runs after React commits it (effect below).
+     *
+     * A Markdown tab in Edit mode prints RENDERED, exactly as Reading view would (L17): the
+     * snapshot is the current buffer through the same renderer, not its source.
+     */
+    const editor = editorRefs.current[doc.id];
+    if (editor && doc.kind === 'markdown') {
+      const { fields, failed } = renderFields(editor.getContent(), doc.path);
+      if (failed) setStatusError(renderFailedMessage(doc.name));
+      pendingPrintRef.current = () => executePrintJob(invoke, label, doc.name);
+      setPrintSnapshot({ kind: 'markdown', markup: { __html: fields.html } });
+      return;
+    }
+    if (editor) {
+      pendingPrintRef.current = () => executePrintJob(invoke, label, doc.name);
+      setPrintSnapshot({ kind: 'code', text: editor.getContent() });
+      return;
+    }
 
-      executePrintJob(invoke, label, doc.name);
-    },
-    [executePrintJob, isPrinting]
-  );
+    executePrintJob(invoke, label, doc.name);
+  };
 
-  /* Runs the deferred snapshot-print job once the 
 has committed to the DOM. */
+  /* Runs the deferred snapshot-print job once the snapshot has committed to the DOM. */
   useEffect(() => {
-    if (codePrintText && pendingPrintRef.current) {
+    if (printSnapshot && pendingPrintRef.current) {
       const job = pendingPrintRef.current;
       pendingPrintRef.current = null;
       job();
     }
-  }, [codePrintText]);
-
-  const openPrintPreview = useCallback(
-    () => runPrintJob(window.electronAPI?.printPreview, 'Preview'),
-    [runPrintJob]
-  );
+  }, [printSnapshot]);
 
-  const exportPdf = useCallback(
-    () => runPrintJob(window.electronAPI?.exportPdf, 'Export'),
-    [runPrintJob]
-  );
+  const openPrintPreview = () => runPrintJob(window.electronAPI?.printPreview, 'Preview');
+  const exportPdf = () => runPrintJob(window.electronAPI?.exportPdf, 'Export');
 
   /* ── Saving ──────────────────────────────────────────────────────────────────────────────── */
 
+  /**
+   * A save landed: `content` (as read from `snapshot`, when an editor supplied one) is on disk in
+   * `format`. The baseline moves to exactly that snapshot, so anything typed while the write was
+   * in flight stays dirty (M4).
+   */
+  const markDocSaved = (id, { content, snapshot, editor, format }) => {
+    const latest = getDoc(id);
+    if (!latest) return; // closed while saving
+    const ed = editorRefs.current[id];
+    if (ed) {
+      if (ed === editor && snapshot) ed.markSaved(snapshot);
+      else ed.setBaseline(content);
+    }
+    patchDoc(id, {
+      textDirty: ed ? ed.isDirty() : latest.kind === 'markdown' ? latest.source !== content : false,
+      format: format ?? latest.format,
+      savedFormat: format ?? latest.savedFormat,
+      diskChange: null,
+      diskDeleted: false,
+      ...(latest.kind === 'markdown' ? { savedSource: content } : {}),
+      ...(latest.kind === 'code' && !ed ? { savedContent: undefined, codeContent: content } : {})
+    });
+    const after = getDoc(id);
+    if (after && !isDocDirty(after)) dropBackup(after);
+  };
+
+  /**
+   * Save As landed on `res.filePath`. If that file was open in ANOTHER tab, that tab is stale:
+   * a clean one just closes, but one with unsaved changes asks first (L14; they used to vanish
+   * with it). The OS dialog already confirmed overwriting the file, and saveFileAs writes before
+   * it returns, so this is the earliest point the renderer can ask. Keeping the changes turns
+   * that tab into an unsaved copy rather than losing them.
+   */
+  const adoptSavedAsPath = async (id, res, content) => {
+    const other = docsRef.current.find((d) => d.id !== id && d.path && pathKey(d.path) === pathKey(res.filePath));
+    if (other) {
+      let discard = !isDocDirty(other);
+      if (!discard) {
+        activate(other.id);
+        discard = await window.electronAPI
+          .confirmDiscard(`${other.name} is also open in another tab with unsaved changes, and Save As just replaced the file. Discard those changes?`)
+          .catch(() => false);
+        activate(id);
+      }
+      if (discard) removeDocNow(other.id, { unwatch: false });
+      else detachDoc(other.id);
+    }
+
+    const latest = getDoc(id);
+    if (!latest) return;
+    const patch = { name: res.name, path: res.filePath, untitled: false, diskDeleted: false, diskChange: null };
+    if (latest.kind === 'code') {
+      patch.langName = latest.plainText ? 'Plain text' : detectLanguage(res.name, content)?.name ?? 'Plain text';
+    } else if (!latest.editMode) {
+      // Relative image paths resolve against the new folder.
+      const { fields } = renderFields(latest.source, res.filePath);
+      Object.assign(patch, fields);
+    }
+    patchDoc(id, patch);
+    // New extension may mean a new language, so retune the live editor without a remount.
+    editorRefs.current[id]?.setLanguage(res.name);
+  };
+
+  /** Turn a tab into an unsaved copy with no file (Save As took its file over). */
+  const detachDoc = (id) => {
+    const doc = getDoc(id);
+    if (!doc) return;
+    const editor = editorRefs.current[id];
+    if (editor) editor.setBaseline('');
+    patchDoc(id, {
+      name: `Copy of ${doc.name}`,
+      path: null,
+      untitled: true,
+      savedFormat: null,
+      diskChange: null,
+      diskDeleted: false,
+      ...(doc.kind === 'markdown' ? { savedSource: '' } : editor ? {} : { savedContent: '' }),
+      textDirty: editor ? editor.isDirty() : docText(doc) !== ''
+    });
+    setStatusError(`The other ${doc.name} tab is now an unsaved copy`);
+  };
+
   /**
    * Save a tab (active by default). Handles all three shapes: code tabs, markdown tabs (edit mode
    * saves the buffer; view mode saves the last known source), and untitled buffers (always Save
-   * As, offering every supported format). `forceAs` is the Save As action.
+   * As, offering every supported format). `forceAs` is the Save As action. The tab's `format`
+   * rides along, and the format main reports having written becomes the tab's.
    *
    * The tab is only marked clean AFTER the write succeeds; a failed save leaves the guards armed.
    * Resolves TRUE only when the file actually reached disk; the quit walk relies on that to stop
    * dead when a Save As is cancelled rather than closing over the edits it just offered to keep.
    */
-  const saveDoc = useCallback(async (docId, { forceAs = false } = {}) => {
+  const saveDoc = async (docId, { forceAs = false } = {}) => {
     const id = typeof docId === 'number' ? docId : activeIdRef.current;
-    const doc = docsRef.current.find((d) => d.id === id);
+    const doc = getDoc(id);
     if (!doc || !window.electronAPI) return false;
 
+    // Snapshot first: the text and the CodeMirror doc it came from, taken together (M4).
     const editor = editorRefs.current[id];
-    const content = editor ? editor.getContent() : doc.kind === 'markdown' ? doc.source : null;
+    const snapshot = editor ? editor.getDocSnapshot() : null;
+    const content = snapshot ? snapshot.toString() : doc.kind === 'markdown' ? doc.source : null;
     if (content === null) return false;
+    const format = doc.format ?? undefined;
 
     setStatusError('');
     try {
       let res;
-      let finalPath = doc.path;
       if (doc.path && !forceAs) {
-        res = await window.electronAPI.saveFile(doc.path, content);
-      } else {
-        const suggested = doc.untitled ? `${doc.name}.txt` : doc.name;
-        res = await window.electronAPI.saveFileAs(suggested, content, doc.path);
-        if (res?.ok) {
-          finalPath = res.filePath;
-          const newKey = pathKey(res.filePath);
-          const newLang = detectLanguage(res.name, content)?.name ?? 'Plain text';
-          setDocs((ds) =>
-            ds
-              // If the chosen path was already open in another tab, that tab is now stale, so drop it.
-              .filter((d) => d.id === id || !d.path || pathKey(d.path) !== newKey)
-              .map((d) =>
-                d.id === id ? { ...d, name: res.name, path: res.filePath, untitled: false, langName: newLang } : d
-              )
-          );
-          // New extension may mean a new language, so retune the live editor without a remount.
-          editor?.setLanguage(res.name);
+        res = await window.electronAPI.saveFile(doc.path, content, format);
+        /*
+         * Main only saves to paths it has open (opened by any route, or chosen in Save As). A tab
+         * restored from a backup whose file was gone, or would not reopen, has a path main never
+         * opened: it gets Save As, which is also how that file comes back into existence.
+         */
+        if (res?.code === 'NOT_OPEN') {
+          res = await window.electronAPI.saveFileAs(suggestedSaveName(doc), content, null, format);
+          if (res?.ok) await adoptSavedAsPath(id, res, content);
         }
+      } else {
+        res = await window.electronAPI.saveFileAs(suggestedSaveName(doc), content, doc.path, format);
+        if (res?.ok) await adoptSavedAsPath(id, res, content);
       }
 
       if (res?.ok) {
-        if (editor) editor.markSaved();
-        if (doc.kind === 'markdown') {
-          // Keep the view-mode render in sync with what's now on disk.
-          setDocs((ds) =>
-            ds.map((d) =>
-              d.id === id ? { ...d, dirty: false, source: content, ...renderMarkdown(content, finalPath) } : d
-            )
-          );
-        } else if (!editor) {
-          setDocs((ds) => ds.map((d) => (d.id === id ? { ...d, dirty: false } : d)));
-        }
+        markDocSaved(id, { content, snapshot, editor, format: res.format ?? format ?? null });
         return true;
       }
-      if (!res?.canceled) setStatusError(res?.error || 'Save failed');
+      if (res?.code === 'UNENCODABLE') {
+        // A Windows-1252 save of text it can't store. main's message suggests UTF-8.
+        setStatusError(res.error || `${doc.name} has characters Windows-1252 can't store. Save it as UTF-8 instead.`);
+      } else if (!res?.canceled) {
+        setStatusError(res?.error || 'Save failed');
+      }
       return false;
     } catch (err) {
       setStatusError(err?.message || 'Save failed');
       return false;
     }
-  }, []);
+  };
 
   /*
    * Quitting with unsaved work. The main process vetoes its own close and hands the flow here,
    * because it cannot save: the buffers live in CodeMirror. Each dirty tab is selected (so the
    * user can see what they are being asked about) and offered Save / Don't save / Cancel; Cancel,
    * or a save that does not reach disk, calls the whole quit off.
+   *
+   * Once the walk completes, the backups of the tabs the user chose not to save are deleted (a
+   * clean exit leaves none behind) and the unload guard stands down, so the window can go. If it
+   * is somehow still here ten seconds later, the close did not happen: backups resume.
    */
-  useEffect(() => {
-    if (!window.electronAPI?.onRequestClose) return;
-    window.electronAPI.onRequestClose(async () => {
-      for (const doc of docsRef.current) {
-        const editor = editorRefs.current[doc.id];
-        if (!(editor ? editor.isDirty() : doc.dirty)) continue;
-        setActiveId(doc.id);
-        const answer = await window.electronAPI.confirmSaveOnClose(doc.name);
-        if (answer === 'cancel') {
-          window.electronAPI.confirmedClose(false);
-          return;
-        }
-        if (answer === 'save' && !(await saveDoc(doc.id))) {
-          window.electronAPI.confirmedClose(false);
+  const handleQuitRequest = async () => {
+    const api = window.electronAPI;
+    const discarded = [];
+    for (const { id } of [...docsRef.current]) {
+      const doc = getDoc(id);
+      if (!doc || !isDocDirty(doc)) continue;
+      activate(doc.id);
+      const answer = await api.confirmSaveOnClose(doc.name).catch(() => 'cancel');
+      if (answer === 'cancel') {
+        api.confirmedClose(false);
+        return;
+      }
+      if (answer === 'save') {
+        if (!(await saveDoc(doc.id))) {
+          api.confirmedClose(false);
           return;
         }
+      } else {
+        discarded.push(getDoc(doc.id) || doc);
       }
-      window.electronAPI.confirmedClose(true);
-    });
-  }, [saveDoc]);
+    }
+    quittingRef.current = true;
+    await Promise.race([
+      Promise.all(discarded.map((d) => dropBackup(d))),
+      new Promise((resolve) => setTimeout(resolve, 2000))
+    ]);
+    api.confirmedClose(true);
+    setTimeout(() => {
+      quittingRef.current = false;
+    }, 10000);
+  };
 
-  /** Mirror a tab's dirty flag into the doc list (title + guards follow via the sync effect). */
-  const handleDirtyChange = useCallback((docId, dirty) => {
-    setDocs((ds) => ds.map((d) => (d.id === docId ? { ...d, dirty } : d)));
-  }, []);
+  /** Mirror an editor's dirty flag into the doc list (title + guards follow via the sync effect). */
+  const handleDirtyChange = (docId, textDirty) => {
+    const doc = getDoc(docId);
+    if (!doc || !!doc.textDirty === textDirty) return;
+    patchDoc(docId, { textDirty });
+    const after = getDoc(docId);
+    if (after && !isDocDirty(after)) dropBackup(after); // clean again: nothing to keep
+  };
 
   /* ── Markdown edit mode ──────────────────────────────────────────────────────────────────── */
 
-  const toggleMarkdownEdit = useCallback((docId) => {
+  /**
+   * Leaving Edit mode hands the buffer to the reading view along with its baseline and dirty
+   * state, so the tab is exactly as unsaved as it was (C4), and coming back into Edit mode
+   * measures dirtiness against `savedSource`, not against whatever text the tab holds.
+   */
+  const toggleMarkdownEdit = (docId) => {
     const id = typeof docId === 'number' ? docId : activeIdRef.current;
-    const doc = docsRef.current.find((d) => d.id === id);
+    const doc = getDoc(id);
     if (!doc || doc.kind !== 'markdown') return;
 
     if (!doc.editMode) {
-      setDocs((ds) => ds.map((d) => (d.id === id ? { ...d, editMode: true } : d)));
-    } else {
-      // Leaving edit mode: the buffer becomes the source of truth for the reading view.
-      const editor = editorRefs.current[id];
-      const text = editor ? editor.getContent() : doc.source;
-      delete editorRefs.current[id];
-      setDocs((ds) =>
-        ds.map((d) =>
-          d.id === id ? { ...d, editMode: false, source: text, ...renderMarkdown(text, d.path) } : d
-        )
-      );
+      patchDoc(id, { editMode: true });
+      return;
     }
-  }, []);
+    // Leaving edit mode: the buffer becomes the source of truth for the reading view.
+    const editor = editorRefs.current[id];
+    const text = editor ? editor.getContent() : doc.source;
+    const savedSource = editor ? editor.getSavedContent() : doc.savedSource;
+    const textDirty = editor ? editor.isDirty() : !!doc.textDirty;
+    const { fields, failed } = renderFields(text, doc.path);
+    if (failed) setStatusError(renderFailedMessage(doc.name));
+    forgetEditor(id);
+    patchDoc(id, { editMode: false, source: text, savedSource, textDirty, ...fields });
+  };
 
   /* ── Split view & diff ───────────────────────────────────────────────────────────────────── */
 
-  const toggleSplit = useCallback(() => {
+  const toggleSplit = () => {
     if (splitIdRef.current !== null) {
-      setSplitId(null);
+      setSplit(null);
       setDiffData(null);
       return;
     }
@@ -702,54 +1438,340 @@ function App() {
     }
     const idx = ds.findIndex((d) => d.id === activeIdRef.current);
     const other = ds[(idx + 1) % ds.length];
-    setSplitId(other.id);
-  }, []);
-
-  /** Text content of a doc as it stands right now (live buffer if an editor is mounted). */
-  const docText = useCallback((doc) => {
-    const editor = editorRefs.current[doc.id];
-    if (editor) return editor.getContent();
-    if (doc.kind === 'markdown') return doc.source;
-    return doc.codeContent;
-  }, []);
+    setSplit(other.id);
+  };
 
-  const toggleDiff = useCallback(() => {
+  const toggleDiff = () => {
     if (diffData) {
       setDiffData(null);
       return;
     }
-    const a = docsRef.current.find((d) => d.id === activeIdRef.current);
+    const a = getDoc(activeIdRef.current);
     if (!a) return;
 
     // With a split open: compare the two panes.
-    const b = docsRef.current.find((d) => d.id === splitIdRef.current);
+    const b = getDoc(splitIdRef.current);
     if (b) {
-      setDiffData({ leftText: docText(a), rightText: docText(b), leftName: a.name, rightName: b.name });
+      setDiffData({
+        leftText: docText(a), rightText: docText(b), leftName: a.name, rightName: b.name,
+        leftFileName: a.name, rightFileName: b.name, docIds: [a.id, b.id]
+      });
       return;
     }
 
     /*
      * No split: diff THIS file's unsaved edits against its last-saved state. The baseline is the
-     * editor's saved snapshot (what markSaved recorded), so it works without touching the disk,
-     * and stays correct even if the file changed externally while dirty (the baseline is what YOU
-     * last had saved, which is what your edits diverged from).
+     * one the dirty flag uses (the editor's, or a reading-view Markdown tab's savedSource), so it
+     * works without touching the disk and always agrees with the tab's dirty dot. The real file
+     * names ride along for syntax highlighting.
      */
     const editor = editorRefs.current[a.id];
-    if (!editor) {
-      setStatusError('Open the document in the editor (Edit mode for markdown) to diff unsaved changes');
+    const saved = editor ? editor.getSavedContent() : a.kind === 'markdown' ? a.savedSource : null;
+    if (saved == null) {
+      setStatusError('Open the document in the editor to diff unsaved changes');
       return;
     }
-    if (!editor.isDirty()) {
-      setStatusError('No unsaved changes to diff');
+    if (!(editor ? editor.isDirty() : a.textDirty)) {
+      setStatusError(
+        formatChanged(a)
+          ? 'The text is unchanged; only the line endings or encoding change when you save'
+          : 'No unsaved changes to diff'
+      );
       return;
     }
     setDiffData({
-      leftText: editor.getSavedContent(),
-      rightText: editor.getContent(),
+      leftText: saved,
+      rightText: docText(a),
       leftName: `${a.name} (saved)`,
-      rightName: `${a.name} (unsaved)`
+      rightName: `${a.name} (unsaved)`,
+      leftFileName: a.name,
+      rightFileName: a.name,
+      docIds: [a.id]
     });
-  }, [diffData, docText]);
+  };
+
+  /* ── Commands: keyboard, menu, palette ───────────────────────────────────────────────────── */
+
+  const openPalette = (query = '') => {
+    setPaletteQuery(query);
+    setPaletteKey((k) => k + 1);
+    setShowPalette(true);
+  };
+
+  const openSettings = (section = null) => {
+    setSettingsSection(section);
+    if (section) setSettingsKey((k) => k + 1);
+    setShowSettings(true);
+  };
+
+  /** The CodeMirror view the user is typing in, else the active tab's editor. */
+  const targetEditorView = () => {
+    const focused = document.activeElement;
+    const view = focused?.closest?.('.cm-editor') ? EditorView.findFromDOM(focused.closest('.cm-editor')) : null;
+    return view || editorRefs.current[activeIdRef.current]?.getView?.() || null;
+  };
+
+  /** Edit → Find: the editor's own search panel, else the reading view's find bar. */
+  const findInActive = () => {
+    const view = targetEditorView();
+    if (view) {
+      openSearchPanel(view);
+      return;
+    }
+    window.dispatchEvent(new CustomEvent('fate:find'));
+  };
+
+  /** Edit → Undo/Redo: a focused text field undoes itself; otherwise the editor's history. */
+  const editHistory = (kind) => {
+    const el = document.activeElement;
+    const inField = el && !el.closest?.('.cm-editor') && (el.isContentEditable || ['INPUT', 'TEXTAREA'].includes(el.tagName));
+    if (inField) {
+      document.execCommand(kind);
+      return;
+    }
+    const view = targetEditorView();
+    if (!view) return;
+    (kind === 'undo' ? undoEdit : redoEdit)(view);
+    view.focus();
+  };
+
+  /** Every rebindable action, by SHORTCUT_ACTIONS id (keyboard, the app menu, the palette). */
+  const runAction = (id) => {
+    const active = activeIdRef.current;
+    switch (id) {
+      case 'newFile': return newFile();
+      case 'openFile': return window.electronAPI?.openFileDialog();
+      case 'save': return saveDoc();
+      case 'saveAs': return saveDoc(undefined, { forceAs: true });
+      case 'print': return active !== null && openPrintPreview();
+      case 'exportPdf': return active !== null && exportPdf();
+      case 'closeTab':
+      case 'close':
+        return active !== null && closeDoc(active);
+      case 'nextTab': return cycleTab(1);
+      case 'prevTab': return cycleTab(-1);
+      case 'goHome': return activate(null);
+      case 'palette': return showPalette ? setShowPalette(false) : openPalette('');
+      case 'toggleEdit': return toggleMarkdownEdit();
+      case 'toggleSplit': return toggleSplit();
+      case 'focusMode': return setFocusMode((f) => !f);
+      case 'settings': return openSettings();
+      default: return undefined;
+    }
+  };
+
+  /** The application menu (main.cjs) sends these; ids per the contract in electron/preload.cjs. */
+  const runMenuCommand = (command) => {
+    switch (command) {
+      case 'palette': return openPalette('');
+      case 'shortcuts': return openSettings('shortcuts');
+      case 'about': return openSettings('about');
+      case 'find': return findInActive();
+      case 'gotoLine': return openPalette(':');
+      case 'gotoSymbol': return openPalette('@');
+      case 'undo': return editHistory('undo');
+      case 'redo': return editHistory('redo');
+      default: return runAction(command);
+    }
+  };
+
+  /*
+   * Listeners registered once (IPC, the app menu, the keyboard, timers) reach the operations
+   * above through this ref, refreshed after every render, so they always run the current closures.
+   */
+  useEffect(() => {
+    opsRef.current = {
+      openDocument, onDiskChange, markDeleted, restoreSession, runAction, runMenuCommand,
+      handleQuitRequest, backupTick, flushBackupsNow, isDocDirty
+    };
+  });
+
+  /* ── Boot: stored settings + IPC listeners ───────────────────────────────────────────────── */
+
+  useEffect(() => {
+    if (window.electronAPI) {
+      const api = window.electronAPI;
+      // Hot-exit backups are listed with the settings, so they are ready before any file opens.
+      const listBackups = api.backups?.list ? api.backups.list().catch(() => []) : Promise.resolve([]);
+      Promise.all([
+        api.store.get('theme'),
+        api.store.get('autoUpdatesEnabled'),
+        api.store.get('sidebarWidth'),
+        api.store.get('shortcuts'),
+        api.store.get('printPageSize'),
+        api.store.get('printLandscape'),
+        api.store.get('editorWrap'),
+        api.store.get('editorTabSize'),
+        api.store.get('editorLint'),
+        api.store.get('fonts'),
+        api.store.get('restoreSession'),
+        api.store.get('customTheme'),
+        api.store.get('session'),
+        listBackups
+      ])
+        .then(([theme, autoUpdatesEnabled, savedSidebarWidth, shortcuts, printPageSize, printLandscape, editorWrap, editorTabSize, editorLint, fonts, restoreSession, customTheme, session, backups]) => {
+          const resolvedCustom = resolveCustomTheme(customTheme);
+          const resolvedTheme = resolveTheme(theme, !!resolvedCustom);
+          const resolvedFonts = resolveFonts(fonts);
+          const newSettings = {
+            theme: resolvedTheme,
+            autoUpdatesEnabled: autoUpdatesEnabled !== false,
+            sidebarWidth: savedSidebarWidth || 300,
+            printPageSize: printPageSize || 'Letter',
+            printLandscape: !!printLandscape,
+            editorWrap: !!editorWrap,
+            editorTabSize: [2, 4, 8].includes(editorTabSize) ? editorTabSize : 4,
+            editorLint: editorLint !== false,
+            fonts: resolvedFonts,
+            restoreSession: restoreSession !== false,
+            customTheme: resolvedCustom,
+            shortcuts: resolveShortcuts(shortcuts)
+          };
+          setSettings(newSettings);
+          setSidebarWidth(newSettings.sidebarWidth);
+          applyCustomTheme(resolvedCustom);
+          document.documentElement.setAttribute('data-theme', resolvedTheme);
+          applyFonts(resolvedFonts);
+
+          // Persist the migration so the legacy value isn't re-resolved on every launch.
+          if (theme !== resolvedTheme) {
+            api.store.set('theme', resolvedTheme);
+          }
+
+          // Last session's tabs (if that setting is on) and any hot-exit backups (always).
+          opsRef.current.restoreSession(newSettings.restoreSession ? session : null, backups);
+        })
+        .catch((err) => console.error('Could not restore settings at startup:', err))
+        /*
+         * Only now may the main process open the launch argument: a backup of that very file
+         * must already be waiting for it, or its tab would open clean and the edits come back as
+         * a duplicate.
+         */
+        .finally(() => api.appReady());
+
+      api.getRuntimeInfo?.().then(setRuntimeInfo).catch(() => {});
+      api.getSystemFonts?.().then(setSystemFonts).catch(() => {});
+
+      api.onOpenFile((content, name, path, meta) => {
+        opsRef.current.openDocument(content, name, path, meta);
+      });
+
+      /*
+       * Live-reload on external disk change, routed to the owning tab by path. Registered once.
+       * Never clobbers unsaved edits: a dirty tab keeps the user's version and shows the
+       * changed-on-disk bar (see applyDiskContent).
+       */
+      api.onFileChanged((content, changedPath, meta) => {
+        opsRef.current.onDiskChange(content, changedPath, meta);
+      });
+      api.onFileDeleted?.((deletedPath) => opsRef.current.markDeleted(deletedPath));
+      api.onMenuCommand?.((command, arg) => opsRef.current.runMenuCommand(command, arg));
+      api.onRequestClose?.(() => opsRef.current.handleQuitRequest());
+
+      api.getAppVersion().then((version) => setAppVersion(version));
+
+      api.onUpdateMessage((message, action) => {
+        setUpdateStatus(message);
+        setUpdateAction(action);
+      });
+
+      refreshRecentFiles();
+      refreshDefaultAppStatus();
+    } else {
+      // Browser dev mode (`npm run dev` without Electron): no store to read from.
+      document.documentElement.setAttribute('data-theme', DEFAULT_THEME);
+      applyFonts(DEFAULT_FONTS);
+    }
+  }, [refreshRecentFiles, refreshDefaultAppStatus]);
+
+  /* Hot exit: the backup tick, and the crash flush the root error boundary calls. */
+  useEffect(() => {
+    if (!window.electronAPI?.backups) return undefined;
+    setCrashFlusher(() => opsRef.current?.flushBackupsNow());
+    const timer = setInterval(() => opsRef.current?.backupTick(), BACKUP_TICK_MS);
+    return () => clearInterval(timer);
+  }, []);
+
+  /*
+   * Unload guard. Anything that unloads the page with unsaved work and without going through the
+   * quit walk (a reload from wherever it comes, a close the walk never saw) is blocked here; the
+   * main process turns the block into a confirmation ('will-prevent-unload'). The quit walk and
+   * the crash screen's Reload stand it down first.
+   */
+  useEffect(() => {
+    window.onbeforeunload = (e) => {
+      if (quittingRef.current) return undefined;
+      const dirty = docsRef.current.some((d) => opsRef.current?.isDocDirty(d) ?? d.dirty);
+      if (!dirty) return undefined;
+      e.preventDefault();
+      e.returnValue = false;
+      return false;
+    };
+    return () => {
+      window.onbeforeunload = null;
+    };
+  }, []);
+
+  /* The app menu shows the live shortcut bindings as its labels (display only; keys stay here). */
+  useEffect(() => {
+    window.electronAPI?.updateMenu?.({ shortcuts: { ...settings.shortcuts, ...FIXED_MENU_SHORTCUTS } });
+  }, [settings.shortcuts]);
+
+  /*
+   * One place owns the window title and the mirrored any-tab-dirty flag. Since 1.11.0 the title is
+   * just the active document's filename (the full app name shows on the home screen only); the
+   * main process composes it; see composeTitle in main.cjs.
+   */
+  useEffect(() => {
+    if (!window.electronAPI) return;
+    const anyDirty = docs.some((d) => d.dirty);
+    window.electronAPI.setEdited(anyDirty);
+    window.electronAPI.setTitle(activeDoc ? activeDoc.name : null, !!activeDoc?.dirty);
+  }, [docs, activeDoc]);
+
+  /*
+   * Persist the session (paths only) whenever the tab set or active tab changes, debounced. Each
+   * path once: a doubled entry used to open a doubled tab, which then saved a tripled list (H6).
+   * An active tab without a path (Untitled) is remembered by its hot-exit backup id instead, so
+   * after a crash the restored buffer you were typing in is the one that comes back selected.
+   *
+   * With "reopen last session's tabs" off, the stored list is CLEARED rather than merely ignored
+   * at launch. Someone who turns that off is saying they want FATE to start empty; leaving a list
+   * of everything they had open sitting in the config file is not that.
+   */
+  useEffect(() => {
+    if (!window.electronAPI) return;
+    clearTimeout(sessionSaveTimerRef.current);
+    if (!settings.restoreSession) {
+      window.electronAPI.store.set('session', { paths: [], active: null });
+      return;
+    }
+    sessionSaveTimerRef.current = setTimeout(() => {
+      window.electronAPI.store.set('session', {
+        paths: uniqueBy(docs.filter((d) => d.path).map((d) => d.path), pathKey),
+        active: activeDoc?.path ?? null,
+        activeBackup: activeDoc && !activeDoc.path ? activeDoc.backupId : null
+      });
+    }, 400);
+    return () => clearTimeout(sessionSaveTimerRef.current);
+  }, [docs, activeDoc, settings.restoreSession]);
+
+
+
+  /*
+   * Re-check the default-app association when the window regains focus. Coming back from
+   * Windows Settings is the exact moment the answer may have changed.
+   */
+  useEffect(() => {
+    const onFocus = () => refreshDefaultAppStatus();
+    window.addEventListener('focus', onFocus);
+    return () => window.removeEventListener('focus', onFocus);
+  }, [refreshDefaultAppStatus]);
+
+  /** Refresh recents whenever the home screen comes back into view. */
+  useEffect(() => {
+    if (activeId === null) refreshRecentFiles();
+  }, [activeId, refreshRecentFiles]);
 
   /* ── Keyboard ────────────────────────────────────────────────────────────────────────────── */
 
@@ -770,33 +1792,6 @@ function App() {
   };
 
   useEffect(() => {
-    const actions = {
-      newFile: () => newFile(),
-      openFile: () => window.electronAPI?.openFileDialog(),
-      save: () => saveDoc(),
-      saveAs: () => saveDoc(undefined, { forceAs: true }),
-      print: () => {
-        if (activeIdRef.current !== null) openPrintPreview();
-      },
-      exportPdf: () => {
-        if (activeIdRef.current !== null) exportPdf();
-      },
-      closeTab: () => {
-        if (activeIdRef.current !== null) closeDoc(activeIdRef.current);
-      },
-      close: () => {
-        if (activeIdRef.current !== null) closeDoc(activeIdRef.current);
-      },
-      nextTab: () => cycleTab(1),
-      prevTab: () => cycleTab(-1),
-      goHome: () => setActiveId(null),
-      palette: () => setShowPalette((p) => !p),
-      toggleEdit: () => toggleMarkdownEdit(),
-      toggleSplit: () => toggleSplit(),
-      focusMode: () => setFocusMode((f) => !f),
-      settings: () => setShowSettings(true)
-    };
-
     const handleKeyDown = (e) => {
       /*
        * CodeMirror handles its own keys first and calls preventDefault on anything it consumed
@@ -842,13 +1837,15 @@ function App() {
         const ds = docsRef.current;
         if (ds.length > 0) {
           const n = parseInt(e.key, 10);
-          setActiveId(n === 9 ? ds[ds.length - 1].id : (ds[n - 1]?.id ?? ds[ds.length - 1].id));
+          const target = n === 9 ? ds[ds.length - 1].id : (ds[n - 1]?.id ?? ds[ds.length - 1].id);
+          activeIdRef.current = target;
+          setActiveId(target);
         }
         return;
       }
       if (e.ctrlKey && !e.altKey && !e.metaKey && (e.key === 'PageDown' || e.key === 'PageUp')) {
         e.preventDefault();
-        cycleTab(e.key === 'PageDown' ? 1 : -1);
+        opsRef.current.runAction(e.key === 'PageDown' ? 'nextTab' : 'prevTab');
         return;
       }
 
@@ -858,7 +1855,7 @@ function App() {
           // Escape-as-close must not swallow Escape while a modal layer is open above the tabs.
           if (id === 'close' && (showSettings || showPalette)) return;
           e.preventDefault();
-          actions[id]?.();
+          opsRef.current.runAction(id);
           return;
         }
       }
@@ -874,7 +1871,7 @@ function App() {
     };
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [settings.shortcuts, activeShortcutRebind, showSettings, showPalette, focusMode, diffData, openPrintPreview, exportPdf, saveDoc, closeDoc, cycleTab, newFile, toggleMarkdownEdit, toggleSplit]);
+  }, [settings.shortcuts, activeShortcutRebind, showSettings, showPalette, focusMode, diffData]);
 
   /* ── Update / drop / recents handlers ────────────────────────────────────────────────────── */
 
@@ -926,7 +1923,7 @@ function App() {
           setStatusError(`Can't open ${file.name}: it looks like a binary file`);
           return;
         }
-        openDocument(text, file.name, resolvedPath);
+        opsRef.current.openDocument(text, file.name, resolvedPath);
       };
       reader.readAsText(file);
     }
@@ -946,6 +1943,12 @@ function App() {
     let depth = 0;
     const hasFiles = (e) => Array.from(e.dataTransfer?.types || []).includes('Files');
 
+    /*
+     * CAPTURE phase on the window, and file drags stop here (M3). CodeMirror has its own drop
+     * handler: it read a dropped file and INSERTED its text at the drop point, on top of FATE
+     * opening it in a tab, so dropping config.json onto app.js put the JSON inside app.js. Text
+     * dragged within or into an editor carries no 'Files' type and passes through untouched.
+     */
     const onDragEnter = (e) => {
       if (!hasFiles(e)) return;
       e.preventDefault();
@@ -955,6 +1958,7 @@ function App() {
     const onDragOver = (e) => {
       if (!hasFiles(e)) return;
       e.preventDefault(); // REQUIRED, or the drop event never fires and Chromium navigates
+      e.stopPropagation();
       e.dataTransfer.dropEffect = 'copy';
     };
     const onDragLeave = (e) => {
@@ -965,20 +1969,21 @@ function App() {
     const onDrop = (e) => {
       if (!hasFiles(e)) return;
       e.preventDefault();
+      e.stopPropagation();
       depth = 0;
       setIsDragActive(false);
       handleDroppedFilesRef.current(Array.from(e.dataTransfer.files));
     };
 
-    window.addEventListener('dragenter', onDragEnter);
-    window.addEventListener('dragover', onDragOver);
-    window.addEventListener('dragleave', onDragLeave);
-    window.addEventListener('drop', onDrop);
+    window.addEventListener('dragenter', onDragEnter, true);
+    window.addEventListener('dragover', onDragOver, true);
+    window.addEventListener('dragleave', onDragLeave, true);
+    window.addEventListener('drop', onDrop, true);
     return () => {
-      window.removeEventListener('dragenter', onDragEnter);
-      window.removeEventListener('dragover', onDragOver);
-      window.removeEventListener('dragleave', onDragLeave);
-      window.removeEventListener('drop', onDrop);
+      window.removeEventListener('dragenter', onDragEnter, true);
+      window.removeEventListener('dragover', onDragOver, true);
+      window.removeEventListener('dragleave', onDragLeave, true);
+      window.removeEventListener('drop', onDrop, true);
     };
   }, []);
 
@@ -993,6 +1998,8 @@ function App() {
 
   /* ── Command palette items ───────────────────────────────────────────────────────────────── */
 
+  const canRenderAsMarkdown = activeDoc?.kind === 'code' && couldBeMarkdown(activeDoc.name);
+
   const paletteItems = showPalette
     ? [
         ...docs.map((d) => ({
@@ -1000,7 +2007,7 @@ function App() {
           section: 'Tabs',
           label: d.name,
           icon: d.kind === 'code' ? FileCode : FileText,
-          run: () => setActiveId(d.id)
+          run: () => activate(d.id)
         })),
         ...recentFiles
           .filter((r) => r.exists)
@@ -1018,13 +2025,16 @@ function App() {
         { id: 'cmd-print', section: 'Commands', label: 'Print preview', icon: Printer, run: openPrintPreview },
         { id: 'cmd-export', section: 'Commands', label: 'Export as PDF', icon: FilePdf, run: exportPdf },
         { id: 'cmd-edit', section: 'Commands', label: 'Edit / view markdown', icon: PencilSimple, run: () => toggleMarkdownEdit() },
+        ...(canRenderAsMarkdown
+          ? [{ id: 'cmd-render-md', section: 'Commands', label: 'Render as Markdown', icon: MarkdownLogo, run: () => renderAsMarkdown(activeDoc.id) }]
+          : []),
         { id: 'cmd-split', section: 'Commands', label: splitId ? 'Close split view' : 'Split view', icon: SquareSplitHorizontal, run: toggleSplit },
         { id: 'cmd-diff', section: 'Commands', label: diffData ? 'Exit diff' : splitId !== null ? 'Diff the split panes' : 'Diff unsaved changes', icon: GitDiff, run: toggleDiff },
         { id: 'cmd-focus', section: 'Commands', label: focusMode ? 'Exit focus mode' : 'Focus mode', icon: ArrowsOutSimple, run: () => setFocusMode((f) => !f) },
-        { id: 'cmd-home', section: 'Commands', label: 'Go to home screen', icon: House, run: () => setActiveId(null) },
+        { id: 'cmd-home', section: 'Commands', label: 'Go to home screen', icon: House, run: () => activate(null) },
         { id: 'cmd-close', section: 'Commands', label: 'Close tab', icon: X, run: () => activeIdRef.current !== null && closeDoc(activeIdRef.current) },
-        { id: 'cmd-settings', section: 'Commands', label: 'Open settings', icon: Gear, run: () => setShowSettings(true) },
-        { id: 'cmd-shortcuts', section: 'Commands', label: 'Keyboard shortcuts', icon: Keyboard, run: () => setShowSettings(true) },
+        { id: 'cmd-settings', section: 'Commands', label: 'Open settings', icon: Gear, run: () => openSettings() },
+        { id: 'cmd-shortcuts', section: 'Commands', label: 'Keyboard shortcuts', icon: Keyboard, run: () => openSettings('shortcuts') },
         { id: 'cmd-updates', section: 'Commands', label: 'Check for updates', icon: CircleNotch, run: handleUpdateAction },
         ...THEMES.map((t) => ({
           id: `theme-${t.value}`,
@@ -1041,6 +2051,45 @@ function App() {
 
   /* ── Render helpers ──────────────────────────────────────────────────────────────────────── */
 
+  /** The bars over a pane: the file changed or vanished under unsaved work, or a notice. */
+  const renderDocBars = (d) => (
+    <>
+      {d.diskDeleted && (
+         patchDoc(d.id, { diskDeleted: false }) },
+            { label: 'Close tab', onClick: () => closeDoc(d.id) }
+          ]}
+        />
+      )}
+      {d.diskChange && (
+         reloadFromDisk(d.id) },
+            { label: 'Keep mine', primary: true, title: 'Keep your edits; saving overwrites the file on disk', onClick: () => keepMine(d.id) },
+            { label: 'Compare', title: 'Show the file on disk beside your version', onClick: () => compareWithDisk(d.id) }
+          ]}
+        />
+      )}
+      {d.notice && (
+         renderAsMarkdown(d.id) }]
+              : []
+          }
+          onDismiss={() => patchDoc(d.id, { notice: null })}
+        />
+      )}
+    
+  );
+
   const renderPane = (d) => {
     const active = d.id === activeId;
     const inSplit = splitId === d.id && !diffData;
@@ -1059,7 +2108,7 @@ function App() {
           
setQuery(e.target.value)} + onKeyDown={onInputKeyDown} + /> + + {count} + + + + +
+ ); +} + +export default PreviewFindBar; diff --git a/src/markdown.js b/src/markdown.js index a8695f6..db9ca92 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -1,4 +1,4 @@ -import { marked } from 'marked'; +import { marked, Parser } from 'marked'; import { markedHighlight } from 'marked-highlight'; import DOMPurify from 'dompurify'; // `lib/common` registers ~40 mainstream languages instead of all ~190. The full `highlight.js` @@ -6,17 +6,22 @@ import DOMPurify from 'dompurify'; // fences essentially never name; anything unregistered falls back to plaintext, unstyled but // intact. import hljs from 'highlight.js/lib/common'; +import powershell from 'highlight.js/lib/languages/powershell'; +import dos from 'highlight.js/lib/languages/dos'; +import dockerfile from 'highlight.js/lib/languages/dockerfile'; import markedKatex from 'marked-katex-extension'; // KaTeX's stylesheet is LOAD-BEARING: it hides the .katex-mathml screen-reader layer. Without it // every equation renders twice: once as maths, once as raw MathML text. import 'katex/dist/katex.min.css'; +import { withTexRepair } from './markdownMath.js'; +import { classifyImageSrc, toFateLocalUrl, createSlugger } from './previewPaths.js'; /** * markdown.js: the markdown rendering pipeline, extracted from App.jsx when tabs arrived. * - * renderMarkdown() is PURE (content in, {html, toc} out) so App.jsx can call it both when opening - * a document and when a watched file changes on disk, without the tangle of setState the old - * processMarkdown carried. + * renderMarkdown() is PURE (content in, {html, toc, …} out) so App.jsx can call it both when + * opening a document and when a watched file changes on disk, without the tangle of setState the + * old processMarkdown carried. * * ── Code fences: marked-highlight, not `marked.setOptions({ highlight })` ───────────────────── * The old `highlight` option was removed from marked in v5. FATE had carried the dead option ever @@ -28,11 +33,23 @@ import 'katex/dist/katex.min.css'; * * ── KaTeX (see AI_CONTEXT.md §1; this is the app's founding feature) ───────────────────────── * `throwOnError: false, nonStandard: true` are load-bearing: nonStandard lets equations sit tight - * against punctuation without breaking the whole parse. + * against punctuation without breaking the whole parse. FATE's own rules around the tokenizers + * (TeX repairs, `$5 and $10`) live in markdownMath.js. */ marked.setOptions({ gfm: true, breaks: true }); +/* + * Shell grammars on top of lib/common, which has bash but none of these, so a ```powershell fence + * rendered as plain text. The code blocks people copy out of a README are mostly commands. The + * aliases are the info strings people write; the grammars declare all but `batch` themselves. + */ +hljs.registerLanguage('powershell', powershell); +hljs.registerLanguage('dos', dos); +hljs.registerLanguage('dockerfile', dockerfile); +hljs.registerAliases(['ps', 'ps1', 'pwsh'], { languageName: 'powershell' }); +hljs.registerAliases(['bat', 'cmd', 'batch'], { languageName: 'dos' }); + marked.use( markedHighlight({ langPrefix: 'hljs language-', @@ -44,67 +61,275 @@ marked.use( ); /* - * ── Corrupted TeX is repaired in maths ONLY ────────────────────────────────────────────────── - * Some generators write "\theta" into a string without escaping the backslash, so the file holds - * a real TAB followed by "heta". TEX_REPAIRS turns those back into TeX. Up to 1.13.4 they ran over - * the WHOLE document, code included: a tab-indented `auth := x` in a ```go block rendered as the - * literal text `\tauth := x`, `cd My\ Documents` in a ```bash block became `My\\ Documents`, and - * copying the block put the damage on the clipboard. Now each KaTeX tokenizer finds maths in the - * source exactly as written, then re-reads the repaired span to get the formula. `raw` keeps the - * original text, because the lexer advances by its length. - * - * The two carriage-return repairs live in renderMarkdown instead: marked turns a lone CR into a - * line break before any tokenizer sees it. + * ── Telling KaTeX's markup from the document's ─────────────────────────────────────────────── + * KaTeX positions every glyph with inline styles and styles itself by class, both of which the + * sanitiser strips from everything else. Checking `closest('.katex')` alone would let raw HTML in + * the document claim the exemption (`
`), so the + * renderer stamps the root element of its own output with a token made fresh for each run of the + * app, which no document can know in advance. The sanitiser trusts only what sits under a stamped + * root, and renderMarkdown removes the stamps before the HTML goes anywhere. */ -/* eslint-disable no-control-regex */ -const TEX_REPAIRS = [ - [/\x09heta/g, '\\theta'], - [/\x09ext/g, '\\text'], - [/\x09imes/g, '\\times'], - [/\x09au/g, '\\tau'], - [/\x0Crac/g, '\\frac'], - [/\x08eta/g, '\\beta'], - [/\x08egin/g, '\\begin'], - [/\x07pprox/g, '\\approx'], - [/\x07lpha/g, '\\alpha'], - [/\x0B/g, '\\v'], - [/\\ /g, '\\\\ '] -]; -/* eslint-enable no-control-regex */ - -const repairTex = (tex) => TEX_REPAIRS.reduce((s, [pattern, fix]) => s.replace(pattern, fix), tex); - -function withTexRepair(extension) { - const tokenize = extension.tokenizer; +const KATEX_STAMP = Array.from(crypto.getRandomValues(new Uint32Array(4)), (n) => n.toString(36)).join(''); + +function stampKatexOutput(extension) { + const render = extension.renderer; return { ...extension, - tokenizer(src, tokens) { - const token = tokenize.call(this, src, tokens); - if (!token) return token; - const repairedRaw = repairTex(token.raw); - if (repairedRaw === token.raw) return token; - const repaired = tokenize.call(this, repairedRaw, tokens); - return repaired?.raw === repairedRaw ? { ...token, text: repaired.text } : token; + renderer(token) { + return render.call(this, token).replace(/^ stampKatexOutput(withTexRepair(ext))) }); + +/* + * ── Source lines, for scroll sync ──────────────────────────────────────────────────────────── + * Ahead of each top-level block goes an empty marker holding the source line the block starts on + * (0-based), counted through the tokens' `raw`, which together are exactly the source. Afterwards + * annotateLines() moves each line onto the element that follows its marker, as `data-line`, and + * drops the markers. Markers rather than pairing tokens with the rendered top-level elements by + * position: raw HTML can turn one token into several elements, or none (a comment), or wrap the + * blocks after it in its own
, and a positional pairing would then put the wrong line on + * every later block. A marker labels its own block or nothing, so sync degrades gracefully. + * + * The line rides on each top-level token (processAllTokens) and the parser writes the marker in + * front of that token's HTML (provideParser). Not as extra tokens: marked's walkTokens copies its + * whole result list once per top-level token, and doubling their number made a long document + * render about 50% slower. + */ +const SOURCE_LINE = Symbol('sourceLine'); + +function countNewlines(s) { + let n = 0; + for (let i = s.indexOf('\n'); i !== -1; i = s.indexOf('\n', i + 1)) n++; + return n; +} + +marked.use({ + hooks: { + processAllTokens(tokens) { + if (!this.block) return tokens; + let line = 0; + for (const token of tokens) { + token[SOURCE_LINE] = line; + line += countNewlines(token.raw || ''); + } + return tokens; + }, + provideParser(block) { + if (!block) return false; // marked's own inline parser + return (tokens, options) => { + const parser = new Parser(options); + let html = ''; + for (const token of tokens) { + if (token[SOURCE_LINE] !== undefined && token.type !== 'space' && token.type !== 'def') { + html += ``; + } + html += parser.parse([token]); + } + return html; + }; + } + } +}); + +function annotateLines(root) { + // A `data-line` the document wrote itself would only confuse the sync. + for (const el of root.querySelectorAll('[data-line]')) el.removeAttribute('data-line'); + for (const marker of root.querySelectorAll('[data-fate-line]')) { + const next = marker.nextElementSibling; + // Two markers in a row: the first block rendered nothing (an HTML comment), so skip its line. + if (next && !next.hasAttribute('data-fate-line') && !next.hasAttribute('data-line')) { + next.setAttribute('data-line', marker.getAttribute('data-fate-line')); + } + marker.remove(); + } +} + +/* + * ── Sanitising (H2, H7) ────────────────────────────────────────────────────────────────────── + * Every preview stays mounted, so whatever one document's HTML can do, it does to the whole app: + * up to 1.13.4 a