Writer Open Flows
How files and folders reach the editor from every entry point.
There are two distinct open mechanisms, deliberately kept separate:
startup_open— a singleOption<PendingOpenPayload>seeded in Rust beforeget_startup_statereads it. For new windows this happens during window creation; for macOS cold-startRunEvent::Opened, it can happen while the main webview exists but is still hidden and unhydrated. Read exactly once during startup hydration. Same lifecycle as settings: set before first render, consumed during the first render.pending_open— a per-window queue for runtime drag-and-drop / dock drops onto an already-running window. Drained by the frontend via theopen:from-dropevent +take_pending_openIPC.
Both feed the single get_startup_state IPC (for startup) or the
restoreFromBundle path, so React's first render already has full content.
Path Resolution (shared)
Every entry point funnels through open_target::classify():
Input path
├─ is_dir? → { workspace: canonicalize(path), file: None }
├─ is_file?
│ └─ .md/.markdown (case-insensitive)?
│ → { workspace: canonicalize(parent), file: canonicalize(path) }
└─ otherwise → None (lenient) / Error (strict CLI)
1. Cold Start (writer ., Finder open, dock drop while not running)
The writer symlink invokes the same binary as the GUI app. main.rs
dispatches on argv[0]: basename writer → CLI launcher, Writer → Tauri app.
On macOS the open target is not delivered through argv — open -a Writer /path (which the CLI launcher, Finder, and dock all use) delivers it via the
RunEvent::Opened system event. That event can fire before setup() builds the
main window or after Tauri has built the still-hidden webview but before React
calls get_startup_state. In either case the handler seeds startup_open as
long as that startup slot has not been read yet. The webview then reads it
during hydration — a single window, no duplicate.
sequenceDiagram
participant OS as macOS (open -a)
participant Run as Tauri run loop
participant State as AppState (main)
participant WV as Main WebviewWindow
participant React as React (useOpenDrop)
participant IPC as get_startup_state
OS->>Run: RunEvent::Opened { urls: [file:///path] }
Run->>Run: resolve_path(url)
Run->>State: find_by_workspace(path) → None
Note over Run: Main startup slot unread →<br/>cold start. Seed startup_open.
Run->>State: get_or_create("main").try_set_startup_open(payload)
Note over WV: setup() builds the window,<br/>get_or_create("main") returns the<br/>same state with startup_open set
WV->>React: WebView loads, React mounts
React->>React: useOpenDrop → resolveStartup()
React->>IPC: get_startup_state()
IPC->>State: take_startup_open() → Some(payload)
IPC->>IPC: build_restore_bundle(payload.workspace)
Note over IPC: Strip session + active_file,<br/>set open_file = payload.file
IPC-->>React: { settings, recents, restore_bundle }
React->>React: restoreFromBundle(bundle)
Note over React: open_file set → open just that file,<br/>no previous-session tabs
React->>React: setStartupResolved() → show window
On Linux/Windows the path arrives through argv instead; setup() reads
std::env::args()[1] and calls set_startup_open directly (gated behind
#[cfg(not(target_os = "macos"))]).
2. Runtime Drag-and-Drop onto a Window
When the app is already running and the user drops a file/folder onto a window,
the per-window pending_open queue + open:from-drop event carry it. This path
never touches startup_open.
sequenceDiagram
participant User
participant WV as WebviewWindow
participant Rust as Tauri (lib.rs)
participant State as WorkspaceState
participant React as React (useOpenDrop)
participant Store as Zustand Stores
User->>WV: Drag folder/file onto window
WV->>Rust: WindowEvent::DragDrop { paths }
loop First valid path in drop
Rust->>Rust: resolve_path(path)
Note over Rust: Lenient: skip non-dir / non-.md
end
Rust->>State: push_pending_open(payload) (runtime queue)
Rust->>WV: emit_to(label, "open:from-drop", payload)
React->>React: listen("open:from-drop") fires
React->>React: await startupReady, then drainPendingOpens()
React->>Rust: IPC: take_pending_open()
Rust->>State: pop_pending_open()
Rust-->>React: PendingOpenPayload
alt Same workspace as current window
React->>Store: openFile(payload.file) if present
else Different workspace
React->>Rust: IPC: open_workspace_in_new_window()
Note over Rust: New window seeded via startup_open
end
3. Dock Drop / Finder Open While Running
Dragging onto the dock icon (or double-clicking a .md in Finder via the
fileAssociations registration) fires RunEvent::Opened. The handler routes by
whether a window already hosts the workspace, whether the main startup slot is
still unread, and whether the main window is already visible.
sequenceDiagram
participant OS as macOS
participant Run as Tauri run loop
participant State as AppState
participant WV as Existing Window
participant NewWV as New Window
OS->>Run: RunEvent::Opened { urls: [file:///path] }
Run->>Run: resolve_path(url)
Run->>State: find_by_workspace(canonical_path)
alt A window already hosts this workspace
State-->>Run: Some(label)
Run->>State: push_pending_open(payload) (runtime queue)
Run->>WV: emit_to(label, "open:from-drop")
Note over WV: Drains queue, focuses,<br/>opens file if specified
else No window hosts it and main startup slot unread
State-->>Run: None
Run->>State: try_set_startup_open(payload) → Ok
Note over WV: Hidden main hydrates onto<br/>requested workspace
else No window hosts it and main hidden
State-->>Run: None
Run->>State: push_pending_open(payload)
Run->>WV: emit_to("main", "open:from-drop")
Note over WV: Startup drainer runs after<br/>hydration completes
else No window hosts it and main visible
State-->>Run: None
Note over Run: Warm start → preserve<br/>current editor state
Run->>NewWV: open_new_workspace_window(workspace, file)
Note over NewWV: New window seeded via<br/>startup_open, hydrates on load
end
The cold-start branch of this same handler is covered in flow 1 — it seeds the
main window while startup_open is still readable instead of spawning a new one.
4. Second Launch (Single-Instance Plugin)
When Writer is already running and the user runs writer . again, the OS hands
the second process's argv to the existing process via
tauri-plugin-single-instance.
sequenceDiagram
participant Shell
participant OS as macOS (open -a)
participant Plugin as single-instance plugin
participant Rust as Tauri (existing process)
participant NewWV as New Window
participant WV as Existing Window
Shell->>OS: writer ~/docs → open -a Writer ~/docs
OS->>Plugin: 2nd process argv intercepted
Plugin->>Rust: handle_single_instance(argv)
Rust->>Rust: resolve_path(argv[1])
alt Path resolves
Rust->>NewWV: open_new_workspace_window(workspace, file)
Note over NewWV: Focuses existing window if that<br/>workspace is already open
else No path argument
Rust->>WV: set_focus() on "main" window
end
5. New Window (open_new_workspace_window)
Every secondary window — whether opened from the frontend
(open_workspace_in_new_window), the single-instance plugin, or a warm dock
drop — is seeded the same way: a fresh WorkspaceState is created, its
startup_open is set, and the window's webview reads it through the same
get_startup_state path as the main window on cold start. If a window already
hosts the requested workspace, it is focused instead of duplicated.
6. Unified Startup (per-window)
Every window — main or secondary — runs this once. The single get_startup_state
IPC eliminates the waterfall of separate settings/recents/workspace fetches.
sequenceDiagram
participant WV as WebviewWindow
participant React as useOpenDrop
participant IPC as get_startup_state (Rust)
participant State as WorkspaceState
participant Store as Zustand Stores
WV->>React: React mounts → useOpenDrop()
React->>React: resolveStartup() (guarded, runs once)
React->>IPC: get_startup_state()
IPC->>State: read settings (merged layers)
IPC->>IPC: load_recent_workspaces()
IPC->>State: take_startup_open()
alt startup_open set (CLI / Finder / new-window)
IPC->>IPC: build_restore_bundle(startup_open.workspace)
Note over IPC: Strip session + active_file,<br/>set open_file = startup_open.file
else no startup_open, restore-workspace enabled
IPC->>IPC: build_restore_bundle(recents[0])
Note over IPC: Keep session → restore previous tabs
else no startup_open, restore disabled
Note over IPC: No bundle — welcome screen
end
IPC-->>React: StartupState { settings, recents, restore_bundle }
React->>Store: settings.hydrateFromBackend()
React->>Store: workspace.recentWorkspaces = recents
opt restore_bundle present
React->>Store: workspace.restoreFromBundle(bundle)
alt bundle.session has tabs
Store->>Store: restoreSession(tabs, active_file)
else bundle.open_file set
Store->>Store: openFile(open_file)
else neither
Store->>Store: ensureLauncherTab()
end
end
React->>Store: setStartupResolved()
Note over Store: Gate flips → AppLayout renders
React->>WV: show_main_window()
React->>React: listen("open:from-drop")
Note over React: Runtime drops drain via pending_open
Key Design Decisions
-
Single source of truth per concern:
startup_openowns the initial open (seeded beforeget_startup_statereads it);pending_openowns runtime drops (queue + event). Their consumers do not overlap, which removes the cold-start double-open and the race where a runtime event clobbered startup. -
Explicit open ≠ session restore: when
startup_openis set, the restore bundle's session and active file are stripped andopen_filecarries the request. Sowriter file.mdopens just that file, not the previous session's tabs. With nostartup_open, the bundle keeps the session and restores tabs. -
One IPC, one render:
get_startup_statebundles settings + recents + the prefetched restore bundle. React hydrates everything before flipping the startup gate, so the first visible frame is the full editor — no welcome screen flash, no second IPC waterfall. -
Canonical paths everywhere: paths are canonicalized on the Rust side (
/var→/private/varon macOS) so watcher events, sidebar entries, and window lookups (find_by_workspace) all key off the same string. -
Per-window isolation: each window has its own
WorkspaceState(workspace root, file index, watcher, settings layer,startup_open,pending_open). Concurrent session saves serialize through a process-wide lock. -
Lenient vs. strict resolution: drag-drop and
RunEvent::Openeduseresolve_path(returnsNonefor unsupported files). The CLI usesvalidate_and_resolve(typed error for stderr).