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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
43 changes: 39 additions & 4 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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();
}
}
}
});
}
63 changes: 63 additions & 0 deletions src-tauri/src/open_with.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<OpenTarget>>);

// 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<I, S>(app: &AppHandle, items: I)
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let targets: Vec<OpenTarget> = 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::<OpenedPaths>() {
if let Ok(mut queued) = state.0.lock() {
queued.extend(targets.clone());
}
}
let _ = app.emit(OPEN_PATH_EVENT, targets);
}

fn resolve(item: &str) -> Option<PathBuf> {
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<OpenTarget> {
app.state::<OpenedPaths>()
.0
.lock()
.map(|mut queued| std::mem::take(&mut *queued))
.unwrap_or_default()
}
20 changes: 20 additions & 0 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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("");
Expand Down
98 changes: 98 additions & 0 deletions src/hooks/useOpenWith.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
selectRoot: (path: string | undefined) => Promise<void>;
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<void> {
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<void>>(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<OpenTarget[]>(OPEN_PATH_EVENT, (e) => enqueue(e.payload));
if (cancelled) {
unlisten();
return;
}
try {
const initial = await invoke<OpenTarget[]>(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]);
}
11 changes: 11 additions & 0 deletions src/hooks/usePanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -131,5 +141,6 @@ export function usePanes(options: UsePanesOptions): Panes {
setSplitSize,
focusPane,
openInOtherPane,
openInActivePane,
};
}
Loading