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);
}