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
93 changes: 64 additions & 29 deletions src/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,56 @@ mod tests {
ActionKind::PowerPlanSet { guid: "balanced" }
);
}

#[test]
fn parse_todo_add_payload_with_delimiters_and_whitespace() {
let payload = crate::plugins::todo::TodoAddActionPayload {
text: "ship | release, notes now".into(),
priority: 9,
tags: vec!["team|alpha,beta".into(), "has space".into()],
};
let encoded = crate::plugins::todo::encode_todo_add_action_payload(&payload)
.expect("encode todo add payload");
let action = Action {
label: String::new(),
desc: String::new(),
action: format!("todo:add:{encoded}"),
args: None,
};

assert_eq!(
parse_action_kind(&action),
ActionKind::TodoAdd {
text: "ship | release, notes now".into(),
priority: 9,
tags: vec!["team|alpha,beta".into(), "has space".into()],
}
);
}

#[test]
fn parse_todo_tag_payload_with_delimiters_and_whitespace() {
let payload = crate::plugins::todo::TodoTagActionPayload {
idx: 12,
tags: vec!["owner|dev,ops".into(), "needs review".into()],
};
let encoded = crate::plugins::todo::encode_todo_tag_action_payload(&payload)
.expect("encode todo tag payload");
let action = Action {
label: String::new(),
desc: String::new(),
action: format!("todo:tag:{encoded}"),
args: None,
};

assert_eq!(
parse_action_kind(&action),
ActionKind::TodoSetTags {
idx: 12,
tags: vec!["owner|dev,ops".into(), "needs review".into()],
}
);
}
}

pub(crate) fn mute_active_window() {
Expand Down Expand Up @@ -383,7 +433,7 @@ enum ActionKind<'a> {
},
StopwatchShow(u64),
TodoAdd {
text: &'a str,
text: String,
priority: u8,
tags: Vec<String>,
},
Expand Down Expand Up @@ -629,24 +679,13 @@ fn parse_action_kind(action: &Action) -> ActionKind<'_> {
}
}
if let Some(rest) = s.strip_prefix("todo:add:") {
let mut parts = rest.splitn(3, '|');
let text = parts.next().unwrap_or("");
let priority = parts.next().and_then(|p| p.parse::<u8>().ok()).unwrap_or(0);
let tags: Vec<String> = parts
.next()
.map(|t| {
if t.is_empty() {
Vec::new()
} else {
t.split(',').map(|s| s.to_string()).collect()
}
})
.unwrap_or_default();
return ActionKind::TodoAdd {
text,
priority,
tags,
};
if let Some(payload) = crate::plugins::todo::decode_todo_add_action_payload(rest) {
return ActionKind::TodoAdd {
text: payload.text,
priority: payload.priority,
tags: payload.tags,
};
}
}
if let Some(rest) = s.strip_prefix("todo:pset:") {
if let Some((idx, p)) = rest.split_once('|') {
Expand All @@ -659,15 +698,11 @@ fn parse_action_kind(action: &Action) -> ActionKind<'_> {
}
}
if let Some(rest) = s.strip_prefix("todo:tag:") {
if let Some((idx, tags_str)) = rest.split_once('|') {
if let Ok(i) = idx.parse::<usize>() {
let tags: Vec<String> = if tags_str.is_empty() {
Vec::new()
} else {
tags_str.split(',').map(|s| s.to_string()).collect()
};
return ActionKind::TodoSetTags { idx: i, tags };
}
if let Some(payload) = crate::plugins::todo::decode_todo_tag_action_payload(rest) {
return ActionKind::TodoSetTags {
idx: payload.idx,
tags: payload.tags,
};
}
}
if let Some(idx) = s.strip_prefix("todo:remove:") {
Expand Down Expand Up @@ -970,7 +1005,7 @@ pub fn launch_action(action: &Action) -> anyhow::Result<()> {
text,
priority,
tags,
} => todo::add(text, priority, &tags),
} => todo::add(&text, priority, &tags),
ActionKind::TodoSetPriority { idx, priority } => todo::set_priority(idx, priority),
ActionKind::TodoSetTags { idx, tags } => todo::set_tags(idx, &tags),
ActionKind::TodoRemove(i) => todo::remove(i),
Expand Down
101 changes: 97 additions & 4 deletions src/plugins/todo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ use crate::common::json_watch::{watch_json, JsonWatcher};
use crate::common::lru::LruCache;
use crate::common::query::parse_query_filters;
use crate::plugin::Plugin;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use once_cell::sync::Lazy;
Expand Down Expand Up @@ -52,6 +54,45 @@ pub struct TodoEntry {
pub tags: Vec<String>,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct TodoAddActionPayload {
pub text: String,
pub priority: u8,
pub tags: Vec<String>,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct TodoTagActionPayload {
pub idx: usize,
pub tags: Vec<String>,
}

fn encode_action_payload<T: Serialize>(payload: &T) -> Option<String> {
let json = serde_json::to_vec(payload).ok()?;
Some(URL_SAFE_NO_PAD.encode(json))
}

fn decode_action_payload<T: for<'de> Deserialize<'de>>(payload: &str) -> Option<T> {
let json = URL_SAFE_NO_PAD.decode(payload).ok()?;
serde_json::from_slice(&json).ok()
}

pub(crate) fn encode_todo_add_action_payload(payload: &TodoAddActionPayload) -> Option<String> {
encode_action_payload(payload)
}

pub(crate) fn decode_todo_add_action_payload(payload: &str) -> Option<TodoAddActionPayload> {
decode_action_payload(payload)
}

pub(crate) fn encode_todo_tag_action_payload(payload: &TodoTagActionPayload) -> Option<String> {
encode_action_payload(payload)
}

pub(crate) fn decode_todo_tag_action_payload(payload: &str) -> Option<TodoTagActionPayload> {
decode_action_payload(payload)
}

/// Shared in-memory todo cache kept in sync with `todo.json`.
/// Disk writes and the [`JsonWatcher`] ensure updates are visible immediately
/// to all plugin instances and tests.
Expand Down Expand Up @@ -407,7 +448,6 @@ impl TodoPlugin {
Some((text, priority, tags))
}) {
ParseArgsResult::Parsed((text, priority, tags)) => {
let tag_str = tags.join(",");
let mut label_suffix_parts: Vec<String> = Vec::new();
if !tags.is_empty() {
label_suffix_parts.push(format!("Tag: {}", tags.join(", ")));
Expand All @@ -420,10 +460,18 @@ impl TodoPlugin {
} else {
format!("Add todo {text} {}", label_suffix_parts.join("; "))
};
let payload = TodoAddActionPayload {
text,
priority,
tags,
};
let Some(encoded_payload) = encode_todo_add_action_payload(&payload) else {
return Vec::new();
};
return vec![Action {
label,
desc: "Todo".into(),
action: format!("todo:add:{text}|{priority}|{tag_str}"),
action: format!("todo:add:{encoded_payload}"),
args: None,
}];
}
Expand Down Expand Up @@ -473,11 +521,14 @@ impl TodoPlugin {
}
}
}
let tag_str = tags.join(",");
let payload = TodoTagActionPayload { idx, tags };
let Some(encoded_payload) = encode_todo_tag_action_payload(&payload) else {
return Vec::new();
};
return vec![Action {
label: format!("Set tags for todo {idx}"),
desc: "Todo".into(),
action: format!("todo:tag:{idx}|{tag_str}"),
action: format!("todo:tag:{encoded_payload}"),
args: None,
}];
}
Expand Down Expand Up @@ -930,4 +981,46 @@ mod tests {
);
assert_eq!(actions_list[0], "todo:dialog");
}

#[test]
fn todo_add_and_tag_actions_encode_payload_for_round_trip() {
let plugin = TodoPlugin {
matcher: SkimMatcherV2::default(),
data: TODO_DATA.clone(),
cache: TODO_CACHE.clone(),
watcher: None,
};

let add_actions =
plugin.search_internal("todo add finish|draft, now p=7 #core|team,ops #has space");
assert_eq!(add_actions.len(), 1);
let add_encoded = add_actions[0]
.action
.strip_prefix("todo:add:")
.expect("todo:add: prefix");
let add_payload = decode_todo_add_action_payload(add_encoded).expect("decode add payload");
assert_eq!(
add_payload,
TodoAddActionPayload {
text: "finish|draft, now space".into(),
priority: 7,
tags: vec!["core|team,ops".into(), "has".into()],
}
);

let tag_actions = plugin.search_internal("todo tag 4 #alpha|beta,gamma #needs space");
assert_eq!(tag_actions.len(), 1);
let tag_encoded = tag_actions[0]
.action
.strip_prefix("todo:tag:")
.expect("todo:tag: prefix");
let tag_payload = decode_todo_tag_action_payload(tag_encoded).expect("decode tag payload");
assert_eq!(
tag_payload,
TodoTagActionPayload {
idx: 4,
tags: vec!["alpha|beta,gamma".into(), "needs".into()],
}
);
}
}
67 changes: 56 additions & 11 deletions tests/todo_plugin.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use eframe::egui;
use multi_launcher::gui::{
todo_view_layout_sizes, todo_view_window_constraints, LauncherApp, TodoDialog, TodoViewDialog,
};
use multi_launcher::plugin::Plugin;
use multi_launcher::plugin::PluginManager;
use multi_launcher::plugins::todo::{
append_todo, load_todos, mark_done, remove_todo, set_priority, set_tags, TodoEntry, TodoPlugin,
TODO_FILE,
append_todo, load_todos, mark_done, remove_todo, set_priority, set_tags, TodoAddActionPayload,
TodoEntry, TodoPlugin, TodoTagActionPayload, TODO_FILE,
};
use multi_launcher::settings::Settings;
use once_cell::sync::Lazy;
Expand All @@ -15,6 +17,11 @@ use tempfile::tempdir;

static TEST_MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));

fn decode_payload<T: serde::de::DeserializeOwned>(encoded: &str) -> T {
let json = URL_SAFE_NO_PAD.decode(encoded).unwrap();
serde_json::from_slice(&json).unwrap()
}

fn new_app(ctx: &egui::Context) -> LauncherApp {
LauncherApp::new(
ctx,
Expand All @@ -40,7 +47,16 @@ fn search_add_returns_action() {
let plugin = TodoPlugin::default();
let results = plugin.search("todo add task ");
assert_eq!(results.len(), 1);
assert_eq!(results[0].action, "todo:add:task|0|");
let encoded = results[0].action.strip_prefix("todo:add:").unwrap();
let payload: TodoAddActionPayload = decode_payload(encoded);
assert_eq!(
payload,
TodoAddActionPayload {
text: "task".into(),
priority: 0,
tags: vec![],
}
);
assert_eq!(results[0].label, "Add todo task");
}

Expand All @@ -50,7 +66,16 @@ fn search_add_with_priority_and_tags() {
let plugin = TodoPlugin::default();
let results = plugin.search("todo add task p=3 #a #b");
assert_eq!(results.len(), 1);
assert_eq!(results[0].action, "todo:add:task|3|a,b");
let encoded = results[0].action.strip_prefix("todo:add:").unwrap();
let payload: TodoAddActionPayload = decode_payload(encoded);
assert_eq!(
payload,
TodoAddActionPayload {
text: "task".into(),
priority: 3,
tags: vec!["a".into(), "b".into()],
}
);
assert_eq!(results[0].label, "Add todo task Tag: a, b; priority: 3");
}

Expand All @@ -60,7 +85,16 @@ fn search_add_with_at_tags() {
let plugin = TodoPlugin::default();
let results = plugin.search("todo add task @a @b");
assert_eq!(results.len(), 1);
assert_eq!(results[0].action, "todo:add:task|0|a,b");
let encoded = results[0].action.strip_prefix("todo:add:").unwrap();
let payload: TodoAddActionPayload = decode_payload(encoded);
assert_eq!(
payload,
TodoAddActionPayload {
text: "task".into(),
priority: 0,
tags: vec!["a".into(), "b".into()],
}
);
assert_eq!(results[0].label, "Add todo task Tag: a, b");
}

Expand Down Expand Up @@ -235,7 +269,15 @@ fn search_pset_and_tag_actions() {
assert_eq!(res[0].action, "todo:pset:1|4");
let res = plugin.search("todo tag 2 #x #y");
assert_eq!(res.len(), 1);
assert_eq!(res[0].action, "todo:tag:2|x,y");
let encoded = res[0].action.strip_prefix("todo:tag:").unwrap();
let payload: TodoTagActionPayload = decode_payload(encoded);
assert_eq!(
payload,
TodoTagActionPayload {
idx: 2,
tags: vec!["x".into(), "y".into()],
}
);
}

#[test]
Expand Down Expand Up @@ -325,16 +367,19 @@ fn tag_command_without_filter_lists_all_tags() {

append_todo(TODO_FILE, "alpha task", 1, &["alpha".into(), "beta".into()]).unwrap();
append_todo(TODO_FILE, "beta task", 1, &["beta".into()]).unwrap();
append_todo(TODO_FILE, "gamma task", 1, &["gamma".into(), "alpha".into()]).unwrap();
append_todo(
TODO_FILE,
"gamma task",
1,
&["gamma".into(), "alpha".into()],
)
.unwrap();

let plugin = TodoPlugin::default();
let results = plugin.search("todo tag");
let labels: Vec<&str> = results.iter().map(|action| action.label.as_str()).collect();

assert_eq!(
labels,
vec!["#alpha (2)", "#beta (2)", "#gamma (1)"]
);
assert_eq!(labels, vec!["#alpha (2)", "#beta (2)", "#gamma (1)"]);
}
#[test]
fn search_view_opens_dialog() {
Expand Down