Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,7 @@
flex: 1;
overflow-y: auto;
padding: 48px 40px;
position: relative;
}

#content.empty {
Expand Down Expand Up @@ -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); }
</style>
</head>
<body>
Expand Down Expand Up @@ -1969,7 +2020,19 @@ <h2 data-i18n="app.name">Markdown Viewer</h2>
</button>
</div>
</div>
<div id="slow-read-notice" hidden>
<span class="srn-text" data-i18n="slowRead.message"></span>
<button
class="srn-close"
id="slow-read-dismiss"
data-i18n-title="slowRead.dismiss"
aria-label="Dismiss"
>✕</button>
</div>
<div id="markdown" style="display:none"></div>
<div id="content-loading" hidden>
<div class="content-loading-spinner"></div>
</div>
</div>

<div id="outline">
Expand Down
57 changes: 57 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64, String> {
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<String, String> {
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<std::path::PathBuf, String> {
let dir = app
.path()
Expand Down Expand Up @@ -210,6 +264,9 @@ pub fn run(path_arg: Option<String>) {
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,
Expand Down
58 changes: 58 additions & 0 deletions src/debug-overlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
formatClipboard,
formatOpenRecord,
type OpenRecord,
} from "./debug-overlay";

function rec(phases: Partial<OpenRecord["phases"]>, 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");
});
});
Loading
Loading