From 1078552fc27fdc65af33f8036c0a3b1f218a6cce Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre Dupuy Date: Wed, 1 Jul 2026 22:16:34 +0200 Subject: [PATCH] feat: fast document open, cloud-sync UX, and a debug HUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a document could take ~3s. Instrumentation (new debug HUD) showed the render pipeline was only ~110ms — the time was entirely in the file read. Root cause: OneDrive "files on-demand" hydrating online-only files on first read (a plain `cat` of the same file took 2.3s at 0% CPU; 0.03s once moved to a local folder). Not the app, not the IPC. What this changes: - Read via a dedicated async Rust command `read_document` instead of the fs plugin. `async` keeps the (possibly slow) read off the UI thread — a sync command ran on the main thread and froze the window (FPS → 2). Confined to the home/resource dirs with canonicalization; errors stay generic (addresses the path-traversal review). - In-memory document cache keyed by path + mtime (new cheap `document_mtime` command) → re-opening a document is instant, without serving stale content after an external edit. - Loading spinner shown after a 150ms delay (no flash on fast/cached reads) and a one-time dismissible notice when an uncached read exceeds 600ms, explaining the likely cloud-sync cause (fr + en). Debug tooling (kept for future perf work): - Debug HUD (Cmd/Ctrl+Shift+D): live FPS + per-phase open timings (ipc ping, read, parse, images, mermaid, dom, outline, total), with click-to-copy. The IPC "ping" probe only runs while the HUD is open. - `read_document` logs the raw std::fs read duration to stderr. --- index.html | 63 +++++++++ src-tauri/src/lib.rs | 57 ++++++++ src/debug-overlay.test.ts | 58 +++++++++ src/debug-overlay.ts | 268 ++++++++++++++++++++++++++++++++++++++ src/locales/en.json | 4 +- src/locales/fr.json | 4 +- src/main.ts | 111 +++++++++++++++- 7 files changed, 562 insertions(+), 3 deletions(-) create mode 100644 src/debug-overlay.test.ts create mode 100644 src/debug-overlay.ts diff --git a/index.html b/index.html index 01f165b..56e8b6f 100644 --- a/index.html +++ b/index.html @@ -985,6 +985,7 @@ flex: 1; overflow-y: auto; padding: 48px 40px; + position: relative; } #content.empty { @@ -1834,6 +1835,56 @@ max-width: 100%; margin: 0; } + + #content-loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + #content-loading[hidden] { display: none; } + .content-loading-spinner { + width: 28px; + height: 28px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: mdv-spin 0.7s linear infinite; + } + @keyframes mdv-spin { to { transform: rotate(360deg); } } + @media (prefers-reduced-motion: reduce) { + .content-loading-spinner { animation-duration: 2s; } + } + + #slow-read-notice { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 0 0 24px; + padding: 10px 14px; + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 6px; + background: var(--accent-light); + color: var(--text); + font-size: 13px; + line-height: 1.45; + } + #slow-read-notice[hidden] { display: none; } + #slow-read-notice .srn-text { flex: 1; } + #slow-read-notice .srn-close { + flex: none; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 15px; + line-height: 1; + padding: 2px 4px; + } + #slow-read-notice .srn-close:hover { color: var(--text); } @@ -1969,7 +2020,19 @@

Markdown Viewer

+ +
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a24ee9a..e9621c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,6 +29,60 @@ fn print_webview(webview: tauri::Webview) -> Result<(), String> { webview.print().map_err(|e| e.to_string()) } +// No-op round-trip used to isolate IPC transport latency from filesystem +// latency in the debug HUD. TEMP diagnostic. +#[tauri::command] +fn ping() {} + +// Last-modified time (ms since epoch) used to validate the frontend's in-memory +// document cache. Metadata only — cheap even when the file body is slow to read +// (e.g. a not-yet-materialized OneDrive/iCloud "online-only" file). +#[tauri::command] +async fn document_mtime(path: String) -> Result { + let meta = std::fs::metadata(&path).map_err(|_| "cannot stat file".to_string())?; + let modified = meta.modified().map_err(|_| "no mtime".to_string())?; + let ms = modified + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| "bad mtime".to_string())? + .as_millis() as u64; + Ok(ms) +} + +// Reading through the `fs` plugin adds large fixed latency per open on +// macOS/WKWebView. A direct app command reads the file itself. +// +// `async` on purpose: the read itself is slow on this machine (endpoint +// security / synced-folder scanning adds seconds per open — the IPC round-trip +// is ~1ms). A sync command would run on the main thread and freeze the UI; an +// async command runs off it, so the window stays responsive during the read. +// +// Confined to the user's home dir or the bundled resource dir (examples): +// canonicalize resolves `..`/symlinks, then we require the result to sit under +// an allowed root. Errors stay generic to avoid leaking paths. +#[tauri::command] +async fn read_document(app: tauri::AppHandle, path: String) -> Result { + let requested = + std::fs::canonicalize(&path).map_err(|_| "cannot resolve path".to_string())?; + + let roots = [app.path().home_dir(), app.path().resource_dir()] + .into_iter() + .flatten() + .filter_map(|p| std::fs::canonicalize(p).ok()); + if !roots.into_iter().any(|root| requested.starts_with(&root)) { + return Err("path outside allowed roots".to_string()); + } + + let t = std::time::Instant::now(); + let content = + std::fs::read_to_string(&requested).map_err(|_| "failed to read file".to_string())?; + eprintln!( + "[read_document] std::fs read {} bytes in {:?}", + content.len(), + t.elapsed() + ); + Ok(content) +} + fn themes_dir_path(app: &tauri::AppHandle) -> Result { let dir = app .path() @@ -210,6 +264,9 @@ pub fn run(path_arg: Option) { let app = tauri::Builder::default() .invoke_handler(tauri::generate_handler![ print_webview, + read_document, + document_mtime, + ping, export_pdf, get_pending_open, list_system_fonts, diff --git a/src/debug-overlay.test.ts b/src/debug-overlay.test.ts new file mode 100644 index 0000000..2618e1f --- /dev/null +++ b/src/debug-overlay.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + formatClipboard, + formatOpenRecord, + type OpenRecord, +} from "./debug-overlay"; + +function rec(phases: Partial, total = 0): OpenRecord { + return { + file: "doc.md", + total, + phases: { + ping: 0, + read: 0, + parse: 0, + images: 0, + mermaid: 0, + dom: 0, + outline: 0, + ...phases, + }, + }; +} + +describe("formatOpenRecord", () => { + it("lists every phase in order plus the total", () => { + const lines = formatOpenRecord(rec({}, 12.34)); + expect(lines).toHaveLength(8); + expect(lines[0]).toContain("ipc ping (no-op)"); + expect(lines[1]).toContain("read file (IPC)"); + expect(lines[2]).toContain("parse markdown"); + expect(lines[7]).toBe("total: 12.3 ms"); + }); + + it("flags the slowest phase", () => { + const lines = formatOpenRecord(rec({ parse: 5, mermaid: 900, dom: 2 })); + const mermaidLine = lines.find((l) => l.startsWith("mermaid:")); + const parseLine = lines.find((l) => l.startsWith("parse markdown:")); + expect(mermaidLine).toContain("◀ slowest"); + expect(parseLine).not.toContain("slowest"); + }); + + it("formats milliseconds to one decimal", () => { + const lines = formatOpenRecord(rec({ parse: 123.456 })); + expect(lines[2]).toContain("123.5 ms"); + }); +}); + +describe("formatClipboard", () => { + it("prepends the file name and FPS to the phase lines", () => { + const text = formatClipboard(rec({ parse: 5 }, 60), 58); + const lines = text.split("\n"); + expect(lines[0]).toBe("open: doc.md"); + expect(lines[1]).toBe("FPS 58"); + expect(lines[2]).toContain("ipc ping (no-op)"); + expect(lines[lines.length - 1]).toBe("total: 60.0 ms"); + }); +}); diff --git a/src/debug-overlay.ts b/src/debug-overlay.ts new file mode 100644 index 0000000..04fd55e --- /dev/null +++ b/src/debug-overlay.ts @@ -0,0 +1,268 @@ +// Lightweight, self-contained debug HUD: per-phase document-open timings and a +// live FPS meter. Toggled from main.ts (Cmd/Ctrl+Shift+D). Kept out of +// index.html on purpose — it is dev tooling, not shipped UI, so it builds its +// own DOM and injects its own styles the first time it is shown. + +export interface PhaseTimings { + ping: number; + read: number; + parse: number; + images: number; + mermaid: number; + dom: number; + outline: number; +} + +export interface OpenRecord { + file: string; + total: number; + phases: PhaseTimings; +} + +export interface DebugOverlay { + recordOpen: (rec: OpenRecord) => void; + toggle: () => void; + isVisible: () => boolean; + destroy: () => void; +} + +const PHASE_LABELS: Array<[keyof PhaseTimings, string]> = [ + ["ping", "ipc ping (no-op)"], + ["read", "read file (IPC)"], + ["parse", "parse markdown"], + ["images", "resolve images"], + ["mermaid", "mermaid"], + ["dom", "copy/lightbox"], + ["outline", "outline"], +]; + +function ms(n: number): string { + return `${n.toFixed(1)} ms`; +} + +// Pure: renders an open record as ordered "label: value" lines, slowest phase +// flagged. Unit-tested without touching the DOM. +export function formatOpenRecord(rec: OpenRecord): string[] { + let slowestKey: keyof PhaseTimings = "parse"; + let slowestVal = -1; + for (const [key] of PHASE_LABELS) { + if (rec.phases[key] > slowestVal) { + slowestVal = rec.phases[key]; + slowestKey = key; + } + } + const lines = PHASE_LABELS.map(([key, label]) => { + const flag = key === slowestKey ? " ◀ slowest" : ""; + return `${label}: ${ms(rec.phases[key])}${flag}`; + }); + lines.push(`total: ${ms(rec.total)}`); + return lines; +} + +// Pure: the full clipboard payload (header + phase lines) for one open. +export function formatClipboard(rec: OpenRecord, fps: number): string { + const header = [`open: ${rec.file}`, `FPS ${fps}`]; + return [...header, ...formatOpenRecord(rec)].join("\n"); +} + +const STYLE_ID = "mdv-debug-style"; +const CSS = ` +.mdv-debug { + position: fixed; + bottom: 12px; + left: 12px; + z-index: 99999; + min-width: 240px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(12, 14, 18, 0.92); + color: #e6e6e6; + font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + pointer-events: auto; + user-select: none; + cursor: pointer; +} +.mdv-debug:active { background: rgba(30, 34, 42, 0.96); } +.mdv-debug__fps { font-size: 15px; font-weight: 700; } +.mdv-debug__fps b { color: #4ade80; } +.mdv-debug__title { margin: 8px 0 2px; color: #93c5fd; } +.mdv-debug__lines { white-space: pre; margin: 0; } +.mdv-debug__hint { margin-top: 8px; color: #6b7280; font-size: 10px; } +`; + +export function createDebugOverlay( + win: Window = window, + doc: Document = document +): DebugOverlay { + let visible = false; + let el: HTMLElement | null = null; + let fpsValueEl: HTMLElement | null = null; + let linesEl: HTMLElement | null = null; + let titleEl: HTMLElement | null = null; + let hintEl: HTMLElement | null = null; + let rafId: number | null = null; + let hintTimer: ReturnType | null = null; + let frames = 0; + let windowStart = 0; + let lastFps = 0; + let last: OpenRecord | null = null; + + const DEFAULT_HINT = "click to copy · ⌘⇧D to close"; + + function ensureStyle(): void { + if (doc.getElementById(STYLE_ID)) { + return; + } + const style = doc.createElement("style"); + style.id = STYLE_ID; + style.textContent = CSS; + doc.head.appendChild(style); + } + + function build(): void { + ensureStyle(); + el = doc.createElement("div"); + el.className = "mdv-debug"; + el.setAttribute("aria-hidden", "true"); + + const fpsEl = doc.createElement("div"); + fpsEl.className = "mdv-debug__fps"; + fpsEl.append("FPS "); + fpsValueEl = doc.createElement("b"); + fpsValueEl.textContent = "--"; + fpsEl.appendChild(fpsValueEl); + + titleEl = doc.createElement("div"); + titleEl.className = "mdv-debug__title"; + titleEl.textContent = "no document opened yet"; + + linesEl = doc.createElement("pre"); + linesEl.className = "mdv-debug__lines"; + + hintEl = doc.createElement("div"); + hintEl.className = "mdv-debug__hint"; + hintEl.textContent = DEFAULT_HINT; + + el.appendChild(fpsEl); + el.appendChild(titleEl); + el.appendChild(linesEl); + el.appendChild(hintEl); + el.addEventListener("click", copyToClipboard); + doc.body.appendChild(el); + if (last) { + renderRecord(last); + } + } + + function flashHint(message: string): void { + if (!hintEl) { + return; + } + hintEl.textContent = message; + if (hintTimer !== null) { + clearTimeout(hintTimer); + } + hintTimer = setTimeout(() => { + if (hintEl) { + hintEl.textContent = DEFAULT_HINT; + } + hintTimer = null; + }, 1200); + } + + function copyToClipboard(): void { + if (!last) { + flashHint("nothing to copy yet"); + return; + } + const text = formatClipboard(last, lastFps); + const clip = win.navigator?.clipboard; + if (clip?.writeText) { + clip.writeText(text).then( + () => flashHint("copied ✓"), + () => flashHint("copy failed") + ); + } else { + flashHint("clipboard unavailable"); + } + } + + function renderRecord(rec: OpenRecord): void { + if (titleEl) { + titleEl.textContent = `open: ${rec.file}`; + } + if (linesEl) { + linesEl.textContent = formatOpenRecord(rec).join("\n"); + } + } + + function loop(now: number): void { + frames++; + const elapsed = now - windowStart; + if (elapsed >= 500) { + const fps = Math.round((frames * 1000) / elapsed); + lastFps = fps; + if (fpsValueEl) { + fpsValueEl.textContent = String(fps); + } + frames = 0; + windowStart = now; + } + rafId = win.requestAnimationFrame(loop); + } + + function startFps(): void { + frames = 0; + windowStart = win.performance.now(); + rafId = win.requestAnimationFrame(loop); + } + + function stopFps(): void { + if (rafId !== null) { + win.cancelAnimationFrame(rafId); + rafId = null; + } + } + + function show(): void { + if (visible) { + return; + } + visible = true; + build(); + startFps(); + } + + function hide(): void { + if (!visible) { + return; + } + visible = false; + stopFps(); + if (hintTimer !== null) { + clearTimeout(hintTimer); + hintTimer = null; + } + el?.remove(); + el = fpsValueEl = linesEl = titleEl = hintEl = null; + } + + return { + recordOpen(rec: OpenRecord): void { + last = rec; + if (visible) { + renderRecord(rec); + } + }, + toggle(): void { + if (visible) { + hide(); + } else { + show(); + } + }, + isVisible: () => visible, + destroy: hide, + }; +} diff --git a/src/locales/en.json b/src/locales/en.json index 6403986..27e0b4a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -107,5 +107,7 @@ "mermaid.error": "Mermaid syntax error — showing source", "mermaid.close": "Close (Esc)", "code.copy": "Copy", - "code.copied": "Copied!" + "code.copied": "Copied!", + "slowRead.message": "This file took a long time to open. This folder looks cloud-synced (OneDrive, iCloud…): \"online-only\" files are downloaded on first read. Move your documents to a local folder to speed things up.", + "slowRead.dismiss": "Dismiss" } diff --git a/src/locales/fr.json b/src/locales/fr.json index c4a94f5..1cd4033 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -107,5 +107,7 @@ "mermaid.error": "Erreur de syntaxe Mermaid — affichage de la source", "mermaid.close": "Fermer (Échap)", "code.copy": "Copier", - "code.copied": "Copié !" + "code.copied": "Copié !", + "slowRead.message": "Ce fichier a mis longtemps à s'ouvrir. Ce dossier semble synchronisé dans le cloud (OneDrive, iCloud…) : les fichiers « en ligne uniquement » se téléchargent à la première lecture. Déplacez vos documents dans un dossier local pour accélérer.", + "slowRead.dismiss": "Masquer" } diff --git a/src/main.ts b/src/main.ts index 9343d51..e65eb5b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { readDir, readTextFile } from "@tauri-apps/plugin-fs"; import { openUrl } from "@tauri-apps/plugin-opener"; import { load } from "@tauri-apps/plugin-store"; import { confirmDialog } from "./confirm-dialog"; +import { createDebugOverlay } from "./debug-overlay"; import { addCopyButtons, addImageLightbox } from "./dom"; import { trapFocus, type FocusTrap } from "./focus-trap"; import { @@ -392,6 +393,15 @@ const breadcrumb = document.getElementById("breadcrumb") as HTMLDivElement; const markdownEl = document.getElementById("markdown") as HTMLDivElement; const emptyState = document.getElementById("empty-state") as HTMLDivElement; const contentEl = document.getElementById("content") as HTMLDivElement; +const contentLoading = document.getElementById( + "content-loading" +) as HTMLDivElement; +const slowReadNotice = document.getElementById( + "slow-read-notice" +) as HTMLDivElement; +const slowReadDismiss = document.getElementById( + "slow-read-dismiss" +) as HTMLButtonElement; const openBtn = document.getElementById("open-btn") as HTMLButtonElement; const examplesBtn = document.getElementById( "examples-btn" @@ -466,6 +476,10 @@ async function openFileFromPath(filePath: string): Promise { await setRootPath(parentDir, fileName); } +// --- Debug HUD --- + +const debugOverlay = createDebugOverlay(); + // --- Search --- let searchController: SearchController | null = null; @@ -513,6 +527,11 @@ function handleGlobalSearchShortcut(e: KeyboardEvent): void { return; } const key = e.key.toLowerCase(); + if (key === "d" && e.shiftKey) { + e.preventDefault(); + debugOverlay.toggle(); + return; + } if (key === "f" && !searchInput.disabled) { e.preventDefault(); focusSearch(); @@ -1595,23 +1614,97 @@ function resolveImagePaths(container: HTMLElement, filePath: string): void { } } +// Reads are slow on cloud-synced folders (OneDrive/iCloud materialize +// "online-only" files on first access). Cache by path+mtime so re-opening a +// document is instant, and surface a one-time notice when a real read is slow. +const SLOW_READ_MS = 600; +const docCache = new Map(); +let slowReadDismissed = false; +let loadingTimer: ReturnType | null = null; + +function showLoadingSoon(): void { + // Small delay so instant (cached) reads never flash a spinner. + loadingTimer = setTimeout(() => { + contentLoading.hidden = false; + }, 150); +} + +function hideLoading(): void { + if (loadingTimer !== null) { + clearTimeout(loadingTimer); + loadingTimer = null; + } + contentLoading.hidden = true; +} + +slowReadDismiss.addEventListener("click", () => { + slowReadNotice.hidden = true; + slowReadDismissed = true; +}); + +async function readDocumentText( + fullPath: string +): Promise<{ text: string; slow: boolean }> { + let mtime: number | null = null; + try { + mtime = await invoke("document_mtime", { path: fullPath }); + } catch { + mtime = null; + } + const cached = docCache.get(fullPath); + if (cached && mtime !== null && cached.mtime === mtime) { + return { text: cached.text, slow: false }; + } + const start = performance.now(); + const text = await invoke("read_document", { path: fullPath }); + const slow = performance.now() - start >= SLOW_READ_MS; + if (mtime !== null) { + docCache.set(fullPath, { mtime, text }); + } + return { text, slow }; +} + async function loadFile(filePath: string): Promise { try { const fullPath = `${rootPath}/${filePath}`; - const text = await readTextFile(fullPath); + // IPC round-trip probe — only when the debug HUD is open, so normal use + // doesn't pay for an extra invoke on every document open. + const tPing0 = performance.now(); + if (debugOverlay.isVisible()) { + await invoke("ping"); + } + const tPing1 = performance.now(); + + showLoadingSoon(); + const tRead0 = performance.now(); + let text: string; + let slowRead: boolean; + try { + ({ text, slow: slowRead } = await readDocumentText(fullPath)); + } finally { + hideLoading(); + } + const tRead1 = performance.now(); + + slowReadNotice.hidden = !(slowRead && !slowReadDismissed); activeFile = filePath; emptyState.style.display = "none"; markdownEl.style.display = "block"; contentEl.classList.remove("empty"); + const t0 = performance.now(); markdownEl.innerHTML = parseMarkdown(text); + const t1 = performance.now(); resolveImagePaths(markdownEl, fullPath); contentEl.scrollTop = 0; + const t2 = performance.now(); await renderMermaidDiagrams(); + const t3 = performance.now(); addCopyButtons(markdownEl); addImageLightbox(markdownEl, openImageOverlay); + const t4 = performance.now(); const fileDir = filePath.split("/").slice(0, -1); if (fileDir.join("/") !== currentPath.join("/")) { @@ -1622,11 +1715,27 @@ async function loadFile(filePath: string): Promise { const currentDir = filePath.split("/").slice(0, -1); interceptLinks(currentDir); highlightSidebar(); + const t5 = performance.now(); buildOutline(); + const t6 = performance.now(); titlebarFilename.textContent = filePath.split("/").pop() ?? ""; setSearchEnabled(true); searchController?.reset(); + + debugOverlay.recordOpen({ + file: filePath.split("/").pop() ?? filePath, + total: t6 - tPing0, + phases: { + ping: tPing1 - tPing0, + read: tRead1 - tRead0, + parse: t1 - t0, + images: t2 - t1, + mermaid: t3 - t2, + dom: t4 - t3, + outline: t6 - t5, + }, + }); } catch (e) { console.error("Failed to load file:", filePath, e); }