From 71c052e7ade9382ed2b8d8a62805207251cbc6c2 Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Sat, 11 Jul 2026 18:48:40 +0800 Subject: [PATCH] feat: open files and folders with the app via OS file associations Register .md/.markdown/.mdx associations so DocsReader appears in Finder's Open With menu and can be set as the default handler. Opened targets route into the app: a folder becomes a workspace root, and a file resolves to its enclosing workspace (or its parent folder as an ad-hoc root) and opens in the active pane. macOS delivers opens through RunEvent::Opened for both cold and warm starts; Windows and Linux deliver them as argv via the single-instance plugin, which also folds a second launch into the running window. A buffer on the Rust side holds paths captured before the frontend mounts so cold-start opens are not lost. --- README.md | 1 + docs/FEATURES.md | 1 + src-tauri/Cargo.lock | 16 +++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 43 +++++++++++++++-- src-tauri/src/open_with.rs | 63 ++++++++++++++++++++++++ src-tauri/tauri.conf.json | 20 ++++++++ src/App.tsx | 8 ++++ src/hooks/useOpenWith.ts | 98 ++++++++++++++++++++++++++++++++++++++ src/hooks/usePanes.ts | 11 +++++ 10 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/open_with.rs create mode 100644 src/hooks/useOpenWith.ts diff --git a/README.md b/README.md index a58d2b6..aec9f5e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Needs `docsreader-mcp` on your PATH and `jq`. Full setup in the [plugin README]( | **Interactive checklists** | Toggle any checkbox from the rendered view; the change writes back to the file | | **Five lenses** | Tree, Recent, Tags, Pinned, and a Tasks kanban board over one library | | **Split view** | Two docs side-by-side or stacked, each with its own tabs and scroll | +| **Open with** | Double-click a `.md` in Finder or "Open With DocsReader" to jump straight to a file or folder | | **Task board** | To Do / In Progress / Done with drag-to-advance and acceptance-criteria progress, consistent with the MCP | | **Agent-aware** | Open docs reload live as agents write; on-disk changes surface a diff; git status shows in the tree | | **Quiet and local** | Minimal chrome, no telemetry, signed updates, notarized on macOS | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0be519d..a9c7183 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -19,6 +19,7 @@ The full feature list for DocsReader. The [README](../README.md#features) shows ## Browsing - **Workspaces:** keep multiple unrelated folders open and pivot between them +- **Open with:** double-click a `.md`/`.markdown`/`.mdx` in Finder, or right-click > Open With DocsReader. A folder opens as a workspace; a file resolves to its workspace (or its parent folder) and opens in the active pane, whether the app was already running or launched by the open - **Lenses:** five browsing modes over the same library (Tree, Recent, Tags, Pinned, Tasks) - **Jump-to-file:** fuzzy finder across every workspace, opens with Cmd+P (binding configurable) - **Document outline:** auto-built TOC that follows the active heading as you scroll diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5a508b3..4700bdb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -812,6 +812,7 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-opener", "tauri-plugin-process", + "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", "tempfile", @@ -4151,6 +4152,21 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-store" version = "2.4.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3ea037e..cbe0edb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2.3.1" docsreader-core = { path = "core" } toml_edit = "0.25.12" +tauri-plugin-single-instance = "2" [workspace] members = ["core", "mcp"] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c091b3..4b725ec 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,15 +1,37 @@ mod agents; +mod open_with; mod tauri_api; +use open_with::OpenedPaths; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let mut builder = tauri::Builder::default(); + + // A file/folder opened via the OS while an instance is already running + // arrives as argv on Windows/Linux; single-instance folds that launch + // into the running window and forwards the paths. macOS delivers opens + // through RunEvent::Opened instead (handled below), so this branch is a + // no-op there beyond preventing a duplicate instance. + #[cfg(desktop)] + { + use tauri::Manager; + builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + open_with::dispatch(app, argv.into_iter().skip(1)); + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); + } + })); + } + + builder .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) + .manage(OpenedPaths::default()) .invoke_handler(tauri::generate_handler![ tauri_api::scan_markdown, tauri_api::convert_workspace, @@ -21,8 +43,21 @@ pub fn run() { tauri_api::git_status, tauri_api::git_show_head, tauri_api::list_tasks, - tauri_api::set_task_status + tauri_api::set_task_status, + open_with::take_opened_paths ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while running tauri application") + .run(|_app, _event| { + #[cfg(target_os = "macos")] + { + use tauri::Manager; + if let tauri::RunEvent::Opened { urls } = _event { + open_with::dispatch(_app, urls.iter().map(|url| url.to_string())); + if let Some(window) = _app.get_webview_window("main") { + let _ = window.set_focus(); + } + } + } + }); } diff --git a/src-tauri/src/open_with.rs b/src-tauri/src/open_with.rs new file mode 100644 index 0000000..642a02a --- /dev/null +++ b/src-tauri/src/open_with.rs @@ -0,0 +1,63 @@ +use std::path::PathBuf; +use std::sync::Mutex; + +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager, Url}; + +pub const OPEN_PATH_EVENT: &str = "open-path"; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenTarget { + pub path: String, + pub is_dir: bool, +} + +#[derive(Default)] +pub struct OpenedPaths(pub Mutex>); + +// Turn whatever the OS handed us - file:// URLs from macOS RunEvent::Opened, +// or bare paths from Windows/Linux argv - into concrete filesystem targets, +// stash them for a cold-started frontend to drain, and emit for a warm one. +pub fn dispatch(app: &AppHandle, items: I) +where + I: IntoIterator, + S: AsRef, +{ + let targets: Vec = items + .into_iter() + .filter_map(|item| resolve(item.as_ref())) + .map(|path| OpenTarget { + is_dir: path.is_dir(), + path: path.to_string_lossy().into_owned(), + }) + .collect(); + if targets.is_empty() { + return; + } + if let Some(state) = app.try_state::() { + if let Ok(mut queued) = state.0.lock() { + queued.extend(targets.clone()); + } + } + let _ = app.emit(OPEN_PATH_EVENT, targets); +} + +fn resolve(item: &str) -> Option { + if let Ok(url) = Url::parse(item) { + if url.scheme() == "file" { + return url.to_file_path().ok(); + } + } + let path = PathBuf::from(item); + path.exists().then_some(path) +} + +#[tauri::command] +pub fn take_opened_paths(app: AppHandle) -> Vec { + app.state::() + .0 + .lock() + .map(|mut queued| std::mem::take(&mut *queued)) + .unwrap_or_default() +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e915acc..d49097f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -39,6 +39,26 @@ "resources": [ "resources/welcome/**/*" ], + "fileAssociations": [ + { + "ext": ["md", "markdown"], + "name": "Markdown", + "description": "Markdown document", + "mimeType": "text/markdown", + "role": "Editor" + }, + { + "ext": ["mdx"], + "name": "MDX", + "description": "MDX document", + "mimeType": "text/markdown", + "role": "Editor", + "exportedType": { + "identifier": "com.docsreader.app.mdx", + "conformsTo": ["public.plain-text"] + } + } + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/App.tsx b/src/App.tsx index b81ef57..34b3024 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ import { useViewSettings } from "@/hooks/useViewSettings"; import { useSidebarState } from "@/hooks/useSidebarState"; import { usePinned } from "@/hooks/usePinned"; import { useUpdater } from "@/hooks/useUpdater"; +import { useOpenWith } from "@/hooks/useOpenWith"; import { buildTree } from "@/lib/tree"; import { collectDirKeys } from "@/components/explorer/FileTree"; import { buildHideMatcher } from "@/lib/match"; @@ -67,6 +68,13 @@ function App() { const sidebar = useSidebarState(viewSettings.settings.defaultFolderState); const pinned = usePinned(); const updater = useUpdater(); + useOpenWith({ + hydrated: library.hydrated && panes.hydrated, + roots: library.roots, + addRoot: library.addRoot, + selectRoot: library.selectRoot, + openFile: panes.openInActivePane, + }); useTheme(viewSettings.settings.colorScheme, viewSettings.settings.accentColor); const deferredSettings = useDeferredValue(viewSettings.settings); const [search, setSearch] = useState(""); diff --git a/src/hooks/useOpenWith.ts b/src/hooks/useOpenWith.ts new file mode 100644 index 0000000..1900340 --- /dev/null +++ b/src/hooks/useOpenWith.ts @@ -0,0 +1,98 @@ +import { useEffect, useRef } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { dirname } from "@/lib/path"; + +const OPEN_PATH_EVENT = "open-path"; +const TAKE_OPENED_PATHS = "take_opened_paths"; + +export interface OpenTarget { + path: string; + isDir: boolean; +} + +interface UseOpenWithOptions { + // Wait until roots and panes are hydrated: opening before pane hydration + // completes would let loadTabsState clobber the freshly opened tab. + hydrated: boolean; + roots: string[]; + addRoot: (path: string) => Promise; + selectRoot: (path: string | undefined) => Promise; + openFile: (path: string) => void; +} + +function containingRoot(roots: string[], filePath: string): string | undefined { + const norm = filePath.replace(/\\/g, "/"); + let best: string | undefined; + for (const root of roots) { + const r = root.replace(/\\/g, "/"); + if (norm === r || norm.startsWith(r + "/")) { + if (!best || r.length > best.length) best = root; + } + } + return best; +} + +async function handleTarget(o: UseOpenWithOptions, target: OpenTarget): Promise { + if (target.isDir) { + if (o.roots.includes(target.path)) await o.selectRoot(target.path); + else await o.addRoot(target.path); + return; + } + const existing = containingRoot(o.roots, target.path); + if (existing) await o.selectRoot(existing); + else await o.addRoot(dirname(target.path)); + o.openFile(target.path); +} + +// Route files and folders opened through the OS ("Open With DocsReader", +// double-click a .md when DocsReader is the default) into the app. A folder +// becomes a workspace root; a file resolves to its enclosing root (or its +// parent folder as an ad-hoc root) and opens as a tab in the active pane. +export function useOpenWith(options: UseOpenWithOptions): void { + const optsRef = useRef(options); + optsRef.current = options; + // Serialize handling so multiple targets arriving together don't race on + // addRoot's shared root list. + const queueRef = useRef>(Promise.resolve()); + + useEffect(() => { + if (!options.hydrated) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + + const enqueue = (targets: OpenTarget[]) => { + queueRef.current = queueRef.current.then(async () => { + for (const target of targets) { + if (cancelled) return; + try { + await handleTarget(optsRef.current, target); + } catch (err) { + console.error("open-with failed", target, err); + } + } + }); + }; + + void (async () => { + // Attach the listener before draining so an Opened event that fires + // during startup is never lost between the two calls. + unlisten = await listen(OPEN_PATH_EVENT, (e) => enqueue(e.payload)); + if (cancelled) { + unlisten(); + return; + } + try { + const initial = await invoke(TAKE_OPENED_PATHS); + if (initial.length > 0) enqueue(initial); + } catch (err) { + console.error(`${TAKE_OPENED_PATHS} failed`, err); + } + })(); + + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; + }, [options.hydrated]); +} diff --git a/src/hooks/usePanes.ts b/src/hooks/usePanes.ts index ef3a787..e002f1f 100644 --- a/src/hooks/usePanes.ts +++ b/src/hooks/usePanes.ts @@ -21,6 +21,7 @@ export interface Panes { setSplitSize: (size: number) => void; focusPane: (idx: PaneIndex) => void; openInOtherPane: (path: string) => void; + openInActivePane: (path: string) => void; } interface UsePanesOptions { @@ -118,6 +119,15 @@ export function usePanes(options: UsePanesOptions): Panes { [pane0, pane1] ); + const openInActivePane = useCallback( + (path: string) => { + const current = layoutRef.current; + if (current.split === "off" || current.activePane === 0) pane0.openInActive(path); + else pane1.openInActive(path); + }, + [pane0, pane1] + ); + const panes: [Tabs, Tabs] = [pane0, pane1]; const activePane = layout.activePane === 0 ? pane0 : pane1; const hydrated = layoutHydrated && pane0.hydrated && pane1.hydrated; @@ -131,5 +141,6 @@ export function usePanes(options: UsePanesOptions): Panes { setSplitSize, focusPane, openInOtherPane, + openInActivePane, }; }