feat: Add support for tabular/csv data input.
This commit is contained in:
@@ -17,6 +17,7 @@ tracing = "0.1"
|
||||
nucleo-matcher = "0.3.1"
|
||||
fancy-regex = "0.14"
|
||||
indexmap = "2"
|
||||
csv = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] }
|
||||
|
||||
@@ -20,6 +20,9 @@ pub enum PiklError {
|
||||
#[error("channel closed")]
|
||||
ChannelClosed,
|
||||
|
||||
#[error("csv parse error: {0}")]
|
||||
CsvParse(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Script(#[from] crate::script::action_fd::ScriptError),
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod script;
|
||||
pub mod error;
|
||||
|
||||
// Re-export submodules at crate root so the public API stays flat.
|
||||
pub use model::column;
|
||||
pub use model::event;
|
||||
pub use model::item;
|
||||
pub use model::output;
|
||||
@@ -22,6 +23,7 @@ pub use query::navigation;
|
||||
pub use query::pipeline;
|
||||
pub use query::regex_filter;
|
||||
pub use query::strategy;
|
||||
pub use runtime::csv_input;
|
||||
pub use runtime::debounce;
|
||||
pub use runtime::hook;
|
||||
pub use runtime::input;
|
||||
|
||||
155
crates/pikl-core/src/model/column.rs
Normal file
155
crates/pikl-core/src/model/column.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
//! Column configuration for table mode. Defines which fields
|
||||
//! to display as columns and how to label them.
|
||||
|
||||
use crate::item::resolve_field_path;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A single column definition: which field to extract and
|
||||
/// what to call it in the header.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColumnDef {
|
||||
/// Dotted path into the item's JSON value (e.g. "meta.res").
|
||||
pub field: String,
|
||||
/// Display name for the column header. Defaults to the
|
||||
/// field path if no alias is given.
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// Column layout for table mode. Controls which fields are
|
||||
/// shown and in what order.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColumnConfig {
|
||||
pub columns: Vec<ColumnDef>,
|
||||
}
|
||||
|
||||
impl ColumnConfig {
|
||||
/// Parse a column spec string like "label,meta.res:Resolution,meta.size".
|
||||
/// Columns are comma-separated. Each column can have an alias
|
||||
/// after the first colon: "field:Alias".
|
||||
pub fn parse(spec: &str) -> Self {
|
||||
let columns = spec
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|part| {
|
||||
let part = part.trim();
|
||||
if let Some((field, alias)) = part.split_once(':') {
|
||||
ColumnDef {
|
||||
field: field.trim().to_string(),
|
||||
display_name: alias.trim().to_string(),
|
||||
}
|
||||
} else {
|
||||
ColumnDef {
|
||||
field: part.to_string(),
|
||||
display_name: part.to_string(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self { columns }
|
||||
}
|
||||
|
||||
/// Build a ColumnConfig from raw header names. Used when
|
||||
/// auto-generating columns from CSV headers.
|
||||
pub fn from_headers(headers: &[String]) -> Self {
|
||||
let columns = headers
|
||||
.iter()
|
||||
.map(|h| ColumnDef {
|
||||
field: h.clone(),
|
||||
display_name: h.clone(),
|
||||
})
|
||||
.collect();
|
||||
Self { columns }
|
||||
}
|
||||
|
||||
/// Resolve each column's value from an item's JSON value.
|
||||
/// Missing fields produce an empty string.
|
||||
pub fn resolve_values(&self, value: &Value) -> Vec<String> {
|
||||
self.columns
|
||||
.iter()
|
||||
.map(|col| {
|
||||
resolve_field_path(value, &col.field)
|
||||
.map(value_to_cell)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a JSON value to a cell string for display.
|
||||
fn value_to_cell(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_columns() {
|
||||
let config = ColumnConfig::parse("label,age,city");
|
||||
assert_eq!(config.columns.len(), 3);
|
||||
assert_eq!(config.columns[0].field, "label");
|
||||
assert_eq!(config.columns[0].display_name, "label");
|
||||
assert_eq!(config.columns[2].field, "city");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_alias() {
|
||||
let config = ColumnConfig::parse("label,meta.res:Resolution");
|
||||
assert_eq!(config.columns.len(), 2);
|
||||
assert_eq!(config.columns[1].field, "meta.res");
|
||||
assert_eq!(config.columns[1].display_name, "Resolution");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_string() {
|
||||
let config = ColumnConfig::parse("");
|
||||
assert!(config.columns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_trailing_comma() {
|
||||
let config = ColumnConfig::parse("label,age,");
|
||||
assert_eq!(config.columns.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_flat_values() {
|
||||
let config = ColumnConfig::parse("label,age");
|
||||
let value = json!({"label": "alice", "age": 30});
|
||||
let cells = config.resolve_values(&value);
|
||||
assert_eq!(cells, vec!["alice", "30"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_nested_values() {
|
||||
let config = ColumnConfig::parse("label,meta.res");
|
||||
let value = json!({"label": "test", "meta": {"res": "1080p"}});
|
||||
let cells = config.resolve_values(&value);
|
||||
assert_eq!(cells, vec!["test", "1080p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_missing_field() {
|
||||
let config = ColumnConfig::parse("label,nope");
|
||||
let value = json!({"label": "test"});
|
||||
let cells = config.resolve_values(&value);
|
||||
assert_eq!(cells, vec!["test", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_headers_builds_identity() {
|
||||
let headers = vec!["name".to_string(), "age".to_string()];
|
||||
let config = ColumnConfig::from_headers(&headers);
|
||||
assert_eq!(config.columns.len(), 2);
|
||||
assert_eq!(config.columns[0].field, "name");
|
||||
assert_eq!(config.columns[0].display_name, "name");
|
||||
}
|
||||
}
|
||||
@@ -81,12 +81,24 @@ pub struct ViewState {
|
||||
pub mode: Mode,
|
||||
pub selection_count: usize,
|
||||
pub multi_enabled: bool,
|
||||
/// Column headers for table mode. None when not in
|
||||
/// table mode.
|
||||
pub columns: Option<Vec<ColumnHeader>>,
|
||||
/// Monotonically increasing counter. Each call to
|
||||
/// `build_view_state()` bumps this, so frontends can
|
||||
/// detect duplicate broadcasts and skip redundant redraws.
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Column header with display name and computed width for
|
||||
/// table mode rendering.
|
||||
#[must_use]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColumnHeader {
|
||||
pub display_name: String,
|
||||
pub width: u16,
|
||||
}
|
||||
|
||||
/// A single item in the current viewport window. Has the
|
||||
/// display label pre-resolved and the position in the
|
||||
/// filtered list.
|
||||
@@ -97,6 +109,9 @@ pub struct VisibleItem {
|
||||
pub formatted_text: Option<String>,
|
||||
pub index: usize,
|
||||
pub selected: bool,
|
||||
/// Column cell values for table mode. None when not in
|
||||
/// table mode.
|
||||
pub column_values: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Final outcome of [`crate::menu::MenuRunner::run`]. The
|
||||
|
||||
@@ -225,28 +225,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn icon_from_object() {
|
||||
let item = Item::new(
|
||||
json!({"label": "Firefox", "icon": "firefox.png"}),
|
||||
"label",
|
||||
);
|
||||
let item = Item::new(json!({"label": "Firefox", "icon": "firefox.png"}), "label");
|
||||
assert_eq!(item.icon(), Some("firefox.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_from_object() {
|
||||
let item = Item::new(
|
||||
json!({"label": "Firefox", "group": "browsers"}),
|
||||
"label",
|
||||
);
|
||||
let item = Item::new(json!({"label": "Firefox", "group": "browsers"}), "label");
|
||||
assert_eq!(item.group(), Some("browsers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_from_object() {
|
||||
let item = Item::new(
|
||||
json!({"label": "test", "meta": {"res": 1080}}),
|
||||
"label",
|
||||
);
|
||||
let item = Item::new(json!({"label": "test", "meta": {"res": 1080}}), "label");
|
||||
let meta = item.meta();
|
||||
assert!(meta.is_some());
|
||||
assert_eq!(meta.and_then(|m| m.get("res")), Some(&json!(1080)));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod column;
|
||||
pub mod event;
|
||||
pub mod item;
|
||||
pub mod output;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! item value with action context and original index so
|
||||
//! hooks and downstream tools know what happened.
|
||||
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::Serialize;
|
||||
use serde::ser::SerializeMap;
|
||||
use serde_json::Value;
|
||||
|
||||
/// What the user did to produce this output.
|
||||
|
||||
@@ -66,6 +66,17 @@ pub trait Menu: Send + 'static {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the column values for a filtered item, if table
|
||||
/// mode is active. Returns None when no column config is set.
|
||||
fn column_values(&self, _filtered_index: usize) -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the column config, if one is set.
|
||||
fn column_config(&self) -> Option<&crate::model::column::ColumnConfig> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension of [`Menu`] with mutation methods. Required by
|
||||
|
||||
@@ -170,12 +170,7 @@ impl FilterPipeline {
|
||||
})
|
||||
}
|
||||
FilterKind::Field { path } => {
|
||||
Self::eval_field(
|
||||
stage,
|
||||
&input_indices,
|
||||
&self.item_values,
|
||||
path,
|
||||
)
|
||||
Self::eval_field(stage, &input_indices, &self.item_values, path)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ pub enum FilterKind {
|
||||
/// Field-specific filter: `meta.res:3840` matches
|
||||
/// items where the dotted path resolves to a value
|
||||
/// containing the query text.
|
||||
Field { path: String },
|
||||
Field {
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A parsed filter segment with its kind, inversion flag,
|
||||
@@ -86,7 +88,9 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
|
||||
&& let Some((path, value)) = try_parse_field_filter(rest)
|
||||
{
|
||||
return ParsedSegment {
|
||||
kind: FilterKind::Field { path: path.to_string() },
|
||||
kind: FilterKind::Field {
|
||||
path: path.to_string(),
|
||||
},
|
||||
inverse: true,
|
||||
query: value,
|
||||
};
|
||||
@@ -95,7 +99,9 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
|
||||
// Check for field filter: field.path:value
|
||||
if let Some((path, value)) = try_parse_field_filter(segment) {
|
||||
return ParsedSegment {
|
||||
kind: FilterKind::Field { path: path.to_string() },
|
||||
kind: FilterKind::Field {
|
||||
path: path.to_string(),
|
||||
},
|
||||
inverse: false,
|
||||
query: value,
|
||||
};
|
||||
@@ -150,7 +156,10 @@ fn try_parse_field_filter(segment: &str) -> Option<(&str, &str)> {
|
||||
return None;
|
||||
}
|
||||
// Path chars must be alphanumeric, underscore, or dot
|
||||
if !path.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
|
||||
if !path
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '_' || c == '.')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
208
crates/pikl-core/src/runtime/csv_input.rs
Normal file
208
crates/pikl-core/src/runtime/csv_input.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! CSV/TSV input parser. Reads delimited data into [`Item`]s
|
||||
//! with structured JSON values. First row is always headers.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::PiklError;
|
||||
use crate::item::Item;
|
||||
|
||||
/// Input format for stdin parsing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InputFormat {
|
||||
/// Auto-detect: try JSON lines first, fall back to plain text.
|
||||
Auto,
|
||||
/// Comma-separated values.
|
||||
Csv,
|
||||
/// Tab-separated values.
|
||||
Tsv,
|
||||
}
|
||||
|
||||
impl InputFormat {
|
||||
/// Parse from a CLI string. Returns None for unknown formats.
|
||||
pub fn from_str_opt(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"auto" => Some(Self::Auto),
|
||||
"csv" => Some(Self::Csv),
|
||||
"tsv" => Some(Self::Tsv),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn delimiter(self) -> u8 {
|
||||
match self {
|
||||
Self::Tsv => b'\t',
|
||||
_ => b',',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of parsing CSV/TSV input.
|
||||
pub struct CsvParseResult {
|
||||
pub items: Vec<Item>,
|
||||
pub headers: Vec<String>,
|
||||
}
|
||||
|
||||
/// Known top-level fields that get promoted out of meta.
|
||||
const KNOWN_FIELDS: &[&str] = &["label", "sublabel", "icon", "group"];
|
||||
|
||||
/// Read CSV or TSV from a reader, producing structured Items.
|
||||
///
|
||||
/// Each row becomes a JSON object. The `label_key` controls
|
||||
/// which column becomes the display label. If no column matches
|
||||
/// `label_key`, the first column is used as `label`.
|
||||
///
|
||||
/// Known fields (label, sublabel, icon, group) are promoted to
|
||||
/// the top level. Everything else goes into a `meta` object.
|
||||
pub fn read_csv_items_sync(
|
||||
reader: impl Read,
|
||||
format: InputFormat,
|
||||
label_key: &str,
|
||||
) -> Result<CsvParseResult, PiklError> {
|
||||
let mut csv_reader = csv::ReaderBuilder::new()
|
||||
.delimiter(format.delimiter())
|
||||
.has_headers(true)
|
||||
.from_reader(reader);
|
||||
|
||||
let raw_headers = csv_reader
|
||||
.headers()
|
||||
.map_err(|e| PiklError::CsvParse(e.to_string()))?
|
||||
.clone();
|
||||
|
||||
if raw_headers.is_empty() {
|
||||
return Err(PiklError::CsvParse("no headers found".to_string()));
|
||||
}
|
||||
|
||||
let headers: Vec<String> = raw_headers.iter().map(|h| h.to_string()).collect();
|
||||
|
||||
// Figure out which column is the label. If label_key matches
|
||||
// a header, use that. Otherwise first column becomes "label".
|
||||
let has_label_column = headers.iter().any(|h| h == label_key);
|
||||
|
||||
let mut items = Vec::new();
|
||||
for result in csv_reader.records() {
|
||||
let record = result.map_err(|e| PiklError::CsvParse(e.to_string()))?;
|
||||
let mut top = Map::new();
|
||||
let mut meta = Map::new();
|
||||
|
||||
for (i, field_value) in record.iter().enumerate() {
|
||||
let Some(header) = headers.get(i) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !has_label_column && i == 0 {
|
||||
// First column becomes label when no explicit label column exists
|
||||
top.insert("label".to_string(), Value::String(field_value.to_string()));
|
||||
// Also keep it in meta under its original header name
|
||||
meta.insert(header.clone(), Value::String(field_value.to_string()));
|
||||
} else if KNOWN_FIELDS.contains(&header.as_str()) {
|
||||
top.insert(header.clone(), Value::String(field_value.to_string()));
|
||||
} else {
|
||||
meta.insert(header.clone(), Value::String(field_value.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if !meta.is_empty() {
|
||||
top.insert("meta".to_string(), Value::Object(meta));
|
||||
}
|
||||
|
||||
let value = Value::Object(top);
|
||||
items.push(Item::new(value, label_key));
|
||||
}
|
||||
|
||||
Ok(CsvParseResult { items, headers })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn basic_csv() {
|
||||
let data = "name,age,city\nalice,30,toronto\nbob,25,vancouver\n";
|
||||
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||
assert_eq!(result.items.len(), 2);
|
||||
assert_eq!(result.headers, vec!["name", "age", "city"]);
|
||||
// First column becomes label when no "label" header
|
||||
assert_eq!(result.items[0].label(), "alice");
|
||||
assert_eq!(result.items[1].label(), "bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_with_label_header() {
|
||||
let data = "label,age\nalice,30\nbob,25\n";
|
||||
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||
assert_eq!(result.items[0].label(), "alice");
|
||||
// "age" should be in meta
|
||||
assert_eq!(result.items[0].field_value("meta.age"), Some(&json!("30")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsv_input() {
|
||||
let data = "name\tage\nalice\t30\nbob\t25\n";
|
||||
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Tsv, "label").unwrap();
|
||||
assert_eq!(result.items.len(), 2);
|
||||
assert_eq!(result.items[0].label(), "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_fields_promoted() {
|
||||
let data = "label,sublabel,icon,group,extra\nfoo,bar,ic.png,grp,val\n";
|
||||
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||
let item = &result.items[0];
|
||||
assert_eq!(item.label(), "foo");
|
||||
assert_eq!(item.sublabel(), Some("bar"));
|
||||
assert_eq!(item.icon(), Some("ic.png"));
|
||||
assert_eq!(item.group(), Some("grp"));
|
||||
assert_eq!(item.field_value("meta.extra"), Some(&json!("val")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_column_label_also_in_meta() {
|
||||
let data = "name,age\nalice,30\n";
|
||||
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||
// First column promoted to label
|
||||
assert_eq!(result.items[0].label(), "alice");
|
||||
// Original header name also in meta
|
||||
assert_eq!(
|
||||
result.items[0].field_value("meta.name"),
|
||||
Some(&json!("alice"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_fields() {
|
||||
let data = "name,bio\nalice,\"likes, commas\"\n";
|
||||
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||
assert_eq!(
|
||||
result.items[0].field_value("meta.bio"),
|
||||
Some(&json!("likes, commas"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_errors() {
|
||||
let data = "";
|
||||
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_only_no_rows() {
|
||||
let data = "name,age\n";
|
||||
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.headers, vec!["name", "age"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_format_from_str() {
|
||||
assert_eq!(InputFormat::from_str_opt("csv"), Some(InputFormat::Csv));
|
||||
assert_eq!(InputFormat::from_str_opt("tsv"), Some(InputFormat::Tsv));
|
||||
assert_eq!(InputFormat::from_str_opt("auto"), Some(InputFormat::Auto));
|
||||
assert_eq!(InputFormat::from_str_opt("xml"), None);
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,7 @@ pub struct DebouncedDispatcher {
|
||||
}
|
||||
|
||||
impl DebouncedDispatcher {
|
||||
pub fn new(
|
||||
handler: Arc<dyn HookHandler>,
|
||||
action_tx: mpsc::Sender<Action>,
|
||||
) -> Self {
|
||||
pub fn new(handler: Arc<dyn HookHandler>, action_tx: mpsc::Sender<Action>) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
_action_tx: action_tx,
|
||||
@@ -71,11 +68,7 @@ impl DebouncedDispatcher {
|
||||
/// Dispatch a hook event through the debounce system.
|
||||
pub fn dispatch(&mut self, event: HookEvent) {
|
||||
let kind = event.kind();
|
||||
let mode = self
|
||||
.modes
|
||||
.get(&kind)
|
||||
.cloned()
|
||||
.unwrap_or(DebounceMode::None);
|
||||
let mode = self.modes.get(&kind).cloned().unwrap_or(DebounceMode::None);
|
||||
|
||||
tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event");
|
||||
|
||||
|
||||
@@ -188,9 +188,8 @@ mod tests {
|
||||
#[test]
|
||||
fn response_add_items() {
|
||||
let json = r#"{"action": "add_items", "items": [{"label": "new"}]}"#;
|
||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
||||
std::unreachable!("parse failed: {e}")
|
||||
});
|
||||
let resp: HookResponse =
|
||||
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
|
||||
assert_eq!(
|
||||
resp,
|
||||
HookResponse::AddItems {
|
||||
@@ -202,9 +201,8 @@ mod tests {
|
||||
#[test]
|
||||
fn response_replace_items() {
|
||||
let json = r#"{"action": "replace_items", "items": ["a", "b"]}"#;
|
||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
||||
std::unreachable!("parse failed: {e}")
|
||||
});
|
||||
let resp: HookResponse =
|
||||
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
|
||||
assert_eq!(
|
||||
resp,
|
||||
HookResponse::ReplaceItems {
|
||||
@@ -216,9 +214,8 @@ mod tests {
|
||||
#[test]
|
||||
fn response_remove_items() {
|
||||
let json = r#"{"action": "remove_items", "indices": [0, 3, 5]}"#;
|
||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
||||
std::unreachable!("parse failed: {e}")
|
||||
});
|
||||
let resp: HookResponse =
|
||||
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
|
||||
assert_eq!(
|
||||
resp,
|
||||
HookResponse::RemoveItems {
|
||||
@@ -230,9 +227,8 @@ mod tests {
|
||||
#[test]
|
||||
fn response_set_filter() {
|
||||
let json = r#"{"action": "set_filter", "text": "hello"}"#;
|
||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
||||
std::unreachable!("parse failed: {e}")
|
||||
});
|
||||
let resp: HookResponse =
|
||||
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
|
||||
assert_eq!(
|
||||
resp,
|
||||
HookResponse::SetFilter {
|
||||
@@ -244,9 +240,8 @@ mod tests {
|
||||
#[test]
|
||||
fn response_close() {
|
||||
let json = r#"{"action": "close"}"#;
|
||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
||||
std::unreachable!("parse failed: {e}")
|
||||
});
|
||||
let resp: HookResponse =
|
||||
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
|
||||
assert_eq!(resp, HookResponse::Close);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use crate::filter::Filter;
|
||||
use crate::format::FormatTemplate;
|
||||
use crate::item::Item;
|
||||
use crate::model::column::ColumnConfig;
|
||||
use crate::model::traits::{Menu, MutableMenu};
|
||||
use crate::pipeline::FilterPipeline;
|
||||
|
||||
@@ -19,6 +20,7 @@ pub struct JsonMenu {
|
||||
filter: FilterPipeline,
|
||||
filter_fields: Vec<String>,
|
||||
format_template: Option<FormatTemplate>,
|
||||
column_config: Option<ColumnConfig>,
|
||||
}
|
||||
|
||||
impl JsonMenu {
|
||||
@@ -36,6 +38,7 @@ impl JsonMenu {
|
||||
filter,
|
||||
filter_fields: vec!["label".to_string()],
|
||||
format_template: None,
|
||||
column_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +55,11 @@ impl JsonMenu {
|
||||
self.format_template = Some(template);
|
||||
}
|
||||
|
||||
/// Set the column config for table mode rendering.
|
||||
pub fn set_column_config(&mut self, config: ColumnConfig) {
|
||||
self.column_config = Some(config);
|
||||
}
|
||||
|
||||
/// Rebuild the filter pipeline from scratch. Called after
|
||||
/// filter_fields change or item mutations.
|
||||
fn rebuild_pipeline(&mut self) {
|
||||
@@ -141,6 +149,16 @@ impl Menu for JsonMenu {
|
||||
let orig_idx = self.filter.matched_index(filtered_index)?;
|
||||
Some(template.render(&self.items[orig_idx].value))
|
||||
}
|
||||
|
||||
fn column_values(&self, filtered_index: usize) -> Option<Vec<String>> {
|
||||
let config = self.column_config.as_ref()?;
|
||||
let orig_idx = self.filter.matched_index(filtered_index)?;
|
||||
Some(config.resolve_values(&self.items[orig_idx].value))
|
||||
}
|
||||
|
||||
fn column_config(&self) -> Option<&ColumnConfig> {
|
||||
self.column_config.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl MutableMenu for JsonMenu {
|
||||
@@ -177,7 +195,11 @@ impl MutableMenu for JsonMenu {
|
||||
self.items.remove(idx);
|
||||
}
|
||||
}
|
||||
tracing::debug!(count, remaining = self.items.len(), "items removed from menu");
|
||||
tracing::debug!(
|
||||
count,
|
||||
remaining = self.items.len(),
|
||||
"items removed from menu"
|
||||
);
|
||||
self.rebuild_pipeline();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ use indexmap::IndexSet;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
use crate::debounce::{hook_response_to_action, DebouncedDispatcher};
|
||||
use crate::debounce::{DebouncedDispatcher, hook_response_to_action};
|
||||
use crate::error::PiklError;
|
||||
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
|
||||
use crate::event::{Action, ColumnHeader, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
|
||||
use crate::hook::{HookEvent, HookHandler};
|
||||
use crate::model::traits::MutableMenu;
|
||||
use crate::navigation::Viewport;
|
||||
@@ -97,14 +97,16 @@ impl SelectionState {
|
||||
/// Undo the last selection change.
|
||||
pub fn undo(&mut self) {
|
||||
if let Some(prev) = self.undo_stack.pop() {
|
||||
self.redo_stack.push(std::mem::replace(&mut self.selected, prev));
|
||||
self.redo_stack
|
||||
.push(std::mem::replace(&mut self.selected, prev));
|
||||
}
|
||||
}
|
||||
|
||||
/// Redo a previously undone selection change.
|
||||
pub fn redo(&mut self) {
|
||||
if let Some(next) = self.redo_stack.pop() {
|
||||
self.undo_stack.push(std::mem::replace(&mut self.selected, next));
|
||||
self.undo_stack
|
||||
.push(std::mem::replace(&mut self.selected, next));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,22 +234,56 @@ impl<M: MutableMenu> MenuRunner<M> {
|
||||
fn build_view_state(&mut self) -> ViewState {
|
||||
self.generation += 1;
|
||||
let range = self.viewport.visible_range();
|
||||
let has_columns = self.menu.column_config().is_some();
|
||||
let visible_items: Vec<VisibleItem> = range
|
||||
.clone()
|
||||
.filter_map(|i| {
|
||||
self.menu.filtered_label(i).map(|label| {
|
||||
let formatted_text = self.menu.formatted_label(i);
|
||||
let orig_idx = self.menu.original_index(i).unwrap_or(0);
|
||||
let column_values = if has_columns {
|
||||
self.menu.column_values(i)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
VisibleItem {
|
||||
label: label.to_string(),
|
||||
formatted_text,
|
||||
index: i,
|
||||
selected: self.selection.is_selected(orig_idx),
|
||||
column_values,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Compute column headers with widths if table mode is active
|
||||
let columns = self.menu.column_config().map(|config| {
|
||||
config
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(col_idx, col_def)| {
|
||||
let header_len = col_def.display_name.len();
|
||||
let max_cell = visible_items
|
||||
.iter()
|
||||
.filter_map(|vi| {
|
||||
vi.column_values
|
||||
.as_ref()
|
||||
.and_then(|cv| cv.get(col_idx))
|
||||
.map(|s| s.len())
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let width = header_len.max(max_cell).min(60) as u16;
|
||||
ColumnHeader {
|
||||
display_name: col_def.display_name.clone(),
|
||||
width,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let cursor = if self.menu.filtered_count() == 0 {
|
||||
0
|
||||
} else {
|
||||
@@ -263,6 +299,7 @@ impl<M: MutableMenu> MenuRunner<M> {
|
||||
mode: self.mode,
|
||||
selection_count: self.selection.count(),
|
||||
multi_enabled: self.selection.multi_enabled,
|
||||
columns,
|
||||
generation: self.generation,
|
||||
}
|
||||
}
|
||||
@@ -404,7 +441,9 @@ impl<M: MutableMenu> MenuRunner<M> {
|
||||
let items: Vec<(Value, usize)> = indices
|
||||
.into_iter()
|
||||
.filter_map(|orig_idx| {
|
||||
self.menu.serialize_original(orig_idx).map(|v| (v.clone(), orig_idx))
|
||||
self.menu
|
||||
.serialize_original(orig_idx)
|
||||
.map(|v| (v.clone(), orig_idx))
|
||||
})
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
@@ -572,8 +611,7 @@ impl<M: MutableMenu> MenuRunner<M> {
|
||||
return Ok(MenuResult::Selected { items });
|
||||
}
|
||||
ActionOutcome::Quicklist { items } => {
|
||||
let values: Vec<Value> =
|
||||
items.iter().map(|(v, _)| v.clone()).collect();
|
||||
let values: Vec<Value> = items.iter().map(|(v, _)| v.clone()).collect();
|
||||
let count = values.len();
|
||||
info!(count, "quicklist returned");
|
||||
self.emit_hook(HookEvent::Quicklist {
|
||||
@@ -582,9 +620,7 @@ impl<M: MutableMenu> MenuRunner<M> {
|
||||
});
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(MenuEvent::Quicklist(values));
|
||||
let _ = self.event_tx.send(MenuEvent::Quicklist(values));
|
||||
return Ok(MenuResult::Quicklist { items });
|
||||
}
|
||||
ActionOutcome::Cancelled => {
|
||||
@@ -673,7 +709,9 @@ mod tests {
|
||||
let mut m = ready_menu();
|
||||
m.apply_action(Action::MoveDown(1));
|
||||
let outcome = m.apply_action(Action::Confirm);
|
||||
assert!(matches!(&outcome, ActionOutcome::Selected { items } if items[0].0.as_str() == Some("beta")));
|
||||
assert!(
|
||||
matches!(&outcome, ActionOutcome::Selected { items } if items[0].0.as_str() == Some("beta"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -859,10 +897,14 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let event = rx.recv().await;
|
||||
assert!(matches!(&event, Ok(MenuEvent::Selected(items)) if items[0].0.as_str() == Some("alpha")));
|
||||
assert!(
|
||||
matches!(&event, Ok(MenuEvent::Selected(items)) if items[0].0.as_str() == Some("alpha"))
|
||||
);
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha")));
|
||||
assert!(
|
||||
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -884,7 +926,9 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma")));
|
||||
assert!(
|
||||
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -909,7 +953,9 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta")));
|
||||
assert!(
|
||||
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -944,7 +990,9 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon")));
|
||||
assert!(
|
||||
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1211,10 +1259,7 @@ mod tests {
|
||||
match outcome {
|
||||
ActionOutcome::Quicklist { items } => {
|
||||
assert_eq!(items.len(), 4);
|
||||
let labels: Vec<&str> = items
|
||||
.iter()
|
||||
.filter_map(|(v, _)| v.as_str())
|
||||
.collect();
|
||||
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:?}"),
|
||||
@@ -1661,4 +1706,103 @@ mod tests {
|
||||
assert!(vs.visible_items[0].selected);
|
||||
assert!(!vs.visible_items[1].selected);
|
||||
}
|
||||
|
||||
// -- Column / table mode tests --
|
||||
|
||||
use crate::model::column::ColumnConfig;
|
||||
|
||||
fn table_menu() -> MenuRunner<JsonMenu> {
|
||||
let items = vec![
|
||||
Item::new(
|
||||
serde_json::json!({"label": "alice", "meta": {"age": "30", "city": "toronto"}}),
|
||||
"label",
|
||||
),
|
||||
Item::new(
|
||||
serde_json::json!({"label": "bob", "meta": {"age": "25", "city": "vancouver"}}),
|
||||
"label",
|
||||
),
|
||||
Item::new(
|
||||
serde_json::json!({"label": "charlie", "meta": {"age": "35", "city": "montreal"}}),
|
||||
"label",
|
||||
),
|
||||
];
|
||||
let mut json_menu = JsonMenu::new(items, "label".to_string());
|
||||
json_menu.set_column_config(ColumnConfig::parse("label,meta.age:Age,meta.city:City"));
|
||||
let (runner, _tx) = MenuRunner::new(json_menu);
|
||||
runner
|
||||
}
|
||||
|
||||
fn ready_table_menu() -> MenuRunner<JsonMenu> {
|
||||
let mut m = table_menu();
|
||||
m.run_filter();
|
||||
m.apply_action(Action::Resize { height: 10 });
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_state_has_columns_when_config_set() {
|
||||
let mut m = ready_table_menu();
|
||||
let vs = m.build_view_state();
|
||||
assert!(vs.columns.is_some(), "columns should be Some");
|
||||
let cols = vs.columns.as_ref().unwrap();
|
||||
assert_eq!(cols.len(), 3);
|
||||
assert_eq!(cols[0].display_name, "label");
|
||||
assert_eq!(cols[1].display_name, "Age");
|
||||
assert_eq!(cols[2].display_name, "City");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_state_column_widths_computed() {
|
||||
let mut m = ready_table_menu();
|
||||
let vs = m.build_view_state();
|
||||
let cols = vs.columns.as_ref().unwrap();
|
||||
// "label" header is 5 chars, "charlie" is 7 chars: width should be 7
|
||||
assert_eq!(cols[0].width, 7);
|
||||
// "Age" header is 3 chars, max cell "35" is 2 chars: width should be 3
|
||||
assert_eq!(cols[1].width, 3);
|
||||
// "City" header is 4 chars, "vancouver" is 9 chars: width should be 9
|
||||
assert_eq!(cols[2].width, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_state_items_have_column_values() {
|
||||
let mut m = ready_table_menu();
|
||||
let vs = m.build_view_state();
|
||||
let item = &vs.visible_items[0];
|
||||
assert!(item.column_values.is_some());
|
||||
let vals = item.column_values.as_ref().unwrap();
|
||||
assert_eq!(vals, &["alice", "30", "toronto"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_state_no_columns_without_config() {
|
||||
let mut m = ready_menu();
|
||||
let vs = m.build_view_state();
|
||||
assert!(vs.columns.is_none());
|
||||
assert!(vs.visible_items[0].column_values.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_mode_filter_still_works() {
|
||||
let mut m = ready_table_menu();
|
||||
m.apply_action(Action::UpdateFilter("bob".to_string()));
|
||||
let vs = m.build_view_state();
|
||||
assert_eq!(vs.total_filtered, 1);
|
||||
assert_eq!(vs.visible_items[0].label, "bob");
|
||||
// Column values should still be present
|
||||
let vals = vs.visible_items[0].column_values.as_ref().unwrap();
|
||||
assert_eq!(vals, &["bob", "25", "vancouver"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_mode_column_widths_change_with_filter() {
|
||||
let mut m = ready_table_menu();
|
||||
m.apply_action(Action::UpdateFilter("alice".to_string()));
|
||||
let vs = m.build_view_state();
|
||||
let cols = vs.columns.as_ref().unwrap();
|
||||
// Only "alice" visible now, so label width = max(5, 5) = 5
|
||||
assert_eq!(cols[0].width, 5);
|
||||
// City "toronto" (7) vs header "City" (4): width = 7
|
||||
assert_eq!(cols[2].width, 7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod csv_input;
|
||||
pub mod debounce;
|
||||
pub mod hook;
|
||||
pub mod input;
|
||||
|
||||
@@ -209,7 +209,9 @@ async fn handle_multi_action_key(
|
||||
return false;
|
||||
}
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char(' '), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||
(KeyCode::Char(' '), m)
|
||||
if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||
{
|
||||
let _ = action_tx.send(Action::ToggleSelect).await;
|
||||
let _ = action_tx.send(Action::MoveDown(1)).await;
|
||||
true
|
||||
@@ -229,14 +231,18 @@ async fn handle_multi_action_key(
|
||||
}
|
||||
Mode::Visual => {
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char(' '), m) | (KeyCode::Enter, m) if !m.intersects(KeyModifiers::ALT) => {
|
||||
(KeyCode::Char(' '), m) | (KeyCode::Enter, m)
|
||||
if !m.intersects(KeyModifiers::ALT) =>
|
||||
{
|
||||
// Apply visual selection
|
||||
if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) {
|
||||
let cursor = vs.cursor;
|
||||
let _ = action_tx.send(Action::SelectRange {
|
||||
start: anchor,
|
||||
end: cursor,
|
||||
}).await;
|
||||
let _ = action_tx
|
||||
.send(Action::SelectRange {
|
||||
start: anchor,
|
||||
end: cursor,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
*visual_anchor = None;
|
||||
let _ = action_tx.send(Action::SetMode(Mode::Normal)).await;
|
||||
@@ -250,10 +256,30 @@ async fn handle_multi_action_key(
|
||||
|
||||
/// Render the menu into the given frame. Extracted from the
|
||||
/// event loop so it can be tested with a [`TestBackend`].
|
||||
fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str, visual_anchor: Option<usize>) {
|
||||
fn render_menu(
|
||||
frame: &mut ratatui::Frame,
|
||||
vs: &ViewState,
|
||||
filter_text: &str,
|
||||
visual_anchor: Option<usize>,
|
||||
) {
|
||||
let is_table = vs.columns.is_some();
|
||||
|
||||
let constraints = if is_table {
|
||||
vec![
|
||||
Constraint::Length(1), // prompt
|
||||
Constraint::Length(1), // header row
|
||||
Constraint::Min(1), // items
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Constraint::Length(1), // prompt
|
||||
Constraint::Min(1), // items
|
||||
]
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
||||
.constraints(constraints)
|
||||
.split(frame.area());
|
||||
|
||||
let filtered_count = vs.total_filtered;
|
||||
@@ -295,6 +321,35 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str, vi
|
||||
frame.set_cursor_position(((prompt_prefix_len + filter_text.len()) as u16, 0));
|
||||
}
|
||||
|
||||
// Render table header if in table mode
|
||||
if let Some(ref columns) = vs.columns {
|
||||
let header_chunk = chunks[1];
|
||||
let header_spans: Vec<Span> = columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, col)| {
|
||||
let mut spans = Vec::new();
|
||||
if i > 0 {
|
||||
spans.push(Span::styled(
|
||||
" \u{2502} ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
format!("{:<width$}", col.display_name, width = col.width as usize),
|
||||
Style::default()
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.add_modifier(Modifier::UNDERLINED),
|
||||
));
|
||||
spans
|
||||
})
|
||||
.collect();
|
||||
let header_line = Paragraph::new(Line::from(header_spans));
|
||||
frame.render_widget(header_line, header_chunk);
|
||||
}
|
||||
|
||||
let list_chunk = if is_table { chunks[2] } else { chunks[1] };
|
||||
|
||||
// Determine visual range for highlighting
|
||||
let visual_range = if vs.mode == Mode::Visual {
|
||||
visual_anchor.map(|anchor| {
|
||||
@@ -324,18 +379,53 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str, vi
|
||||
style = style.fg(Color::Green);
|
||||
}
|
||||
|
||||
let marker = if vi.selected { "* " } else { " " };
|
||||
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
|
||||
if vs.multi_enabled {
|
||||
ListItem::new(format!("{marker}{text}")).style(style)
|
||||
// Table mode: render cells padded to column widths
|
||||
if let (Some(col_vals), Some(columns)) = (&vi.column_values, &vs.columns) {
|
||||
let marker = if vs.multi_enabled {
|
||||
if vi.selected { "* " } else { " " }
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let cell_spans: Vec<Span> = columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(ci, col)| {
|
||||
let mut spans = Vec::new();
|
||||
if ci > 0 {
|
||||
spans.push(Span::styled(
|
||||
" \u{2502} ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
}
|
||||
let cell = col_vals.get(ci).map(|s| s.as_str()).unwrap_or("");
|
||||
spans.push(Span::raw(format!(
|
||||
"{:<width$}",
|
||||
cell,
|
||||
width = col.width as usize
|
||||
)));
|
||||
spans
|
||||
})
|
||||
.collect();
|
||||
let mut all_spans = Vec::new();
|
||||
if !marker.is_empty() {
|
||||
all_spans.push(Span::raw(marker));
|
||||
}
|
||||
all_spans.extend(cell_spans);
|
||||
ListItem::new(Line::from(all_spans)).style(style)
|
||||
} else {
|
||||
ListItem::new(text).style(style)
|
||||
let marker = if vi.selected { "* " } else { " " };
|
||||
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
|
||||
if vs.multi_enabled {
|
||||
ListItem::new(format!("{marker}{text}")).style(style)
|
||||
} else {
|
||||
ListItem::new(text).style(style)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items);
|
||||
frame.render_widget(list, chunks[1]);
|
||||
frame.render_widget(list, list_chunk);
|
||||
}
|
||||
|
||||
/// Map a crossterm key event to an [`Action`], updating
|
||||
@@ -501,18 +591,21 @@ mod tests {
|
||||
formatted_text: None,
|
||||
index: 0,
|
||||
selected: false,
|
||||
column_values: None,
|
||||
},
|
||||
VisibleItem {
|
||||
label: "bravo".into(),
|
||||
formatted_text: None,
|
||||
index: 1,
|
||||
selected: false,
|
||||
column_values: None,
|
||||
},
|
||||
VisibleItem {
|
||||
label: "charlie".into(),
|
||||
formatted_text: None,
|
||||
index: 2,
|
||||
selected: false,
|
||||
column_values: None,
|
||||
},
|
||||
],
|
||||
cursor: 0,
|
||||
@@ -522,6 +615,7 @@ mod tests {
|
||||
mode: Mode::Insert,
|
||||
selection_count: 0,
|
||||
multi_enabled: false,
|
||||
columns: None,
|
||||
generation: 1,
|
||||
}
|
||||
}
|
||||
@@ -996,6 +1090,7 @@ mod tests {
|
||||
mode: Mode::Insert,
|
||||
selection_count: 0,
|
||||
multi_enabled: false,
|
||||
columns: None,
|
||||
generation: 1,
|
||||
};
|
||||
let backend = render_to_backend(30, 4, &vs, "");
|
||||
@@ -1069,6 +1164,157 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Table mode rendering tests --
|
||||
|
||||
use pikl_core::event::ColumnHeader;
|
||||
|
||||
fn table_view_state() -> ViewState {
|
||||
ViewState {
|
||||
visible_items: vec![
|
||||
VisibleItem {
|
||||
label: "alice".into(),
|
||||
formatted_text: None,
|
||||
index: 0,
|
||||
selected: false,
|
||||
column_values: Some(vec!["alice".into(), "30".into(), "toronto".into()]),
|
||||
},
|
||||
VisibleItem {
|
||||
label: "bob".into(),
|
||||
formatted_text: None,
|
||||
index: 1,
|
||||
selected: false,
|
||||
column_values: Some(vec!["bob".into(), "25".into(), "vancouver".into()]),
|
||||
},
|
||||
],
|
||||
cursor: 0,
|
||||
filter_text: Arc::from(""),
|
||||
total_items: 2,
|
||||
total_filtered: 2,
|
||||
mode: Mode::Insert,
|
||||
selection_count: 0,
|
||||
multi_enabled: false,
|
||||
columns: Some(vec![
|
||||
ColumnHeader {
|
||||
display_name: "name".into(),
|
||||
width: 9,
|
||||
},
|
||||
ColumnHeader {
|
||||
display_name: "age".into(),
|
||||
width: 3,
|
||||
},
|
||||
ColumnHeader {
|
||||
display_name: "city".into(),
|
||||
width: 9,
|
||||
},
|
||||
]),
|
||||
generation: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_renders_header_row() {
|
||||
let vs = table_view_state();
|
||||
let backend = render_to_backend(50, 6, &vs, "");
|
||||
// Row 0 = prompt, Row 1 = header, Row 2+ = items
|
||||
let header = line_text(&backend, 1);
|
||||
assert!(
|
||||
header.contains("name"),
|
||||
"header should contain 'name': got '{header}'"
|
||||
);
|
||||
assert!(
|
||||
header.contains("age"),
|
||||
"header should contain 'age': got '{header}'"
|
||||
);
|
||||
assert!(
|
||||
header.contains("city"),
|
||||
"header should contain 'city': got '{header}'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_header_has_bold_style() {
|
||||
let vs = table_view_state();
|
||||
let backend = render_to_backend(50, 6, &vs, "");
|
||||
let buf = backend.buffer();
|
||||
// Row 1 col 0 should be bold (header row)
|
||||
let cell = &buf[(0, 1)];
|
||||
assert!(
|
||||
cell.modifier.contains(Modifier::BOLD),
|
||||
"header should be bold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_items_start_at_row_2() {
|
||||
let vs = table_view_state();
|
||||
let backend = render_to_backend(50, 6, &vs, "");
|
||||
let row2 = line_text(&backend, 2);
|
||||
let row3 = line_text(&backend, 3);
|
||||
assert!(
|
||||
row2.contains("alice"),
|
||||
"row 2 should have alice: got '{row2}'"
|
||||
);
|
||||
assert!(row3.contains("bob"), "row 3 should have bob: got '{row3}'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cells_contain_values() {
|
||||
let vs = table_view_state();
|
||||
let backend = render_to_backend(50, 6, &vs, "");
|
||||
let row2 = line_text(&backend, 2);
|
||||
assert!(
|
||||
row2.contains("toronto"),
|
||||
"row should contain city value: got '{row2}'"
|
||||
);
|
||||
assert!(
|
||||
row2.contains("30"),
|
||||
"row should contain age value: got '{row2}'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cursor_row_has_reversed_style() {
|
||||
let vs = table_view_state(); // cursor at 0
|
||||
let backend = render_to_backend(50, 6, &vs, "");
|
||||
let buf = backend.buffer();
|
||||
// Row 2 (first item in table mode) should be REVERSED
|
||||
let cell = &buf[(0, 2)];
|
||||
assert!(
|
||||
cell.modifier.contains(Modifier::REVERSED),
|
||||
"cursor row should have REVERSED style in table mode"
|
||||
);
|
||||
// Row 3 should not
|
||||
let cell2 = &buf[(0, 3)];
|
||||
assert!(
|
||||
!cell2.modifier.contains(Modifier::REVERSED),
|
||||
"non-cursor row should not have REVERSED in table mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_header_has_separators() {
|
||||
let vs = table_view_state();
|
||||
let backend = render_to_backend(50, 6, &vs, "");
|
||||
let header = line_text(&backend, 1);
|
||||
assert!(
|
||||
header.contains('\u{2502}'),
|
||||
"header should contain column separator: got '{header}'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_without_columns_renders_normally() {
|
||||
// When columns is None, should render like a normal list (no header row)
|
||||
let vs = sample_view_state();
|
||||
let backend = render_to_backend(30, 6, &vs, "");
|
||||
// Row 1 should be items, not a header
|
||||
let row1 = line_text(&backend, 1);
|
||||
assert!(
|
||||
row1.contains("alpha"),
|
||||
"without columns, row 1 should be first item: got '{row1}'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_cleared_on_mode_key() {
|
||||
let mut ft = String::new();
|
||||
|
||||
@@ -12,9 +12,7 @@ use tokio::sync::mpsc;
|
||||
|
||||
use pikl_core::error::PiklError;
|
||||
use pikl_core::event::Action;
|
||||
use pikl_core::hook::{
|
||||
parse_hook_response, HookEvent, HookEventKind, HookHandler,
|
||||
};
|
||||
use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, parse_hook_response};
|
||||
|
||||
/// A persistent handler hook process. Spawns a child process,
|
||||
/// writes events to its stdin as JSON lines, reads responses
|
||||
@@ -27,10 +25,7 @@ pub struct ShellHandlerHook {
|
||||
|
||||
impl ShellHandlerHook {
|
||||
/// Build from CLI flags. Returns None if no handler hooks are configured.
|
||||
pub fn from_cli(
|
||||
cli: &crate::Cli,
|
||||
action_tx: mpsc::Sender<Action>,
|
||||
) -> Option<Self> {
|
||||
pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender<Action>) -> Option<Self> {
|
||||
let mut handlers: Vec<(HookEventKind, &str)> = Vec::new();
|
||||
|
||||
if let Some(ref cmd) = cli.on_open {
|
||||
@@ -134,18 +129,10 @@ async fn run_handler_process(
|
||||
// Writer loop: reads events from channel, writes to stdin
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
let json = serde_json::to_string(&event).unwrap_or_default();
|
||||
if stdin_writer
|
||||
.write_all(json.as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
if stdin_writer.write_all(json.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stdin_writer
|
||||
.write_all(b"\n")
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
if stdin_writer.write_all(b"\n").await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stdin_writer.flush().await.is_err() {
|
||||
@@ -157,12 +144,9 @@ async fn run_handler_process(
|
||||
drop(stdin_writer);
|
||||
|
||||
// Wait up to 1s for child to exit after EOF
|
||||
let exited = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
child.wait(),
|
||||
)
|
||||
.await
|
||||
.is_ok();
|
||||
let exited = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait())
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
if !exited {
|
||||
// Send SIGTERM on Unix, then wait another second
|
||||
@@ -177,12 +161,9 @@ async fn run_handler_process(
|
||||
libc::kill(pid as libc::pid_t, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
let termed = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
child.wait(),
|
||||
)
|
||||
.await
|
||||
.is_ok();
|
||||
let termed = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait())
|
||||
.await
|
||||
.is_ok();
|
||||
if !termed {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
@@ -194,11 +175,7 @@ async fn run_handler_process(
|
||||
}
|
||||
|
||||
// Wait for the reader to finish
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
reader_handle,
|
||||
)
|
||||
.await;
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), reader_handle).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -121,8 +121,7 @@ impl HookHandler for ShellExecHandler {
|
||||
if let Some(cmd) = self.commands.get(&kind) {
|
||||
let cmd = cmd.clone();
|
||||
// Serialize event as JSON for the hook's stdin
|
||||
let event_json =
|
||||
serde_json::to_value(&event).unwrap_or(serde_json::Value::Null);
|
||||
let event_json = serde_json::to_value(&event).unwrap_or(serde_json::Value::Null);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_hook(&cmd, &event_json).await {
|
||||
tracing::warn!(error = %e, command = %cmd, "exec hook failed");
|
||||
|
||||
@@ -7,6 +7,8 @@ use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use pikl_core::column::ColumnConfig;
|
||||
use pikl_core::csv_input::{self, InputFormat};
|
||||
use pikl_core::debounce::{DebounceMode, DebouncedDispatcher};
|
||||
use pikl_core::error::PiklError;
|
||||
use pikl_core::event::{Action, MenuResult, Mode};
|
||||
@@ -127,6 +129,14 @@ struct Cli {
|
||||
/// Output selected items in selection order instead of input order
|
||||
#[arg(long)]
|
||||
selection_order: bool,
|
||||
|
||||
/// Input format: auto, csv, or tsv
|
||||
#[arg(long, value_name = "FORMAT", default_value = "auto")]
|
||||
input_format: String,
|
||||
|
||||
/// Columns to display in table mode (e.g. "label,meta.age:Age")
|
||||
#[arg(long, value_name = "COLS")]
|
||||
columns: Option<String>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -185,8 +195,21 @@ fn main() {
|
||||
.stdin_timeout
|
||||
.unwrap_or(if script.is_some() { 30 } else { 0 });
|
||||
|
||||
let items = match read_stdin_with_timeout(timeout, &cli.label_key) {
|
||||
Ok(items) => items,
|
||||
let input_format = match InputFormat::from_str_opt(&cli.input_format) {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"pikl: unknown input format '{}', expected auto, csv, or tsv",
|
||||
cli.input_format
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let (items, csv_headers) = match read_stdin_with_timeout(timeout, &cli.label_key, input_format)
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
|
||||
std::process::exit(2);
|
||||
@@ -201,6 +224,13 @@ fn main() {
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
// Resolve column config: explicit --columns, or auto-generate from CSV headers
|
||||
let column_config = if let Some(ref cols) = cli.columns {
|
||||
Some(ColumnConfig::parse(cols))
|
||||
} else {
|
||||
csv_headers.map(|h| ColumnConfig::from_headers(&h))
|
||||
};
|
||||
|
||||
// STEP 3: Build menu, start runtime
|
||||
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
|
||||
let _ = writeln!(
|
||||
@@ -234,17 +264,18 @@ fn main() {
|
||||
|
||||
// STEP 4: Branch on headless vs interactive
|
||||
let result = if let Some(script) = script {
|
||||
rt.block_on(run_headless(items, &cli, script, start_mode))
|
||||
rt.block_on(run_headless(items, &cli, script, start_mode, column_config))
|
||||
} else {
|
||||
rt.block_on(run_interactive(items, &cli, start_mode))
|
||||
rt.block_on(run_interactive(items, &cli, start_mode, column_config))
|
||||
};
|
||||
|
||||
// STEP 5: Handle result
|
||||
handle_result(result, &cli);
|
||||
}
|
||||
|
||||
/// Build a JsonMenu with optional filter_fields and format template.
|
||||
fn build_menu(items: Vec<Item>, cli: &Cli) -> JsonMenu {
|
||||
/// Build a JsonMenu with optional filter_fields, format template,
|
||||
/// and column config.
|
||||
fn build_menu(items: Vec<Item>, cli: &Cli, column_config: Option<ColumnConfig>) -> JsonMenu {
|
||||
let mut menu = JsonMenu::new(items, cli.label_key.clone());
|
||||
if let Some(ref fields) = cli.filter_fields {
|
||||
menu.set_filter_fields(fields.clone());
|
||||
@@ -252,6 +283,9 @@ fn build_menu(items: Vec<Item>, cli: &Cli) -> JsonMenu {
|
||||
if let Some(ref template) = cli.format {
|
||||
menu.set_format_template(FormatTemplate::parse(template));
|
||||
}
|
||||
if let Some(config) = column_config {
|
||||
menu.set_column_config(config);
|
||||
}
|
||||
menu
|
||||
}
|
||||
|
||||
@@ -318,10 +352,7 @@ struct CompositeHookHandler {
|
||||
}
|
||||
|
||||
impl HookHandler for CompositeHookHandler {
|
||||
fn handle(
|
||||
&self,
|
||||
event: pikl_core::hook::HookEvent,
|
||||
) -> Result<(), PiklError> {
|
||||
fn handle(&self, event: pikl_core::hook::HookEvent) -> Result<(), PiklError> {
|
||||
// Both fire. Exec is fire-and-forget, handler may
|
||||
// send responses through action_tx.
|
||||
let _ = self.exec.handle(event.clone());
|
||||
@@ -339,8 +370,9 @@ async fn run_headless(
|
||||
cli: &Cli,
|
||||
script: Vec<ScriptAction>,
|
||||
start_mode: Mode,
|
||||
column_config: Option<ColumnConfig>,
|
||||
) -> Result<MenuResult, PiklError> {
|
||||
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
||||
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, column_config));
|
||||
menu.set_initial_mode(start_mode);
|
||||
menu.set_multi(cli.multi);
|
||||
menu.set_selection_order(cli.selection_order);
|
||||
@@ -388,8 +420,9 @@ async fn run_interactive(
|
||||
items: Vec<Item>,
|
||||
cli: &Cli,
|
||||
start_mode: Mode,
|
||||
column_config: Option<ColumnConfig>,
|
||||
) -> Result<MenuResult, PiklError> {
|
||||
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
||||
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, column_config));
|
||||
menu.set_initial_mode(start_mode);
|
||||
menu.set_multi(cli.multi);
|
||||
menu.set_selection_order(cli.selection_order);
|
||||
@@ -493,15 +526,26 @@ fn write_plain_value(writer: &mut impl Write, value: &Value) -> Result<(), std::
|
||||
/// Read items from stdin. If `timeout_secs` is non-zero,
|
||||
/// spawn a thread and bail if it doesn't finish in time.
|
||||
/// A timeout of 0 means no timeout (blocking read).
|
||||
fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result<Vec<Item>, PiklError> {
|
||||
///
|
||||
/// Returns (items, optional_csv_headers). CSV headers are
|
||||
/// returned so column config can be auto-generated.
|
||||
fn read_stdin_with_timeout(
|
||||
timeout_secs: u64,
|
||||
label_key: &str,
|
||||
format: InputFormat,
|
||||
) -> Result<(Vec<Item>, Option<Vec<String>>), PiklError> {
|
||||
if timeout_secs == 0 {
|
||||
return read_items_sync(std::io::stdin().lock(), label_key);
|
||||
return read_stdin_inner(std::io::stdin().lock(), label_key, format);
|
||||
}
|
||||
|
||||
let label_key = label_key.to_string();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(read_items_sync(std::io::stdin().lock(), &label_key));
|
||||
let _ = tx.send(read_stdin_inner(
|
||||
std::io::stdin().lock(),
|
||||
&label_key,
|
||||
format,
|
||||
));
|
||||
});
|
||||
|
||||
match rx.recv_timeout(Duration::from_secs(timeout_secs)) {
|
||||
@@ -520,6 +564,24 @@ fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result<Vec<Ite
|
||||
}
|
||||
}
|
||||
|
||||
/// Read stdin as either JSON/plain or CSV/TSV depending on format.
|
||||
fn read_stdin_inner(
|
||||
reader: impl std::io::BufRead,
|
||||
label_key: &str,
|
||||
format: InputFormat,
|
||||
) -> Result<(Vec<Item>, Option<Vec<String>>), PiklError> {
|
||||
match format {
|
||||
InputFormat::Auto => {
|
||||
let items = read_items_sync(reader, label_key)?;
|
||||
Ok((items, None))
|
||||
}
|
||||
InputFormat::Csv | InputFormat::Tsv => {
|
||||
let result = csv_input::read_csv_items_sync(reader, format, label_key)?;
|
||||
Ok((result.items, Some(result.headers)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Redirect stdin to `/dev/tty` so the TUI can read keyboard
|
||||
/// input after stdin was consumed for piped items. Flushes
|
||||
/// stale input so crossterm starts clean.
|
||||
|
||||
@@ -67,3 +67,76 @@ fn headless_actions_after_show_ui_exits_2() {
|
||||
"expected show-ui error, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- CSV/TSV integration tests --
|
||||
|
||||
#[test]
|
||||
fn csv_confirm_first() {
|
||||
let (stdout, _stderr, code) = common::run_pikl(
|
||||
"name,age\nalice,30\nbob,25\n",
|
||||
"confirm\n",
|
||||
&["--input-format", "csv"],
|
||||
);
|
||||
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||
// Plain output should show the JSON object
|
||||
assert!(
|
||||
stdout.contains("alice"),
|
||||
"expected alice in stdout, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_with_columns_subset() {
|
||||
let (stdout, _stderr, code) = common::run_pikl(
|
||||
"name,age,city\nalice,30,toronto\nbob,25,vancouver\n",
|
||||
"confirm\n",
|
||||
&["--input-format", "csv", "--columns", "name,city"],
|
||||
);
|
||||
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||
assert!(
|
||||
stdout.contains("alice"),
|
||||
"expected alice in stdout, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_filter_then_confirm() {
|
||||
let (stdout, _stderr, code) = common::run_pikl(
|
||||
"name,age\nalice,30\nbob,25\ncharlie,35\n",
|
||||
"filter bob\nconfirm\n",
|
||||
&["--input-format", "csv"],
|
||||
);
|
||||
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||
assert!(
|
||||
stdout.contains("bob"),
|
||||
"expected bob in stdout, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_multi_select() {
|
||||
let (stdout, _stderr, code) = common::run_pikl(
|
||||
"name,age\nalice,30\nbob,25\n",
|
||||
"toggle-select\nmove-down\ntoggle-select\nconfirm\n",
|
||||
&["--input-format", "csv", "--multi"],
|
||||
);
|
||||
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||
assert!(
|
||||
stdout.contains("alice") && stdout.contains("bob"),
|
||||
"expected both alice and bob in stdout, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsv_confirm_first() {
|
||||
let (stdout, _stderr, code) = common::run_pikl(
|
||||
"name\tage\nalice\t30\nbob\t25\n",
|
||||
"confirm\n",
|
||||
&["--input-format", "tsv"],
|
||||
);
|
||||
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||
assert!(
|
||||
stdout.contains("alice"),
|
||||
"expected alice in stdout, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user