diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dd65fab..dd8c2ee 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1550,7 +1550,7 @@ dependencies = [ [[package]] name = "inkwell" -version = "0.1.0" +version = "0.2.0" dependencies = [ "log", "notify", diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 8a2d319..71bf939 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -1,5 +1,7 @@ use std::fs; +use crate::PendingOpenFiles; + #[tauri::command] pub async fn read_file(path: String) -> Result { fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e)) @@ -18,6 +20,7 @@ pub async fn get_file_size(path: String) -> Result { } /// Returns the file path passed as a CLI argument (for file associations), if any. +/// Works on Linux/Windows where the OS passes the file path via argv. #[tauri::command] pub async fn get_open_file_arg() -> Option { let args: Vec = std::env::args().collect(); @@ -30,3 +33,13 @@ pub async fn get_open_file_arg() -> Option { } }) } + +/// Returns and drains any file paths received via macOS Apple Events (RunEvent::Opened) +/// before the frontend was ready. Called by the frontend on init to handle cold-launch opens. +#[tauri::command] +pub async fn get_pending_open_file(state: tauri::State<'_, PendingOpenFiles>) -> Result, String> { + let mut pending = state.0.lock().unwrap(); + let first = pending.first().cloned(); + pending.clear(); + Ok(first) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d1d2ab6..6ef5c2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,12 +3,20 @@ mod commands; #[cfg(desktop)] mod platform; +use std::sync::Mutex; +use tauri::{Emitter, Manager}; + +/// Stores file paths received via macOS Apple Events (RunEvent::Opened) +/// before the frontend is ready to handle them. +pub struct PendingOpenFiles(pub Mutex>); + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let mut builder = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_store::Builder::default().build()) + .manage(PendingOpenFiles(Mutex::new(Vec::new()))) .invoke_handler(tauri::generate_handler![ commands::file_ops::read_file, commands::file_ops::write_file, @@ -20,6 +28,7 @@ pub fn run() { commands::recent::add_recent_file, commands::recent::clear_recent_files, commands::file_ops::get_open_file_arg, + commands::file_ops::get_pending_open_file, #[cfg(desktop)] platform::desktop::watch_file, #[cfg(desktop)] @@ -41,6 +50,28 @@ pub fn run() { } builder - .run(tauri::generate_context!()) - .expect("error running Inkwell"); + .build(tauri::generate_context!()) + .expect("error building Inkwell") + .run(|app_handle, event| { + if let tauri::RunEvent::Opened { urls } = event { + let paths: Vec = urls + .iter() + .filter_map(|url| url.to_file_path().ok()) + .filter_map(|p| p.to_str().map(String::from)) + .collect(); + + if paths.is_empty() { + return; + } + + // Try to emit to frontend (works when app is already running). + // Also store in state for cold-launch (frontend polls on init). + let state = app_handle.state::(); + let mut pending = state.0.lock().unwrap(); + for path in &paths { + pending.push(path.clone()); + let _ = app_handle.emit("open-file-requested", path.clone()); + } + } + }); } diff --git a/src/main.js b/src/main.js index 433105a..74436fb 100644 --- a/src/main.js +++ b/src/main.js @@ -57,10 +57,21 @@ async function init() { document.getElementById("status-version").textContent = `v${version}`; } catch {} - // Check if app was launched with a file argument (file association / "Open With") + // Handle macOS file-open events for when the app is already running + // (registered early so no events are missed during the checks below) + await listen("open-file-requested", async (event) => { + await loadFile(event.payload); + }); + + // Check if app was launched with a file argument + // 1. CLI argv (Linux/Windows file associations) + // 2. macOS Apple Events buffered in AppState (RunEvent::Opened fires before webview is ready) const fileArg = await invoke("get_open_file_arg"); - if (fileArg) { - await loadFile(fileArg); + const pendingFile = !fileArg ? await invoke("get_pending_open_file") : null; + const openPath = fileArg || pendingFile; + + if (openPath) { + await loadFile(openPath); } else { await showWelcome(); } diff --git a/src/preview/scroll-sync.js b/src/preview/scroll-sync.js index e23e54d..f3181f4 100644 --- a/src/preview/scroll-sync.js +++ b/src/preview/scroll-sync.js @@ -2,6 +2,12 @@ let editorScroller = null; let previewEl = null; let syncSource = null; +let syncResetTimer = null; + +function deferSyncReset() { + clearTimeout(syncResetTimer); + syncResetTimer = setTimeout(() => { syncSource = null; }, 200); +} export function setupScrollSync(editorView, previewElement) { editorScroller = editorView.scrollDOM; @@ -17,7 +23,7 @@ export function setupScrollSync(editorView, previewElement) { const previewMax = previewEl.scrollHeight - previewEl.clientHeight; previewEl.scrollTop = ratio * previewMax; } - requestAnimationFrame(() => { syncSource = null; }); + deferSyncReset(); }); // Preview -> Editor @@ -30,7 +36,7 @@ export function setupScrollSync(editorView, previewElement) { const editorMax = editorScroller.scrollHeight - editorScroller.clientHeight; editorScroller.scrollTop = ratio * editorMax; } - requestAnimationFrame(() => { syncSource = null; }); + deferSyncReset(); }); } @@ -52,7 +58,9 @@ export function getScrollRatio(activeMode) { return 0; } -// Apply a scroll ratio to the target pane(s), suppressing sync feedback +// Apply a scroll ratio to the target pane(s), suppressing sync feedback. +// Uses a single requestAnimationFrame guard (not the 200ms debounce) since +// this is a one-shot restore that shouldn't block user scroll input. export function applyScrollRatio(ratio, targetMode) { syncSource = "restore";