diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 0a9668e..0a68902 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -8,5 +8,5 @@ // Bold group labels inside Features are a deliberate style choice. "MD036": false }, - "globs": ["README.md", "docs/*.md", "ROADMAP.md", "SECURITY.md"] + "globs": ["README.md", "docs/*.md", "SECURITY.md"] } diff --git a/README.md b/README.md index 3e14ad8..8328897 100644 --- a/README.md +++ b/README.md @@ -122,14 +122,6 @@ Then tell your agents how to use it: copy the [AGENTS template](docs/AGENTS-TEMP Found a bug, want a feature, or have feedback? [Open an issue](https://github.com/anbturki/docsreader/issues/new) - I'm actively building this and feedback shapes the roadmap. -## Roadmap - -See [ROADMAP.md](./ROADMAP.md) for the full picture. Short version: - -- **Next:** find-in-page, full-text search, focus mode. -- **Later:** PDF export, kanban view over task files, drag-a-folder-to-add-root, file management. -- **Considering:** plugin API, annotations, drag tabs between panes / N-pane nesting, local "smart" features (related-docs, TL;DR). - ## Screenshots | | | diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index d96d178..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,116 +0,0 @@ -# Roadmap - -DocsReader is in active development. This document tracks what's coming, what's being considered, and what just shipped. - -Layout uses **Now / Next / Later / Considering** - no fixed dates, no quarter commitments. Items move between sections as scope and priorities shift. Want to influence direction? [Open an issue](https://github.com/anbturki/docsreader/issues/new). - -## Now - -In active development. - -_(v0.6.0 - the MCP foundation - just shipped; next up: markdown task formats, see "Next")_ - -## Next - -Committed and scoped, not yet started. - -### Reading experience - -- **Find in page** (Cmd+F) - search within the open document, jump between matches with Enter / Shift+Enter. -- **Full-text search across docs** - go beyond filename matching. Index document bodies on scan; rank with BM25. -- **Focus / reading mode** - hide sidebar, max width, distraction-free. - -### Markdown task formats - -A universal reader for the emerging "markdown-as-PM" conventions used by AI coding agents. Parser registry under the hood; ships with built-in parsers for the most common conventions, with room to add more later. (v0.6 already ships Backlog.md-shaped tasks in managed workspaces via the MCP server; this item is the generic read-only recognition for arbitrary folders.) - -Built-in parsers in v1: - -- **Generic frontmatter** - any file with `status:` + checkbox progress. -- **[Backlog.md](https://github.com/MrLesk/Backlog.md)** - markdown-native task manager for Claude Code, Codex, Gemini CLI, etc. -- **[taskmd](https://medium.com/@driangle/taskmd-task-management-for-the-ai-era-92d8b476e24e)** - local-first markdown tasks for AI coding agents. -- **[Obsidian TaskNotes](https://www.obsidianstats.com/plugins/tasknotes)** - one markdown file per task with YAML frontmatter. - -**Phase 1 (read-only recognition):** status pill, priority, owner, due, and a progress bar in the document header for any recognized task file. - -## Later - -Committed direction, not scoped yet. - -### Reading - -- Print / Export to PDF -- Image lightbox with zoom -- Recently-viewed list (per session and persistent) -- Open-in-external-editor button (VS Code, Cursor, Vim) - -### Tasks view - -- Kanban-style sidebar mode grouping recognized task files by normalized status. -- Tag / owner / priority filters. -- Click a card → open the file as a tab. -- Live updates as files change (file watcher already in place). - -### File management - -- Drag a folder into the window to add it as a root. -- Rename / delete / create files from the sidebar context menu. - -## Considering - -Direction unclear, community input wanted before committing. - -- **Plugin API** - expose the parser registry as a community-extensible plugin system once usage justifies the API stability commitment. Obsidian-style if it earns it. -- **Annotations** - highlights and notes that travel with the document. Would shift DocsReader from a reader to a research tool, meaningful identity change. -- **Drag tabs between panes / N-pane nesting** - extend the v0.5 split view to support dragging a tab from one pane to the other, plus arbitrary nesting beyond two panes. Deferred until the 2-pane MVP has real usage feedback. -- **Local "smart" features** - related-docs ("you may also want…") via TF-IDF, extractive TL;DR via TextRank. AI-feeling, no AI service. -- **Drag-to-update task status** - builds on the write posture v0.6 introduced with quick-edit; needs the kanban view first. - -## Recently shipped - -### v0.6.0 - -- **MCP server (`docsreader-mcp`)** - a bundled stdio MCP server AI agents write through: docs with a status-as-folder lifecycle (research / in-progress / done / archived, optional phase subfolders), topic-addressed memory, and Backlog.md-shaped tasks. Tools for the full lifecycle, every doc exposed as a resource, a self-describing onboarding resource, `start-task` / `record-decision` prompts, and recovery-bearing tool errors. -- **Connect to AI agents** - Settings pane that detects installed MCP clients (Claude Code, Cursor, Windsurf, VS Code, Codex) and registers the server with each in one click; non-destructive config merges. -- **Managed workspaces** - `.docsreader.yaml` marker, convert-to-workspace prompt for plain folders, display names in the switcher, homepage auto-open, and silent live reload while agents write. This replaces the v0.4 `.docs.yaml` curated-navigation manifest system: existing manifests auto-migrate to the new marker on first scan, and curated nav sections, the internal-visibility toggle, and the manifest-issues pane are removed. -- **Quick edit** - pencil toggle on the open doc for fast human fixes; raw markdown round-trips frontmatter untouched. -- **Backlinks pane** - incoming links to the open doc, grouped by source folder, collected during the scan. -- **Agent workspace pickers** - project auto-detection via `CLAUDE_PROJECT_DIR` + walk-up, and an elicitation-based workspace picker for interactive clients when a slug is unknown. -- **Polish + fixes** - mermaid/svgbob diagrams render again in production builds (regression-tested), a denser sidebar, a refreshed agent-first welcome tour, and per-client MCP setup commands in the README. - -### v0.5.0 - -- **Split view** - side-by-side or stacked panes for reading two docs at once. Toggle from the header (single / horizontal / vertical), drag the splitter to resize, "Open in other pane" context-menu entry. Each pane keeps its own tabs, scroll, and external-change banner; the outline tracks whichever pane is focused. Keyboard shortcuts: `Cmd+\` toggles horizontal, `Cmd+Shift+\` toggles vertical, `Cmd+1` / `Cmd+2` focus pane 0 / pane 1. Pane 1's tabs persist across single/split toggles so re-splitting brings them back exactly as they were. - -### v0.4.0 - -- **`.docs.yaml` v0.1 manifest support** - projects shipping a manifest get curated navigation (hand-curated `items` and auto-listed `folder` sections with sort, title-from, badges, nesting), project metadata in the workspace switcher, automatic homepage open on first add, cross-project links between open workspaces, ignore patterns, a visibility toggle for previewing public-only views, and a sidebar pane that surfaces manifest issues. -- **Git integration (T1+T2)** - per-file status badges in the file tree (M / A / D / R / ? / U) for workspaces inside a git repo; "Show git diff" context menu opens a diff vs HEAD with unified or side-by-side view and word-level highlighting. Git binary auto-discovered across PATH plus common Homebrew locations. -- **External-change banner** - when a file open in a tab changes on disk, a banner shows what changed with reload / show-diff / dismiss / always-auto-reload actions; same diff dialog as the git diff feature. -- **Welcome workspace** - first-run installs auto-extract a small bundled tour (uses `.docs.yaml` itself) so empty-state users have something to read. -- **Manifest in-place edit detection** - editing `.docs.yaml` triggers a rescan even though the file set is unchanged. - -### v0.3.0 - -- Workspace-level filesystem watcher: edit / add / remove / rename anywhere in a workspace and the file tree updates without manual refresh. -- Rate-limited watcher: events filtered against a skip list (mirrors the scanner's), 600ms debounce, 2-second minimum interval between rescans regardless of churn. -- Async-then-staple release pipeline: ship the build immediately, swap in the stapled DMG once Apple's notary returns. CI no longer blocks for hours. -- README rewrite around four feature groups (Reading / Browsing / Quiet by default / Trust); right-aligned header logo. - -### v0.2.0 - -- Quick Open (Cmd+P, configurable shortcut) jumps to any file across all roots. -- Outline / TOC sidebar with active-heading scroll-spy. -- LaTeX math via KaTeX. Mermaid diagrams (lazy-loaded; theme-aware). -- Configurable code-block themes (light + dark, 12 themes). -- Sidebar collapse state, open tabs, active tab, and per-tab scroll position survive restarts. -- Strict CSP, sanitized markdown HTML, scheme-allowlisted links. -- README badge tracking pending notarization. - -### v0.1.x - -- Multi-root file scanning, tabs, file watcher, light/dark themes, accent colors, frontmatter parsing, code-signed + notarized macOS bundles, Homebrew tap, signed auto-updates. - ---- - -[Open an issue](https://github.com/anbturki/docsreader/issues/new) to comment, suggest, or vote. diff --git a/src-tauri/core/src/memory.rs b/src-tauri/core/src/memory.rs index 4964b5b..d37f29d 100644 --- a/src-tauri/core/src/memory.rs +++ b/src-tauri/core/src/memory.rs @@ -157,13 +157,8 @@ pub fn search_memory_core( match &q { None => hits.push(hit), Some(q) => { - let score = score_match( - hit.title.as_deref(), - &hit.tags, - &hit.slug, - hit.content.to_lowercase().contains(q), - q, - ); + let body_lower = hit.content.to_lowercase(); + let score = score_match(hit.title.as_deref(), &hit.tags, &hit.slug, &body_lower, q); if score > 0 { hits.push(MemoryHit { score, ..hit }); } @@ -302,6 +297,31 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[tokio::test] + async fn search_matches_multi_word_query_with_and_semantics() { + let root = test_dir("search_multi"); + write_memory_core( + &root, + "coturn config", + "coTURN 4.14 dropped the negative flags for positive ones.", + &["coturn".into()], + None, + ) + .await + .unwrap(); + + // Both words are present but not adjacent - the old single-substring + // match returned nothing here. + let hits = search_memory_core(&root, Some("coturn flags"), None).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].slug, "coturn-config"); + + // A term absent from the entry excludes it (AND). + let none = search_memory_core(&root, Some("coturn kubernetes"), None).unwrap(); + assert!(none.is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + #[tokio::test] async fn delete_removes_entry_and_rejects_bad_refs() { let root = test_dir("del"); diff --git a/src-tauri/core/src/read.rs b/src-tauri/core/src/read.rs index 3357f3c..12d29c1 100644 --- a/src-tauri/core/src/read.rs +++ b/src-tauri/core/src/read.rs @@ -185,33 +185,50 @@ const SCORE_TAG: u32 = 2; const SCORE_SLUG: u32 = 2; const SCORE_CONTENT: u32 = 1; +// Every whitespace-separated term must match somewhere (AND); each term's +// score sums the fields it hits. A single-word query scores exactly as it did +// before tokenization; a multi-word query like "coturn flags" now matches an +// entry containing both words even when they are not adjacent. pub(crate) fn score_match( title: Option<&str>, tags: &[String], slug: &str, - body_matches: bool, + body_lower: &str, query_lower: &str, ) -> u32 { - let mut score = 0u32; - if title.is_some_and(|t| t.to_lowercase().contains(query_lower)) { - score += SCORE_TITLE; - } - if tags.iter().any(|t| t.to_lowercase() == query_lower) { - score += SCORE_TAG; - } - if slug.to_lowercase().contains(query_lower) { - score += SCORE_SLUG; - } - if body_matches { - score += SCORE_CONTENT; + let title_lower = title.map(str::to_lowercase); + let slug_lower = slug.to_lowercase(); + let tags_lower: Vec = tags.iter().map(|t| t.to_lowercase()).collect(); + let mut total = 0u32; + for term in query_lower.split_whitespace() { + let mut term_score = 0u32; + if title_lower.as_deref().is_some_and(|t| t.contains(term)) { + term_score += SCORE_TITLE; + } + if tags_lower.iter().any(|t| t == term) { + term_score += SCORE_TAG; + } + if slug_lower.contains(term) { + term_score += SCORE_SLUG; + } + if body_lower.contains(term) { + term_score += SCORE_CONTENT; + } + if term_score == 0 { + return 0; + } + total += term_score; } - score + total } fn content_snippet(content: &str, query_lower: &str) -> Option { let body = split_frontmatter(content).1; let lower = body.to_lowercase(); - let hit = lower.find(query_lower)?; + let hit = query_lower + .split_whitespace() + .filter_map(|term| lower.find(term)) + .min()?; let start = body[..hit] .char_indices() .rev() @@ -238,13 +255,8 @@ pub fn search_docs_core( let mut hits = Vec::new(); for (doc, content) in collect_docs(root, filters)? { let snippet = content_snippet(&content, &q); - let score = score_match( - doc.title.as_deref(), - &doc.tags, - &doc.slug, - snippet.is_some(), - &q, - ); + let body_lower = split_frontmatter(&content).1.to_lowercase(); + let score = score_match(doc.title.as_deref(), &doc.tags, &doc.slug, &body_lower, &q); if score > 0 { hits.push(SearchHit { doc, @@ -367,6 +379,20 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[tokio::test] + async fn search_matches_multi_word_query_across_the_body() { + let root = test_dir("search_multi"); + seed(&root).await; + + // "use" and "alpha" both occur in Alpha Guide but are not adjacent; + // Beta Notes has "alpha" but not "use", so AND semantics drops it. + let hits = search_docs_core(&root, "alpha use", &DocFilters::default()).unwrap(); + assert_eq!(hits.len(), 1, "only the doc with both terms matches"); + assert_eq!(hits[0].doc.slug, "alpha-guide"); + assert!(hits[0].snippet.is_some()); + let _ = std::fs::remove_dir_all(&root); + } + #[tokio::test] async fn read_concise_vs_detailed() { let root = test_dir("read"); diff --git a/src-tauri/core/src/workspace/resolve.rs b/src-tauri/core/src/workspace/resolve.rs index 9c20170..a75b97f 100644 --- a/src-tauri/core/src/workspace/resolve.rs +++ b/src-tauri/core/src/workspace/resolve.rs @@ -41,6 +41,12 @@ fn ambient_workspace( home: &Path, ) -> Result { for base in roots_hint.iter().map(PathBuf::as_path).chain(walk_up(cwd)) { + // ~/notes is the user default, not a project workspace; skip the home + // directory so the walk-up never tags it Project. user_default below + // classifies it as User, keeping scope consistent with the registry. + if base == home { + continue; + } if let Some(found) = project_workspace_at(base)? { return Ok(found); } @@ -184,6 +190,23 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn home_notes_stays_user_scope_when_reached_by_walk_up() { + let dir = test_dir("res_home_scope"); + let home = dir.join("home"); + save_marker(&home.join("notes"), &marker("ali-notes")).unwrap(); + // cwd is inside home but not inside any project workspace, so the + // walk-up reaches ~/notes. It must stay User, matching the registry. + let cwd = home.join("projects/some-repo"); + std::fs::create_dir_all(&cwd).unwrap(); + + let resolved = resolve_workspace(None, &[], &cwd, &home, &[]).unwrap(); + assert_eq!(resolved.root, home.join("notes")); + assert_eq!(resolved.slug, "ali-notes"); + assert_eq!(resolved.scope, WorkspaceScope::User); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn user_workspace_marker_slug_wins_over_default() { let dir = test_dir("res_userslug");