feat(core): Add Quicklist function to return all visible via ctrl+Q.
Some checks failed
CI / Check (macos-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Clippy (strict) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
2026-03-14 01:49:24 -04:00
parent a39e511cc4
commit 2729e7e1d2
7 changed files with 283 additions and 10 deletions

View File

@@ -38,6 +38,7 @@ pub enum Action {
HalfPageDown(usize),
SetMode(Mode),
Confirm,
Quicklist,
Cancel,
Resize { height: u16 },
AddItems(Vec<Value>),
@@ -53,6 +54,7 @@ pub enum Action {
pub enum MenuEvent {
StateChanged(ViewState),
Selected(Value),
Quicklist(Vec<Value>),
Cancelled,
}
@@ -89,5 +91,6 @@ pub struct VisibleItem {
#[derive(Debug)]
pub enum MenuResult {
Selected { value: Value, index: usize },
Quicklist { items: Vec<(Value, usize)> },
Cancelled,
}

View File

@@ -11,6 +11,7 @@ use serde_json::Value;
#[serde(rename_all = "snake_case")]
pub enum OutputAction {
Select,
Quicklist,
Cancel,
}
@@ -94,6 +95,18 @@ mod tests {
assert_eq!(json["action"], "cancel");
}
#[test]
fn output_item_quicklist_action() {
let item = OutputItem {
value: json!("test"),
action: OutputAction::Quicklist,
index: 2,
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["action"], "quicklist");
assert_eq!(json["index"], 2);
}
#[test]
fn output_item_string_contains_value_text() {
let item = OutputItem {

View File

@@ -59,4 +59,17 @@ pub trait Menu: Send + 'static {
fn formatted_label(&self, _filtered_index: usize) -> Option<String> {
None
}
/// Collect all filtered items as (value, original_index) pairs.
/// Default implementation iterates filtered results using
/// existing trait methods.
fn collect_filtered(&self) -> Vec<(&serde_json::Value, usize)> {
(0..self.filtered_count())
.filter_map(|i| {
let value = self.serialize_filtered(i)?;
let orig = self.original_index(i)?;
Some((value, orig))
})
.collect()
}
}

View File

@@ -20,6 +20,7 @@ pub enum HookEvent {
Select { item: Value, index: usize },
Cancel,
Filter { text: String },
Quicklist { items: Vec<Value>, count: usize },
}
/// Discriminant for [`HookEvent`], used as a key for
@@ -32,6 +33,7 @@ pub enum HookEventKind {
Select,
Cancel,
Filter,
Quicklist,
}
impl HookEvent {
@@ -44,6 +46,7 @@ impl HookEvent {
HookEvent::Select { .. } => HookEventKind::Select,
HookEvent::Cancel => HookEventKind::Cancel,
HookEvent::Filter { .. } => HookEventKind::Filter,
HookEvent::Quicklist { .. } => HookEventKind::Quicklist,
}
}
}
@@ -286,6 +289,27 @@ mod tests {
assert!(resp.is_none());
}
#[test]
fn event_quicklist_serializes() {
let event = HookEvent::Quicklist {
items: vec![json!("alpha"), json!("beta")],
count: 2,
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "quicklist");
assert_eq!(json["count"], 2);
assert_eq!(json["items"].as_array().map(|a| a.len()), Some(2));
}
#[test]
fn event_kind_quicklist() {
let event = HookEvent::Quicklist {
items: vec![],
count: 0,
};
assert_eq!(event.kind(), HookEventKind::Quicklist);
}
// -- Roundtrip: HookEvent serialize -> check shape --
#[test]

View File

@@ -27,6 +27,8 @@ pub enum ActionOutcome {
Cancelled,
/// Menu closed by hook command.
Closed,
/// User quicklisted the filtered items.
Quicklist { items: Vec<(Value, usize)> },
/// Nothing happened (e.g. confirm on empty list).
NoOp,
}
@@ -218,6 +220,18 @@ impl<M: Menu> MenuRunner<M> {
None => ActionOutcome::NoOp,
}
}
Action::Quicklist => {
if self.menu.filtered_count() == 0 {
return ActionOutcome::NoOp;
}
let items: Vec<(Value, usize)> = self
.menu
.collect_filtered()
.into_iter()
.map(|(v, idx)| (v.clone(), idx))
.collect();
ActionOutcome::Quicklist { items }
}
Action::Cancel => ActionOutcome::Cancelled,
Action::Resize { height } => {
self.viewport.set_height(height as usize);
@@ -333,6 +347,21 @@ impl<M: Menu> MenuRunner<M> {
let _ = self.event_tx.send(MenuEvent::Selected(value.clone()));
return Ok(MenuResult::Selected { value, index });
}
ActionOutcome::Quicklist { items } => {
let values: Vec<Value> =
items.iter().map(|(v, _)| v.clone()).collect();
let count = values.len();
self.emit_hook(HookEvent::Quicklist {
items: values.clone(),
count,
});
self.emit_hook(HookEvent::Close);
let _ = self
.event_tx
.send(MenuEvent::Quicklist(values));
return Ok(MenuResult::Quicklist { items });
}
ActionOutcome::Cancelled => {
self.emit_hook(HookEvent::Cancel);
self.emit_hook(HookEvent::Close);
@@ -945,6 +974,127 @@ mod tests {
assert!(matches!(outcome, ActionOutcome::Selected { index: 3, .. }));
}
// -- Quicklist tests --
#[test]
fn apply_quicklist_returns_all_filtered() {
let mut m = ready_menu();
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { items } => {
assert_eq!(items.len(), 4);
let labels: Vec<&str> = items
.iter()
.filter_map(|(v, _)| v.as_str())
.collect();
assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]);
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
#[test]
fn apply_quicklist_respects_filter() {
let mut m = ready_menu();
m.apply_action(Action::UpdateFilter("a".to_string()));
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { items } => {
// "alpha", "gamma", "delta" all contain 'a'
assert!(items.len() >= 2);
for (v, _) in &items {
let label = v.as_str().unwrap_or("");
assert!(
label.contains('a'),
"filtered item should contain 'a': {label}"
);
}
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
#[test]
fn apply_quicklist_on_empty_is_noop() {
let mut m = ready_menu();
m.apply_action(Action::UpdateFilter("zzzzz".to_string()));
assert_eq!(m.menu.filtered_count(), 0);
let outcome = m.apply_action(Action::Quicklist);
assert!(matches!(outcome, ActionOutcome::NoOp));
}
#[test]
fn apply_quicklist_preserves_original_indices() {
let mut m = ready_menu();
// Filter to "del" -> only "delta" at original index 3
m.apply_action(Action::UpdateFilter("del".to_string()));
let outcome = m.apply_action(Action::Quicklist);
match outcome {
ActionOutcome::Quicklist { items } => {
assert_eq!(items.len(), 1);
assert_eq!(items[0].0.as_str(), Some("delta"));
assert_eq!(items[0].1, 3);
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
#[tokio::test]
async fn quicklist_event_broadcast() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
// Skip initial state
let _ = rx.recv().await;
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
let _ = tx.send(Action::Quicklist).await;
if let Ok(MenuEvent::Quicklist(values)) = rx.recv().await {
assert_eq!(values.len(), 4);
} else {
panic!("expected Quicklist event");
}
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Quicklist { .. })));
}
#[tokio::test]
async fn quicklist_after_filter() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 10 }).await;
let _ = rx.recv().await;
// Filter then quicklist back-to-back
let _ = tx.send(Action::UpdateFilter("al".to_string())).await;
let _ = tx.send(Action::Quicklist).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
match result {
Ok(MenuResult::Quicklist { items }) => {
// "alpha" matches "al"
assert!(!items.is_empty());
for (v, _) in &items {
let label = v.as_str().unwrap_or("");
assert!(
label.contains("al"),
"quicklist item should match filter: {label}"
);
}
}
other => panic!("expected Quicklist, got {other:?}"),
}
}
// -- Hook event tests --
#[tokio::test]