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
2 changes: 1 addition & 1 deletion .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
8 changes: 0 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

| | |
Expand Down
116 changes: 0 additions & 116 deletions ROADMAP.md

This file was deleted.

34 changes: 27 additions & 7 deletions src-tauri/core/src/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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");
Expand Down
70 changes: 48 additions & 22 deletions src-tauri/core/src/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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<String> {
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()
Expand All @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
23 changes: 23 additions & 0 deletions src-tauri/core/src/workspace/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ fn ambient_workspace(
home: &Path,
) -> Result<ResolvedWorkspace, CoreError> {
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);
}
Expand Down Expand Up @@ -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");
Expand Down
Loading