From 9bd6ad89325f7eeacac40fd0522eb6c8f9013e6c Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:23:36 -0500 Subject: [PATCH 1/4] Add note link resolution, backlinks UI, and notes graph widget --- src/dashboard/widgets/mod.rs | 7 + src/dashboard/widgets/notes_graph.rs | 145 +++++++++++++ src/gui/note_panel.rs | 90 ++++++-- src/plugins/note.rs | 308 ++++++++++++++++++++++++--- 4 files changed, 504 insertions(+), 46 deletions(-) create mode 100644 src/dashboard/widgets/notes_graph.rs diff --git a/src/dashboard/widgets/mod.rs b/src/dashboard/widgets/mod.rs index cb8acfea..f4823e28 100644 --- a/src/dashboard/widgets/mod.rs +++ b/src/dashboard/widgets/mod.rs @@ -21,6 +21,7 @@ mod gesture_cheat_sheet; mod gesture_health; mod gesture_recent; mod layouts; +mod notes_graph; mod notes_recent; mod notes_tags; mod now_playing; @@ -59,6 +60,7 @@ pub use gesture_cheat_sheet::GestureCheatSheetWidget; pub use gesture_health::GestureHealthWidget; pub use gesture_recent::GestureRecentWidget; pub use layouts::LayoutsWidget; +pub use notes_graph::NotesGraphWidget; pub use notes_recent::NotesRecentWidget; pub use notes_tags::NotesTagsWidget; pub use now_playing::NowPlayingWidget; @@ -363,6 +365,11 @@ impl WidgetRegistry { "notes_tags", WidgetFactory::new(NotesTagsWidget::new).with_settings_ui(NotesTagsWidget::settings_ui), ); + reg.register( + "notes_graph", + WidgetFactory::new(NotesGraphWidget::new) + .with_settings_ui(NotesGraphWidget::settings_ui), + ); reg.register( "todo_focus", WidgetFactory::new(TodoFocusWidget::new).with_settings_ui(TodoFocusWidget::settings_ui), diff --git a/src/dashboard/widgets/notes_graph.rs b/src/dashboard/widgets/notes_graph.rs new file mode 100644 index 00000000..409a9685 --- /dev/null +++ b/src/dashboard/widgets/notes_graph.rs @@ -0,0 +1,145 @@ +use super::{ + edit_typed_settings, Widget, WidgetAction, WidgetSettingsContext, WidgetSettingsUiResult, +}; +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use crate::plugins::note::note_relationship_edges; +use eframe::egui::{self, Color32, Pos2, Stroke}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +fn default_max_nodes() -> usize { + 16 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotesGraphConfig { + #[serde(default = "default_max_nodes")] + pub max_nodes: usize, +} + +impl Default for NotesGraphConfig { + fn default() -> Self { + Self { + max_nodes: default_max_nodes(), + } + } +} + +pub struct NotesGraphWidget { + cfg: NotesGraphConfig, +} + +impl NotesGraphWidget { + pub fn new(cfg: NotesGraphConfig) -> Self { + Self { cfg } + } + + pub fn settings_ui( + ui: &mut egui::Ui, + value: &mut serde_json::Value, + ctx: &WidgetSettingsContext<'_>, + ) -> WidgetSettingsUiResult { + edit_typed_settings(ui, value, ctx, |ui, cfg: &mut NotesGraphConfig, _ctx| { + ui.horizontal(|ui| { + ui.label("Max nodes"); + ui.add(egui::DragValue::new(&mut cfg.max_nodes).clamp_range(4..=64)) + .changed() + }) + .inner + }) + } +} + +impl Default for NotesGraphWidget { + fn default() -> Self { + Self::new(NotesGraphConfig::default()) + } +} + +impl Widget for NotesGraphWidget { + fn render( + &mut self, + ui: &mut egui::Ui, + ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let edges = note_relationship_edges(); + let mut nodes: BTreeSet = BTreeSet::new(); + for (a, b) in &edges { + let _ = nodes.insert(a.clone()); + let _ = nodes.insert(b.clone()); + } + if nodes.is_empty() { + ui.label("No note links yet."); + return None; + } + let node_slugs: Vec = nodes.into_iter().take(self.cfg.max_nodes).collect(); + let n = node_slugs.len().max(1); + + let desired = egui::vec2(ui.available_width().max(180.0), 180.0); + let (rect, _) = ui.allocate_exact_size(desired, egui::Sense::hover()); + let painter = ui.painter_at(rect); + let center = rect.center(); + let radius = rect.width().min(rect.height()) * 0.35; + + let mut pos = std::collections::HashMap::new(); + for (i, slug) in node_slugs.iter().enumerate() { + let theta = (i as f32 / n as f32) * std::f32::consts::TAU; + let p = Pos2::new( + center.x + radius * theta.cos(), + center.y + radius * theta.sin(), + ); + pos.insert(slug.clone(), p); + } + + for (from, to) in &edges { + if let (Some(a), Some(b)) = (pos.get(from), pos.get(to)) { + painter.line_segment([*a, *b], Stroke::new(1.0, Color32::LIGHT_BLUE)); + } + } + + for slug in &node_slugs { + if let Some(p) = pos.get(slug) { + painter.circle_filled(*p, 6.0, Color32::from_rgb(90, 170, 120)); + } + } + + let mut clicked = None; + ui.separator(); + for slug in &node_slugs { + if ui.link(slug).clicked() { + clicked = Some(WidgetAction { + action: Action { + label: format!("Open {slug}"), + desc: "Note".into(), + action: format!("note:open:{slug}"), + args: None, + }, + query_override: Some(format!("note open {slug}")), + }); + } + } + + if clicked.is_none() { + let snapshot = ctx.data_cache.snapshot(); + let names: Vec<_> = snapshot + .notes + .iter() + .take(3) + .map(|n| n.title.clone()) + .collect(); + if !names.is_empty() { + ui.label(format!("Examples: {}", names.join(", "))); + } + } + + clicked + } + + fn on_config_updated(&mut self, settings: &serde_json::Value) { + if let Ok(cfg) = serde_json::from_value::(settings.clone()) { + self.cfg = cfg; + } + } +} diff --git a/src/gui/note_panel.rs b/src/gui/note_panel.rs index dc70854c..87583e1c 100644 --- a/src/gui/note_panel.rs +++ b/src/gui/note_panel.rs @@ -3,8 +3,8 @@ use crate::common::slug::slugify; use crate::gui::LauncherApp; use crate::plugin::Plugin; use crate::plugins::note::{ - assets_dir, available_tags, image_files, load_notes, save_note, Note, NoteExternalOpen, - NotePlugin, + assets_dir, available_tags, image_files, load_notes, note_backlinks, resolve_note_query, + save_note, Note, NoteExternalOpen, NotePlugin, NoteTarget, }; use eframe::egui::{self, popup, Color32, FontId, Key}; use egui_commonmark::{CommonMarkCache, CommonMarkViewer}; @@ -61,7 +61,8 @@ fn preprocess_note_links(content: &str, current_slug: &str) -> String { WIKI_RE .replace_all(content, |caps: ®ex::Captures| { let text = &caps[1]; - let slug = slugify(text); + let target = text.split('|').next().unwrap_or(text).trim(); + let slug = slugify(target); if slug == current_slug { caps[0].to_string() } else { @@ -338,6 +339,38 @@ impl NotePanel { } }); } + let backlinks = note_backlinks(&self.note.slug); + if !backlinks.is_empty() { + let was_focused = self + .last_textedit_id + .map(|id| ui.ctx().memory(|m| m.has_focus(id))) + .unwrap_or(false); + ui.horizontal_wrapped(|ui| { + ui.label("Backlinks:"); + let threshold = app.note_more_limit; + let total = backlinks.len(); + let show_all = self.links_expanded || total <= threshold; + let limit = if show_all { total } else { threshold }; + for linked_note in backlinks.iter().take(limit) { + if ui.link(format!("[[{}]]", linked_note.title)).clicked() { + app.open_note_panel(&linked_note.slug, None); + } + } + if total > threshold { + let label = if self.links_expanded { + "collapse" + } else { + "... (more)" + }; + if ui.button(label).clicked() { + self.links_expanded = !self.links_expanded; + if was_focused { + self.focus_textedit_next_frame = true; + } + } + } + }); + } ui.separator(); let remaining = ui.available_height(); let resp = egui::ScrollArea::vertical() @@ -1129,26 +1162,43 @@ pub fn spawn_external(path: &Path, choice: NoteExternalOpen) -> std::io::Result< } pub fn show_wiki_link(ui: &mut egui::Ui, app: &mut LauncherApp, l: &str) -> egui::Response { - // Display wiki style links with brackets and allow clicking to - // navigate to the referenced note. Missing targets are colored red. - let slug = slugify(l); - let exists = load_notes() - .ok() - .map(|notes| notes.iter().any(|n| n.slug == slug)) - .unwrap_or(false); let text = format!("[[{l}]]"); - let resp = if exists { - ui.link(text) - } else { - ui.add( - egui::Label::new(egui::RichText::new(text).color(Color32::RED)) + let target = l.split('|').next().unwrap_or(l).trim(); + match resolve_note_query(target) { + NoteTarget::Resolved(slug) => { + let resp = ui.link(text); + if resp.clicked() { + app.open_note_panel(&slug, None); + } + resp + } + NoteTarget::Ambiguous(slugs) => { + let label = format!("{text} (ambiguous)"); + let resp = ui.add( + egui::Label::new(egui::RichText::new(label).color(Color32::YELLOW)) + .sense(egui::Sense::click()), + ); + if resp.clicked() { + app.set_error(format!( + "Ambiguous link [[{target}]]; use [[slug:]] or [[path:]]. Candidates: {}", + slugs.join(", ") + )); + } + resp + } + NoteTarget::Broken => { + let resp = ui.add( + egui::Label::new( + egui::RichText::new(format!("{text} (missing)")).color(Color32::RED), + ) .sense(egui::Sense::click()), - ) - }; - if resp.clicked() { - app.open_note_panel(&slug, None); + ); + if resp.clicked() { + app.set_error(format!("Broken note link: [[{target}]]")); + } + resp + } } - resp } fn insert_tag_menu( diff --git a/src/plugins/note.rs b/src/plugins/note.rs index 68b1a1f2..603559f0 100644 --- a/src/plugins/note.rs +++ b/src/plugins/note.rs @@ -49,6 +49,20 @@ pub struct Note { pub alias: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NoteTarget { + Resolved(String), + Broken, + Ambiguous(Vec), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WikiReference { + pub raw: String, + pub target: String, + pub resolved: NoteTarget, +} + #[derive(Default)] pub struct NoteCache { /// All loaded notes. @@ -76,19 +90,40 @@ impl NoteCache { } else { n.tags = n.tags.iter().map(|t| t.to_lowercase()).collect(); } - let slug = n.slug.clone(); + if let Some(a) = &n.alias { + alias_map.insert(a.to_lowercase(), n.slug.clone()); + } for t in &n.tags { tag_set.insert(t.clone()); } - for l in &n.links { - let entry = link_map.entry(l.clone()).or_default(); - if !entry.contains(&slug) { - entry.push(slug.clone()); + } + + let resolver = NoteCache { + notes: notes.clone(), + tags: Vec::new(), + links: HashMap::new(), + index: Vec::new(), + aliases: alias_map.clone(), + }; + + for n in &mut notes { + let mut resolved: Vec = resolve_wiki_references(&resolver, &n.content) + .into_iter() + .filter_map(|r| match r.resolved { + NoteTarget::Resolved(slug) if slug != n.slug => Some(slug), + _ => None, + }) + .collect(); + resolved.sort(); + resolved.dedup(); + n.links = resolved; + + for target_slug in &n.links { + let entry = link_map.entry(target_slug.clone()).or_default(); + if !entry.contains(&n.slug) { + entry.push(n.slug.clone()); } } - if let Some(a) = &n.alias { - alias_map.insert(a.to_lowercase(), slug.clone()); - } } let mut tags: Vec = tag_set.into_iter().collect(); @@ -163,31 +198,175 @@ fn extract_links(content: &str) -> Vec { links } -fn resolve_note<'a>(cache: &'a NoteCache, query: &str) -> Option<&'a Note> { +fn parse_wiki_references(content: &str) -> Vec { + WIKI_RE + .captures_iter(content) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().trim().to_string())) + .filter(|s| !s.is_empty()) + .collect() +} + +fn target_from_reference(raw: &str) -> &str { + raw.split('|').next().unwrap_or(raw).trim() +} + +fn path_matches_note(path_query: &str, note: &Note) -> bool { + let q = path_query.trim().trim_start_matches("./").to_lowercase(); + if q.is_empty() { + return false; + } + let file_name = note + .path + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()) + .unwrap_or_default(); + let full = note.path.to_string_lossy().to_lowercase(); + file_name == q || full.ends_with(&q) +} + +fn resolve_target(cache: &NoteCache, query: &str) -> NoteTarget { let query = query.trim(); if query.is_empty() { - return None; + return NoteTarget::Broken; } let query_lower = query.to_lowercase(); if let Some(slug) = cache.aliases.get(&query_lower) { - return cache.notes.iter().find(|n| n.slug == *slug); + return NoteTarget::Resolved(slug.clone()); + } + if let Some(slug) = query_lower.strip_prefix("slug:") { + let slug = slug.trim(); + return if cache.notes.iter().any(|n| n.slug == slug) { + NoteTarget::Resolved(slug.to_string()) + } else { + NoteTarget::Broken + }; + } + if let Some(path) = query_lower.strip_prefix("path:") { + let mut matches: Vec = cache + .notes + .iter() + .filter(|n| path_matches_note(path, n)) + .map(|n| n.slug.clone()) + .collect(); + matches.sort(); + matches.dedup(); + return match matches.len() { + 0 => NoteTarget::Broken, + 1 => NoteTarget::Resolved(matches.remove(0)), + _ => NoteTarget::Ambiguous(matches), + }; } if let Some(note) = cache .notes .iter() - .find(|n| n.title.eq_ignore_ascii_case(query)) + .find(|n| n.slug.eq_ignore_ascii_case(query)) { - return Some(note); + return NoteTarget::Resolved(note.slug.clone()); } - if let Some(note) = cache + + let title_matches: Vec = cache .notes .iter() - .find(|n| n.slug.eq_ignore_ascii_case(query)) - { - return Some(note); + .filter(|n| n.title.eq_ignore_ascii_case(query)) + .map(|n| n.slug.clone()) + .collect(); + if title_matches.len() == 1 { + return NoteTarget::Resolved(title_matches[0].clone()); + } + if !title_matches.is_empty() { + return NoteTarget::Ambiguous(title_matches); } let slug = slugify(query); - cache.notes.iter().find(|n| n.slug == slug) + if cache.notes.iter().any(|n| n.slug == slug) { + NoteTarget::Resolved(slug) + } else { + NoteTarget::Broken + } +} + +fn resolve_wiki_references(cache: &NoteCache, content: &str) -> Vec { + let mut refs = Vec::new(); + for raw in parse_wiki_references(content) { + let target = target_from_reference(&raw).to_string(); + refs.push(WikiReference { + raw, + resolved: resolve_target(cache, &target), + target, + }); + } + refs +} + +fn resolve_note<'a>(cache: &'a NoteCache, query: &str) -> Option<&'a Note> { + let query = query.trim(); + if query.is_empty() { + return None; + } + let query_lower = query.to_lowercase(); + if let Some(slug) = cache.aliases.get(&query_lower) { + return cache.notes.iter().find(|n| n.slug == *slug); + } + match resolve_target(cache, query) { + NoteTarget::Resolved(slug) => cache.notes.iter().find(|n| n.slug == slug), + NoteTarget::Ambiguous(slugs) => slugs + .first() + .and_then(|slug| cache.notes.iter().find(|n| n.slug == *slug)), + NoteTarget::Broken => None, + } +} + +pub fn resolve_note_query(query: &str) -> NoteTarget { + CACHE + .lock() + .map(|cache| resolve_target(&cache, query)) + .unwrap_or(NoteTarget::Broken) +} + +pub fn note_backlinks(slug: &str) -> Vec { + CACHE + .lock() + .ok() + .map(|cache| { + cache + .links + .get(slug) + .into_iter() + .flat_map(|v| v.iter()) + .filter_map(|s| cache.notes.iter().find(|n| n.slug == *s).cloned()) + .collect() + }) + .unwrap_or_default() +} + +pub fn note_refs_for(slug: &str) -> Vec { + CACHE + .lock() + .ok() + .and_then(|cache| { + cache + .notes + .iter() + .find(|n| n.slug == slug) + .map(|n| resolve_wiki_references(&cache, &n.content)) + }) + .unwrap_or_default() +} + +pub fn note_relationship_edges() -> Vec<(String, String)> { + CACHE + .lock() + .ok() + .map(|cache| { + let mut edges = Vec::new(); + for n in &cache.notes { + for to in &n.links { + edges.push((n.slug.clone(), to.clone())); + } + } + edges + }) + .unwrap_or_default() } pub fn extract_alias(content: &str) -> Option { @@ -732,9 +911,10 @@ impl Plugin for NotePlugin { let tag_ok = if filters.include_tags.is_empty() { true } else { - filters.include_tags.iter().all(|tag| { - n.tags.iter().any(|t| t.contains(tag)) - }) + filters + .include_tags + .iter() + .all(|tag| n.tags.iter().any(|t| t.contains(tag))) }; let exclude_ok = !filters .exclude_tags @@ -785,7 +965,10 @@ impl Plugin for NotePlugin { if let Some(stripped) = filter.strip_prefix("tag:") { filter = stripped.trim(); } - if let Some(stripped) = filter.strip_prefix('#').or_else(|| filter.strip_prefix('@')) { + if let Some(stripped) = filter + .strip_prefix('#') + .or_else(|| filter.strip_prefix('@')) + { filter = stripped.trim(); } @@ -852,9 +1035,15 @@ impl Plugin for NotePlugin { args: None, }]; actions.extend(guard.notes.iter().map(|n| Action { - label: format!("Backlinks for {}", n.alias.as_ref().unwrap_or(&n.title)), + label: format!( + "Backlinks for {}", + n.alias.as_ref().unwrap_or(&n.title) + ), desc: "Backlinks".into(), - action: format!("query:note link {}", n.alias.as_ref().unwrap_or(&n.title)), + action: format!( + "query:note link {}", + n.alias.as_ref().unwrap_or(&n.title) + ), args: None, })); return actions; @@ -1174,8 +1363,7 @@ mod tests { assert_eq!(labels_both, vec!["Alpha"]); let list_both_hash = plugin.search("note list #testing #ui"); - let labels_both_hash: Vec<&str> = - list_both_hash.iter().map(|a| a.label.as_str()).collect(); + let labels_both_hash: Vec<&str> = list_both_hash.iter().map(|a| a.label.as_str()).collect(); assert_eq!(labels_both_hash, vec!["Alpha"]); restore_cache(original); @@ -1444,6 +1632,74 @@ mod tests { assert_eq!(labels, vec!["Backlink: Alpha", "Backlink: Delta"]); assert_eq!(links[0].desc, "Backlinks to Beta Note"); + restore_cache(original); + } + #[test] + fn resolve_target_handles_duplicate_titles_with_slug_or_path() { + let original = set_notes(vec![ + Note { + title: "Roadmap".into(), + path: PathBuf::from("/tmp/alpha-roadmap.md"), + content: String::new(), + tags: Vec::new(), + links: Vec::new(), + slug: "roadmap-alpha".into(), + alias: None, + }, + Note { + title: "Roadmap".into(), + path: PathBuf::from("/tmp/beta-roadmap.md"), + content: String::new(), + tags: Vec::new(), + links: Vec::new(), + slug: "roadmap-beta".into(), + alias: None, + }, + ]); + + assert!(matches!( + resolve_note_query("Roadmap"), + NoteTarget::Ambiguous(_) + )); + assert_eq!( + resolve_note_query("slug:roadmap-beta"), + NoteTarget::Resolved("roadmap-beta".into()) + ); + assert_eq!( + resolve_note_query("path:beta-roadmap.md"), + NoteTarget::Resolved("roadmap-beta".into()) + ); + + restore_cache(original); + } + + #[test] + fn backlinks_index_uses_resolved_title_links() { + let original = set_notes(vec![ + Note { + title: "Main".into(), + path: PathBuf::new(), + content: "Link to [[Target]].".into(), + tags: Vec::new(), + links: Vec::new(), + slug: "main".into(), + alias: None, + }, + Note { + title: "Target".into(), + path: PathBuf::new(), + content: "target".into(), + tags: Vec::new(), + links: Vec::new(), + slug: "target".into(), + alias: None, + }, + ]); + + let backlinks = note_backlinks("target"); + assert_eq!(backlinks.len(), 1); + assert_eq!(backlinks[0].slug, "main"); + restore_cache(original); } } From b0d3b8ec4bef88a23373d46a0ac033f7a79c2903 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:33:12 -0500 Subject: [PATCH 2/4] Fix broken wiki link clicks to open note panel --- src/gui/note_panel.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gui/note_panel.rs b/src/gui/note_panel.rs index 87583e1c..ea27a5f3 100644 --- a/src/gui/note_panel.rs +++ b/src/gui/note_panel.rs @@ -1187,6 +1187,7 @@ pub fn show_wiki_link(ui: &mut egui::Ui, app: &mut LauncherApp, l: &str) -> egui resp } NoteTarget::Broken => { + let slug = slugify(target); let resp = ui.add( egui::Label::new( egui::RichText::new(format!("{text} (missing)")).color(Color32::RED), @@ -1195,6 +1196,7 @@ pub fn show_wiki_link(ui: &mut egui::Ui, app: &mut LauncherApp, l: &str) -> egui ); if resp.clicked() { app.set_error(format!("Broken note link: [[{target}]]")); + app.open_note_panel(&slug, None); } resp } From 64540fffa47584337ee4c974e41bf10176f5d751 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:44:10 -0500 Subject: [PATCH 3/4] Clean up unused imports and dead-code warnings --- src/gui/note_panel.rs | 4 ++-- src/gui/screenshot_editor.rs | 1 - src/mouse_gestures/db.rs | 4 ++-- src/plugins/todo.rs | 36 +++++++++++++++++++----------------- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/gui/note_panel.rs b/src/gui/note_panel.rs index ea27a5f3..e798ee2c 100644 --- a/src/gui/note_panel.rs +++ b/src/gui/note_panel.rs @@ -3,8 +3,8 @@ use crate::common::slug::slugify; use crate::gui::LauncherApp; use crate::plugin::Plugin; use crate::plugins::note::{ - assets_dir, available_tags, image_files, load_notes, note_backlinks, resolve_note_query, - save_note, Note, NoteExternalOpen, NotePlugin, NoteTarget, + assets_dir, available_tags, image_files, note_backlinks, resolve_note_query, save_note, Note, + NoteExternalOpen, NotePlugin, NoteTarget, }; use eframe::egui::{self, popup, Color32, FontId, Key}; use egui_commonmark::{CommonMarkCache, CommonMarkViewer}; diff --git a/src/gui/screenshot_editor.rs b/src/gui/screenshot_editor.rs index edd8b361..df83dc9f 100644 --- a/src/gui/screenshot_editor.rs +++ b/src/gui/screenshot_editor.rs @@ -68,7 +68,6 @@ pub enum MarkupLayer { #[derive(Clone, Debug, Default)] pub struct MarkupHistory { layers: Vec, - undo_stack: Vec, redo_stack: Vec, } diff --git a/src/mouse_gestures/db.rs b/src/mouse_gestures/db.rs index d378036d..980d47c5 100644 --- a/src/mouse_gestures/db.rs +++ b/src/mouse_gestures/db.rs @@ -606,8 +606,8 @@ fn default_legacy_schema_version() -> u32 { #[derive(Debug, Clone, Deserialize)] struct LegacyGestureDb { - #[serde(default = "default_legacy_schema_version")] - schema_version: u32, + #[serde(default = "default_legacy_schema_version", rename = "schema_version")] + _schema_version: u32, #[serde(default)] gestures: Vec, } diff --git a/src/plugins/todo.rs b/src/plugins/todo.rs index 439f6c5c..b663e281 100644 --- a/src/plugins/todo.rs +++ b/src/plugins/todo.rs @@ -29,7 +29,6 @@ const TODO_USAGE: &str = "Usage: todo = - list_testing_ui_hash.iter().map(|a| a.label.as_str()).collect(); + let labels_testing_ui_hash: Vec<&str> = list_testing_ui_hash + .iter() + .map(|a| a.label.as_str()) + .collect(); assert_eq!(labels_testing_ui_hash, vec!["[ ] foo alpha"]); let list_negated = plugin.search_internal("todo list !foo @testing"); @@ -867,7 +865,12 @@ mod tests { let labels: Vec<&str> = tags.iter().map(|a| a.label.as_str()).collect(); assert_eq!( labels, - vec!["#testing (2)", "#ui (2)", "#chore (1)", "#high priority (1)"] + vec![ + "#testing (2)", + "#ui (2)", + "#chore (1)", + "#high priority (1)" + ] ); let actions: Vec<&str> = tags.iter().map(|a| a.action.as_str()).collect(); assert_eq!( @@ -927,5 +930,4 @@ mod tests { ); assert_eq!(actions_list[0], "todo:dialog"); } - } From b838f69194b4840f36ce7017e77da55cb48a4494 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:03:19 -0500 Subject: [PATCH 4/4] Prevent duplicate note panels when opening note links --- src/gui/mod.rs | 33 +++++++++++++++++++++++++++++++++ src/gui/note_panel.rs | 4 ++++ 2 files changed, 37 insertions(+) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 1cfda7b6..45350991 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -4640,6 +4640,17 @@ impl LauncherApp { alias, } }); + if let Some(existing_idx) = self + .note_panels + .iter() + .position(|panel| panel.note_slug() == note.slug) + { + let panel = self.note_panels.remove(existing_idx); + self.note_panels.push(panel); + self.update_panel_stack(); + return; + } + let word_count = note.content.split_whitespace().count(); if self.enable_toasts { push_toast( @@ -4960,6 +4971,28 @@ mod tests { ) } + #[test] + fn open_note_panel_reuses_existing_panel_for_same_slug() { + let _lock = TEST_MUTEX.lock().unwrap(); + let ctx = egui::Context::default(); + let mut app = new_app(&ctx); + let dir = tempdir().unwrap(); + let prev = std::env::var("ML_NOTES_DIR").ok(); + std::env::set_var("ML_NOTES_DIR", dir.path()); + + append_note("Second Note", "body").unwrap(); + app.open_note_panel("second-note", None); + app.open_note_panel("second-note", None); + + assert_eq!(app.note_panels.len(), 1); + + if let Some(prev) = prev { + std::env::set_var("ML_NOTES_DIR", prev); + } else { + std::env::remove_var("ML_NOTES_DIR"); + } + } + #[test] fn open_note_link_valid_and_invalid() { let ctx = egui::Context::default(); diff --git a/src/gui/note_panel.rs b/src/gui/note_panel.rs index e798ee2c..2afb9b09 100644 --- a/src/gui/note_panel.rs +++ b/src/gui/note_panel.rs @@ -140,6 +140,10 @@ impl NotePanel { } } + pub fn note_slug(&self) -> &str { + &self.note.slug + } + pub fn ui(&mut self, ctx: &egui::Context, app: &mut LauncherApp) { if !self.open { return;