diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0bf0de6..5eee2d0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -462,11 +462,19 @@ pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), St } #[tauri::command] -pub fn set_ui_scale(state: State<'_, AppState>, scale: f64) -> Result<(), String> { +pub fn set_ui_scale( + app: AppHandle, + state: State<'_, AppState>, + scale: f64, +) -> Result<(), String> { let mut config = state.config.lock().map_err(|e| e.to_string())?; config.ui_scale = Some(scale); save_app_config(&config)?; - Ok(()) + drop(config); + + use tauri::Emitter; + app.emit("ui-scale-changed", scale) + .map_err(|error| error.to_string()) } #[tauri::command] @@ -799,6 +807,26 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result, + recent_paths: Vec, +) -> Result, String> { + let vault_path = state + .config + .lock() + .map_err(|error| error.to_string())? + .active_vault + .clone() + .ok_or_else(|| "No active vault".to_string())?; + + tauri::async_runtime::spawn_blocking(move || { + operations::get_note_switcher_titles(&vault_path, &recent_paths) + }) + .await + .map_err(|error| error.to_string())? +} + // ── Graph ── #[tauri::command] @@ -1502,10 +1530,18 @@ pub fn set_general_settings( // ── Quick Access ── #[tauri::command] -pub fn get_quick_access(state: State<'_, AppState>) -> Result, String> { - let config = state.config.lock().map_err(|e| e.to_string())?; - let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; - operations::get_quick_access_notes(vault_path) +pub async fn get_quick_access(state: State<'_, AppState>) -> Result, String> { + let vault_path = state + .config + .lock() + .map_err(|error| error.to_string())? + .active_vault + .clone() + .ok_or_else(|| "No active vault".to_string())?; + + tauri::async_runtime::spawn_blocking(move || operations::get_quick_access_notes(&vault_path)) + .await + .map_err(|error| error.to_string())? } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 17a15a4..4d7be6b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -158,6 +158,7 @@ pub fn run() { commands::move_note, commands::get_all_tags, commands::get_all_note_titles, + commands::get_note_switcher_titles, commands::get_graph_data, commands::get_tasks, commands::set_task_done, diff --git a/src-tauri/src/vault/operations.rs b/src-tauri/src/vault/operations.rs index 123e602..a6a8f66 100644 --- a/src-tauri/src/vault/operations.rs +++ b/src-tauri/src/vault/operations.rs @@ -1,10 +1,14 @@ -use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState}; +use crate::types::{ + NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TrashContents, + TrashNotebookEntry, VaultState, +}; use crate::vault::frontmatter; use chrono::{DateTime, Local, Locale, Utc}; use rayon::prelude::*; +use std::collections::HashSet; use std::fs; use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use uuid::Uuid; use walkdir::WalkDir; @@ -1279,6 +1283,59 @@ pub fn remove_quick_access(vault_path: &str, note_relative: &str) -> Result<(), Ok(()) } +const NOTE_SWITCHER_RECENT_LIMIT: usize = 6; + +pub fn get_note_switcher_titles( + vault_path: &str, + recent_paths: &[String], +) -> Result, String> { + let vault_root = Path::new(vault_path); + if !vault_root.is_dir() { + return Err("Vault path does not exist".to_string()); + } + + let mut seen = HashSet::new(); + let mut titles = Vec::with_capacity(NOTE_SWITCHER_RECENT_LIMIT); + + for requested_path in recent_paths { + if titles.len() >= NOTE_SWITCHER_RECENT_LIMIT { + break; + } + + let path = Path::new(requested_path); + let Ok(relative) = path.strip_prefix(vault_root) else { + continue; + }; + let safe_relative = !relative.as_os_str().is_empty() + && relative.components().all(|component| match component { + Component::Normal(name) => !is_hidden(Path::new(name)), + Component::CurDir => true, + _ => false, + }); + if !safe_relative + || path.extension().and_then(|extension| extension.to_str()) != Some("md") + || !path.is_file() + { + continue; + } + + let relative_path = relative.to_path_buf(); + if !seen.insert(relative_path.clone()) { + continue; + } + let Ok(entry) = read_note_entry_fast(path, vault_root) else { + continue; + }; + + titles.push(NoteTitleEntry { + title: entry.meta.title, + path: relative_path.to_string_lossy().replace('\\', "/"), + }); + } + + Ok(titles) +} + pub fn get_quick_access_notes(vault_path: &str) -> Result, String> { let list = load_quick_access(vault_path)?; let vault_root = Path::new(vault_path); @@ -1287,7 +1344,7 @@ pub fn get_quick_access_notes(vault_path: &str) -> Result, String for relative in &list { let full_path = vault_root.join(relative); if full_path.exists() { - if let Ok(entry) = read_note_entry(&full_path, vault_root) { + if let Ok(entry) = read_note_entry_fast(&full_path, vault_root) { notes.push(entry); } } @@ -1309,7 +1366,10 @@ pub fn sanitize_filename(name: &str) -> String { #[cfg(test)] mod tests { - use super::{duplicate_note, helixnotes_dir, load_notebook_icons, set_notebook_icon}; + use super::{ + duplicate_note, get_note_switcher_titles, helixnotes_dir, load_notebook_icons, + set_notebook_icon, + }; use std::fs; use uuid::Uuid; @@ -1333,6 +1393,70 @@ mod tests { fs::remove_dir_all(vault).unwrap(); } + #[test] + fn loads_only_requested_note_switcher_titles() { + let vault = + std::env::temp_dir().join(format!("helixnotes-note-switcher-test-{}", Uuid::new_v4())); + fs::create_dir_all(&vault).unwrap(); + + let mut note_paths = Vec::new(); + for index in 0..8 { + let path = vault.join(format!("Note {index}.md")); + fs::write( + &path, + format!("---\ntitle: Note {index}\n---\n\nNote {index} body.\n"), + ) + .unwrap(); + note_paths.push(path); + } + fs::write( + vault.join("Unrelated.md"), + "---\ntitle: Unrelated\n---\n\nMust not be loaded.\n", + ) + .unwrap(); + + let outside = + std::env::temp_dir().join(format!("helixnotes-outside-note-{}.md", Uuid::new_v4())); + fs::write(&outside, "---\ntitle: Outside\n---\n").unwrap(); + + let requested = vec![ + note_paths[0].to_string_lossy().into_owned(), + note_paths[0].to_string_lossy().into_owned(), + vault.join("Missing.md").to_string_lossy().into_owned(), + outside.to_string_lossy().into_owned(), + note_paths[1].to_string_lossy().into_owned(), + note_paths[2].to_string_lossy().into_owned(), + note_paths[3].to_string_lossy().into_owned(), + note_paths[4].to_string_lossy().into_owned(), + note_paths[5].to_string_lossy().into_owned(), + note_paths[6].to_string_lossy().into_owned(), + note_paths[7].to_string_lossy().into_owned(), + ]; + + let vault_path = vault.to_string_lossy(); + let titles = get_note_switcher_titles(&vault_path, &requested).unwrap(); + assert_eq!(titles.len(), 6); + assert_eq!( + titles + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(), + vec![ + "Note 0.md", + "Note 1.md", + "Note 2.md", + "Note 3.md", + "Note 4.md", + "Note 5.md", + ] + ); + assert!(titles.iter().all(|entry| entry.title != "Unrelated")); + assert!(titles.iter().all(|entry| entry.title != "Outside")); + + fs::remove_dir_all(vault).unwrap(); + fs::remove_file(outside).unwrap(); + } + #[test] fn duplicates_note_content_and_assigns_unique_identity_and_name() { let vault = diff --git a/src/lib/api.ts b/src/lib/api.ts index 5144214..a360daf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -179,6 +179,12 @@ export async function getAllNoteTitles(): Promise { return invoke("get_all_note_titles"); } +export async function getNoteSwitcherTitles( + recentPaths: string[], +): Promise { + return invoke("get_note_switcher_titles", { recentPaths }); +} + export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> { return invoke("get_graph_data"); } diff --git a/src/lib/components/NoteSwitcher.svelte b/src/lib/components/NoteSwitcher.svelte index 87dfb63..182d690 100644 --- a/src/lib/components/NoteSwitcher.svelte +++ b/src/lib/components/NoteSwitcher.svelte @@ -1,9 +1,10 @@ diff --git a/src/lib/utils/note-switcher.ts b/src/lib/utils/note-switcher.ts index 0bc3d2d..bab8fa2 100644 --- a/src/lib/utils/note-switcher.ts +++ b/src/lib/utils/note-switcher.ts @@ -28,6 +28,27 @@ function pathKey(path: string): string { return path.replace(/\\/g, '/'); } +export function buildNoteSwitcherRequestPaths( + currentPath: string | null, + historyPaths: readonly string[] +): string[] { + const paths: string[] = []; + const seen = new Set(); + const addPath = (path: string | null) => { + if (!path) return; + const key = pathKey(path); + if (seen.has(key)) return; + seen.add(key); + paths.push(path); + }; + + addPath(currentPath); + for (let index = historyPaths.length - 1; index >= 0; index -= 1) { + addPath(historyPaths[index]); + } + return paths; +} + function folderLabel(relativePath: string): string { const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean); parts.pop(); @@ -66,9 +87,9 @@ export function buildNoteSwitcherSections({ recent.push(toRow(note, currentPathKey)); }; - addRecent(currentPath); - for (let index = historyPaths.length - 1; index >= 0 && recent.length < limit; index -= 1) { - addRecent(historyPaths[index]); + for (const path of buildNoteSwitcherRequestPaths(currentPath, historyPaths)) { + if (recent.length >= limit) break; + addRecent(path); } const quickAccess: NoteSwitcherRow[] = [];