use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::app::{MouseHitTarget, MouseLayer, MouseScrollTarget, Overlay, PromptKind, TuiApp};
use super::draw::panel;
use super::editor::Editor;
use super::new_request::draw_scrollbar;
use super::theme::Theme;
use crate::i18n::{Status, Strings};
use crate::report::flow::{
Element, EnvClause, FlowNode, ParallelSpec, Pattern, Producer, ReportStmt, ResponseFmt,
RoleRef, WithItem,
};
use crate::report::model::StatKind;
pub(crate) use crate::report::edit::{
InsertPos, NodeKind, NodeRow, RowKind, flatten, insert_node, insert_pos_after,
loop_producer_dir, loop_producer_dir_mut, move_node, node_at, node_at_mut, parse_one_node,
remove_node, replace_node, request_node,
};
pub(crate) struct NodeMenu {
pub(crate) step: NodeMenuStep,
pub(crate) options: Vec<String>,
pub(crate) selected: usize,
pub(crate) pos: InsertPos,
pub(crate) report_id: u64,
pub(crate) report_kind: bool,
pub(crate) edit_path: Option<Vec<usize>>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum NodeMenuStep {
PickKind,
PickRequest,
}
impl NodeMenu {
pub(crate) fn title<'a>(&self, s: &'a Strings) -> &'a str {
match self.step {
NodeMenuStep::PickKind => s.node_menu_title,
NodeMenuStep::PickRequest => s.node_pick_request_title,
}
}
}
pub(crate) struct ShowRow {
pub(crate) name: String,
pub(crate) included: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum FormRow {
Name,
Report,
Response,
Alias,
Field(usize),
Hidden(usize),
With(usize),
AddWith,
}
pub(crate) struct RequestForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) request: String,
pub(crate) titles: Vec<String>,
pub(crate) report: bool,
pub(crate) response: Option<ResponseFmt>,
pub(crate) alias: String,
pub(crate) fields: Vec<ShowRow>,
pub(crate) with: Vec<WithItem>,
pub(crate) hide_fields: Vec<ShowRow>,
pub(crate) selected: usize,
}
impl RequestForm {
#[allow(clippy::too_many_arguments)]
fn build(
report_id: u64,
path: Vec<usize>,
request: String,
titles: Vec<String>,
report: bool,
alias: Option<String>,
response: Option<ResponseFmt>,
current_show: &[String],
report_fields: &[String],
with: Vec<WithItem>,
hide: Vec<String>,
) -> Self {
let with_fields: Vec<String> = with
.iter()
.filter_map(|w| match w {
WithItem::Field { name, .. } => Some(name.clone()),
_ => None,
})
.collect();
let mut names: Vec<String> = Vec::new();
let push = |name: &str, names: &mut Vec<String>| {
if !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
};
for f in crate::report::run::INTRINSIC_FIELDS {
push(f, &mut names);
}
for f in report_fields {
push(f, &mut names);
}
for f in &with_fields {
push(f, &mut names);
}
for f in current_show {
push(f, &mut names);
}
for f in &hide {
push(f, &mut names);
}
let all = current_show.is_empty();
let fields: Vec<ShowRow> = names
.iter()
.map(|name| {
let included = all || current_show.iter().any(|s| s == name);
ShowRow {
name: name.clone(),
included,
}
})
.collect();
let hide_fields = names
.iter()
.map(|name| ShowRow {
name: name.clone(),
included: hide.iter().any(|h| h == name),
})
.collect();
RequestForm {
report_id,
path,
request,
titles,
report,
response,
alias: alias.unwrap_or_default(),
fields,
with,
hide_fields,
selected: 0,
}
}
pub(crate) fn visible_rows(&self) -> Vec<FormRow> {
let mut rows = vec![FormRow::Name, FormRow::Report];
if self.report {
rows.push(FormRow::Response);
rows.push(FormRow::Alias);
rows.extend((0..self.fields.len()).map(FormRow::Field));
rows.extend((0..self.hide_fields.len()).map(FormRow::Hidden));
rows.extend((0..self.with.len()).map(FormRow::With));
rows.push(FormRow::AddWith);
}
rows
}
fn hide(&self) -> Vec<String> {
self.hide_fields
.iter()
.filter(|r| r.included)
.map(|r| r.name.clone())
.collect()
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn show(&self) -> Vec<String> {
if self.fields.iter().all(|r| r.included) {
return Vec::new();
}
self.fields
.iter()
.filter(|r| r.included)
.map(|r| r.name.clone())
.collect()
}
fn alias_opt(&self) -> Option<String> {
let a = self.alias.trim();
if a.is_empty() {
None
} else {
Some(a.to_string())
}
}
fn cycle_name(&mut self, forward: bool) {
let n = self.titles.len();
if n == 0 {
return;
}
let next = match self.titles.iter().position(|t| t == &self.request) {
Some(i) if forward => (i + 1) % n,
Some(i) => (i + n - 1) % n,
None => 0,
};
self.request = self.titles[next].clone();
}
fn cycle_response(&mut self, forward: bool) {
self.response = if forward {
match self.response {
None => Some(ResponseFmt::Raw),
Some(ResponseFmt::Raw) => Some(ResponseFmt::Pretty),
Some(ResponseFmt::Pretty) => None,
}
} else {
match self.response {
None => Some(ResponseFmt::Pretty),
Some(ResponseFmt::Pretty) => Some(ResponseFmt::Raw),
Some(ResponseFmt::Raw) => None,
}
};
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum VarsRow {
Var(usize),
Other,
Alias,
Stat(usize),
}
pub(crate) struct VarsForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) vars: Vec<ShowRow>,
pub(crate) other: String,
pub(crate) alias: String,
pub(crate) stats: Vec<(StatKind, bool)>,
pub(crate) selected: usize,
}
impl VarsForm {
fn build(
report_id: u64,
path: Vec<usize>,
chosen: &[String],
alias: Option<String>,
stats: &[StatKind],
in_scope: Vec<String>,
) -> Self {
let mut names = in_scope;
for c in chosen {
if !names.iter().any(|n| n == c) {
names.push(c.clone());
}
}
VarsForm {
report_id,
path,
vars: names
.into_iter()
.map(|name| {
let included = chosen.iter().any(|c| c == &name);
ShowRow { name, included }
})
.collect(),
other: String::new(),
alias: alias.unwrap_or_default(),
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
selected: 0,
}
}
fn chosen(&self) -> Vec<String> {
let mut out: Vec<String> = self
.vars
.iter()
.filter(|r| r.included)
.map(|r| r.name.clone())
.collect();
let other = self.other.trim();
if !other.is_empty() && !out.iter().any(|n| n == other) {
out.push(other.to_string());
}
out
}
pub(crate) fn visible_rows(&self) -> Vec<VarsRow> {
let mut rows: Vec<VarsRow> = (0..self.vars.len()).map(VarsRow::Var).collect();
rows.push(VarsRow::Other);
if self.chosen().len() == 1 {
rows.push(VarsRow::Alias);
rows.extend((0..self.stats.len()).map(VarsRow::Stat));
}
rows
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn node(&self) -> Option<FlowNode> {
let chosen = self.chosen();
let (first, rest) = chosen.split_first()?;
let alias = self.alias.trim();
let stats: Vec<StatKind> = self
.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect();
if rest.is_empty() && (!alias.is_empty() || !stats.is_empty()) {
return Some(FlowNode::Report(ReportStmt::VarAs {
var: first.clone(),
name: if alias.is_empty() {
first.clone()
} else {
alias.to_string()
},
stats,
}));
}
Some(FlowNode::Report(ReportStmt::Vars(chosen)))
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ComputedRow {
Template,
Alias,
Stat(usize),
}
pub(crate) struct ComputedForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) template: String,
pub(crate) alias: String,
pub(crate) stats: Vec<(StatKind, bool)>,
pub(crate) selected: usize,
}
impl ComputedForm {
pub(crate) fn visible_rows(&self) -> Vec<ComputedRow> {
let mut rows = vec![ComputedRow::Template, ComputedRow::Alias];
rows.extend((0..self.stats.len()).map(ComputedRow::Stat));
rows
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn node(&self) -> Option<FlowNode> {
let template = self.template.trim();
let alias = self.alias.trim();
if template.is_empty() || alias.is_empty() {
return None;
}
Some(FlowNode::Report(ReportStmt::Computed {
template: template.to_string(),
name: alias.to_string(),
stats: self
.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect(),
}))
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum AssignRow {
Key,
Value,
}
pub(crate) struct AssignForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) key: String,
pub(crate) value: String,
pub(crate) selected: usize,
}
impl AssignForm {
pub(crate) fn visible_rows(&self) -> Vec<AssignRow> {
vec![AssignRow::Key, AssignRow::Value]
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn node(&self) -> Option<FlowNode> {
let key = self.key.trim();
(!key.is_empty()).then(|| FlowNode::Assign {
key: key.to_string(),
value: self.value.trim().to_string(),
})
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ListRow {
Name,
Value(usize),
Add,
}
pub(crate) struct ListForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) name: String,
pub(crate) values: Vec<String>,
pub(crate) selected: usize,
}
impl ListForm {
pub(crate) fn visible_rows(&self) -> Vec<ListRow> {
let mut rows = vec![ListRow::Name];
rows.extend((0..self.values.len()).map(ListRow::Value));
rows.push(ListRow::Add);
rows
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn node(&self) -> Option<FlowNode> {
let name = self.name.trim();
(!name.is_empty()).then(|| FlowNode::ListDecl {
name: name.to_string(),
producer: Producer::List(
self.values
.iter()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.map(|v| Element::Scalar(v.to_string()))
.collect(),
),
})
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum WithFieldRow {
Name,
Query,
Stat(usize),
}
pub(crate) struct WithFieldForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) index: Option<usize>,
pub(crate) name: String,
pub(crate) query: String,
pub(crate) stats: Vec<(StatKind, bool)>,
pub(crate) selected: usize,
}
impl WithFieldForm {
fn build(
report_id: u64,
path: Vec<usize>,
index: Option<usize>,
existing: Option<&WithItem>,
) -> Self {
let (name, query, stats) = match existing {
Some(WithItem::Field { name, query, stats }) => {
(name.clone(), query.clone(), stats.clone())
}
_ => (String::new(), String::new(), Vec::new()),
};
WithFieldForm {
report_id,
path,
index,
name,
query,
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
selected: 0,
}
}
pub(crate) fn visible_rows(&self) -> Vec<WithFieldRow> {
let mut rows = vec![WithFieldRow::Name, WithFieldRow::Query];
rows.extend((0..self.stats.len()).map(WithFieldRow::Stat));
rows
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn item(&self) -> Option<WithItem> {
let name = self.name.trim();
if name.is_empty() {
return None;
}
Some(WithItem::Field {
name: name.to_string(),
query: self.query.trim().to_string(),
stats: self
.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect(),
})
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum EnvsRow {
Var,
Mode,
Parallel,
Degree,
Env(usize),
BaselineShow(usize),
}
pub(crate) struct EnvEntry {
pub(crate) name: String,
pub(crate) baseline: bool,
pub(crate) file: bool,
}
pub(crate) struct EnvsForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) var: String,
pub(crate) compare: bool,
pub(crate) parallel: bool,
pub(crate) entries: Vec<EnvEntry>,
pub(crate) choices: Vec<String>,
pub(crate) snapshots: Vec<String>,
pub(crate) selected: usize,
pub(crate) degree: String,
pub(crate) baseline_show: Vec<ShowRow>,
}
impl EnvsForm {
#[allow(clippy::too_many_arguments)]
fn build(
report_id: u64,
path: Vec<usize>,
var: String,
clause: &EnvClause,
parallel: Option<ParallelSpec>,
choices: Vec<String>,
mut snapshots: Vec<String>,
show_choices: Vec<(String, bool)>,
) -> Self {
let (compare, mut entries, baseline_show_names) = match clause {
EnvClause::Plain(names) => (
false,
names
.iter()
.map(|n| EnvEntry {
name: n.clone(),
baseline: false,
file: false,
})
.collect::<Vec<_>>(),
Vec::new(),
),
EnvClause::Roles {
baseline,
comparisons,
baseline_show,
} => {
let entry = |r: &RoleRef, is_baseline: bool| EnvEntry {
name: r.target().to_string(),
baseline: is_baseline,
file: matches!(r, RoleRef::File(_)),
};
let mut es: Vec<EnvEntry> = baseline.iter().map(|r| entry(r, true)).collect();
es.extend(comparisons.iter().map(|r| entry(r, false)));
(true, es, baseline_show.clone())
}
};
for e in &entries {
if e.file && !e.name.trim().is_empty() && !snapshots.iter().any(|s| s == &e.name) {
snapshots.push(e.name.clone());
}
}
if entries.is_empty() {
entries.push(EnvEntry {
name: choices.first().cloned().unwrap_or_default(),
baseline: compare,
file: false,
});
}
let mut baseline_show: Vec<ShowRow> = show_choices
.into_iter()
.map(|(name, included)| ShowRow { name, included })
.collect();
for name in &baseline_show_names {
if !baseline_show.iter().any(|r| &r.name == name) {
baseline_show.push(ShowRow {
name: name.clone(),
included: true,
});
}
}
EnvsForm {
report_id,
path,
var,
compare,
parallel: parallel.is_some(),
degree: parallel
.and_then(|p| p.degree)
.map(|d| d.to_string())
.unwrap_or_default(),
entries,
choices,
snapshots,
selected: 0,
baseline_show,
}
}
pub(crate) fn visible_rows(&self) -> Vec<EnvsRow> {
let mut rows = vec![EnvsRow::Var, EnvsRow::Mode, EnvsRow::Parallel];
if self.parallel {
rows.push(EnvsRow::Degree);
}
rows.extend((0..self.entries.len()).map(EnvsRow::Env));
if self.compare && self.entries.iter().any(|e| e.baseline) {
rows.extend((0..self.baseline_show.len()).map(EnvsRow::BaselineShow));
}
rows
}
fn parallel_spec(&self) -> Option<ParallelSpec> {
self.parallel.then(|| ParallelSpec {
degree: self.degree.trim().parse::<u32>().ok().filter(|d| *d > 0),
})
}
fn selected_baseline_show(&self) -> Vec<String> {
self.baseline_show
.iter()
.filter(|r| r.included)
.map(|r| r.name.clone())
.collect()
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn cycle_entry(&mut self, i: usize, forward: bool) {
let list = if self.entries[i].file {
&self.snapshots
} else {
&self.choices
};
let n = list.len();
if n == 0 {
return;
}
let cur = &self.entries[i].name;
let next = match list.iter().position(|c| c == cur) {
Some(p) if forward => (p + 1) % n,
Some(p) => (p + n - 1) % n,
None => 0,
};
self.entries[i].name = list[next].clone();
}
fn toggle_file(&mut self, i: usize) {
if !self.compare {
return;
}
let becoming_file = !self.entries[i].file;
self.entries[i].file = becoming_file;
let list = if becoming_file {
&self.snapshots
} else {
&self.choices
};
if !list.iter().any(|c| c == &self.entries[i].name)
&& let Some(first) = list.first()
{
self.entries[i].name = first.clone();
}
}
fn toggle_baseline(&mut self, i: usize) {
if !self.compare {
return;
}
let becoming = !self.entries[i].baseline;
for (j, e) in self.entries.iter_mut().enumerate() {
e.baseline = becoming && j == i;
}
}
fn toggle_mode(&mut self) {
self.compare = !self.compare;
if self.compare
&& !self.entries.iter().any(|e| e.baseline)
&& let Some(first) = self.entries.first_mut()
{
first.baseline = true;
}
}
fn toggle_parallel(&mut self) {
self.parallel = !self.parallel;
}
fn add_entry(&mut self) {
self.entries.push(EnvEntry {
name: self.choices.first().cloned().unwrap_or_default(),
baseline: false,
file: false,
});
}
fn remove_entry(&mut self, i: usize) {
if self.entries.len() > 1 && i < self.entries.len() {
self.entries.remove(i);
}
}
fn var_or_default(&self) -> String {
let v = self.var.trim();
if v.is_empty() {
"TARGET".to_string()
} else {
v.to_string()
}
}
fn clause(&self) -> Option<EnvClause> {
if self.compare {
let refs = |want_baseline: bool| -> Vec<RoleRef> {
self.entries
.iter()
.filter(|e| e.baseline == want_baseline)
.filter(|e| !e.name.trim().is_empty())
.map(|e| {
let name = e.name.trim().to_string();
if e.file {
RoleRef::File(name)
} else {
RoleRef::Env(name)
}
})
.collect()
};
let baseline = refs(true);
let comparisons = refs(false);
if baseline.is_empty() && comparisons.is_empty() {
return None;
}
Some(EnvClause::Roles {
baseline,
comparisons,
baseline_show: self.selected_baseline_show(),
})
} else {
let names: Vec<String> = self
.entries
.iter()
.map(|e| e.name.trim().to_string())
.filter(|n| !n.is_empty())
.collect();
if names.is_empty() {
return None;
}
Some(EnvClause::Plain(names))
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum FilesRow {
Var,
Folder,
Match,
Parallel,
Degree,
}
pub(crate) struct FilesForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) var: String,
pub(crate) dir: String,
pub(crate) glob: String,
pub(crate) parallel: bool,
pub(crate) degree: String,
pub(crate) folders: bool,
pub(crate) roles: Vec<(String, String)>,
pub(crate) selected: usize,
}
impl FilesForm {
#[allow(clippy::too_many_arguments)]
fn build(
report_id: u64,
path: Vec<usize>,
var: String,
dir: String,
glob: Option<String>,
parallel: Option<ParallelSpec>,
folders: bool,
roles: Vec<(String, String)>,
) -> Self {
let selected = if dir.trim().is_empty() { 1 } else { 0 };
FilesForm {
report_id,
path,
var,
dir,
glob: glob.unwrap_or_default(),
parallel: parallel.is_some(),
degree: parallel
.and_then(|p| p.degree)
.map(|d| d.to_string())
.unwrap_or_default(),
folders,
roles,
selected,
}
}
pub(crate) fn visible_rows(&self) -> Vec<FilesRow> {
let mut rows = vec![FilesRow::Var, FilesRow::Folder];
if !self.folders {
rows.push(FilesRow::Match);
}
rows.push(FilesRow::Parallel);
if self.parallel {
rows.push(FilesRow::Degree);
}
rows
}
fn parallel_spec(&self) -> Option<ParallelSpec> {
self.parallel.then(|| ParallelSpec {
degree: self.degree.trim().parse::<u32>().ok().filter(|d| *d > 0),
})
}
fn producer(&self) -> Producer {
if self.folders {
Producer::Folders {
dir: self.dir.clone(),
roles: self.roles.clone(),
}
} else {
Producer::Files {
dir: self.dir.clone(),
glob: self.glob_opt(),
}
}
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn var_or_default(&self) -> String {
let v = self.var.trim();
if v.is_empty() {
"FILE".to_string()
} else {
v.to_string()
}
}
fn glob_opt(&self) -> Option<String> {
let g = self.glob.trim();
if g.is_empty() {
None
} else {
Some(g.to_string())
}
}
fn toggle_parallel(&mut self) {
self.parallel = !self.parallel;
}
}
impl TuiApp {
pub(crate) fn report_index_by_id(&self, id: u64) -> Option<usize> {
self.reports.iter().position(|rt| rt.report.id == id)
}
pub(crate) fn report_node_rows(&self, idx: usize) -> Result<Vec<NodeRow>, String> {
let rt = self.reports.get(idx).ok_or("no report")?;
let flow = rt.report.flow().map_err(|e| e.to_string())?;
let entries = self
.resolve_bound_collection(&rt.report)
.map(|ci| self.collections[ci].entries.as_slice())
.unwrap_or(&[]);
let resolves = |name: &str| crate::report::run::resolve_title(entries, name).is_some();
Ok(flatten(&flow, &resolves))
}
fn bound_request_titles(&self, report_id: u64) -> Vec<String> {
let Some(idx) = self.report_index_by_id(report_id) else {
return Vec::new();
};
self.resolve_bound_collection(&self.reports[idx].report)
.map(|ci| {
self.collections[ci]
.entries
.iter()
.map(|e| e.title.clone())
.collect()
})
.unwrap_or_default()
}
pub(crate) fn on_key_report_nodes(&mut self, key: KeyEvent, idx: usize) -> bool {
let Ok(rows) = self.report_node_rows(idx) else {
return false;
};
let last = rows.len().saturating_sub(1);
let sel = self.reports[idx].node_selected.min(last);
self.reports[idx].node_selected = sel;
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Char('z') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.undo_report_node(idx)
}
KeyCode::Up if shift => self.move_selected_node(idx, true),
KeyCode::Down if shift => self.move_selected_node(idx, false),
KeyCode::Char('K') => self.move_selected_node(idx, true),
KeyCode::Char('J') => self.move_selected_node(idx, false),
KeyCode::Up | KeyCode::Char('k') => {
self.reports[idx].node_selected = sel.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.reports[idx].node_selected = (sel + 1).min(last);
}
KeyCode::Home => self.reports[idx].node_selected = 0,
KeyCode::End => self.reports[idx].node_selected = last,
KeyCode::Char('a') | KeyCode::Insert => self.open_report_node_menu(idx),
KeyCode::Enter => self.configure_selected_node(idx),
KeyCode::Char('e') => self.edit_selected_node(idx),
KeyCode::Delete | KeyCode::Backspace => self.delete_selected_node(idx),
_ => return false,
}
true
}
fn open_report_node_menu(&mut self, idx: usize) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let pos = insert_pos_after(&rows, sel);
let s = Strings::for_language(&self.language);
let options = NodeKind::ALL
.iter()
.map(|k| k.label(&s).to_string())
.collect();
self.overlay = Some(Overlay::ReportNodeMenu(Box::new(NodeMenu {
step: NodeMenuStep::PickKind,
options,
selected: 0,
pos,
report_id: self.reports[idx].report.id,
report_kind: false,
edit_path: None,
})));
}
fn open_report_node_folder(&mut self, idx: usize) -> bool {
let Ok(rows) = self.report_node_rows(idx) else {
return false;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else {
return false;
};
let path = row.path.clone();
let current_dir = {
let Ok(flow) = self.reports[idx].report.flow() else {
return false;
};
match node_at(&flow, &path).and_then(loop_producer_dir) {
Some(dir) => dir.to_string(),
None => return false, }
};
let start = {
let p = std::path::Path::new(¤t_dir);
if !current_dir.is_empty() && p.is_dir() {
Some(p.to_path_buf())
} else if let Some(base) = self.active_report_base_dir() {
let joined = base.join(¤t_dir);
Some(if joined.is_dir() { joined } else { base })
} else {
None
}
};
if let Some(dir) = start {
self.last_browse_dir = Some(dir);
}
self.pending_node_folder = Some((self.reports[idx].report.id, path));
self.open_browser(crate::tui::app::FileAction::PickReportNodeFolder);
true
}
pub(crate) fn commit_report_node_folder(&mut self, dir: &str) {
let Some((report_id, path)) = self.pending_node_folder.take() else {
return;
};
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
{
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return;
};
let Some(node) = node_at_mut(&mut flow, &path) else {
return;
};
match loop_producer_dir_mut(node) {
Some(slot) => *slot = dir.to_string(),
None => return,
}
let text = flow.to_text();
rt.set_text_undoable(text);
}
self.revalidate_report(idx);
self.select_node_path(idx, &path);
self.save_state();
}
fn configure_selected_node(&mut self, idx: usize) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else { return };
if row.kind == RowKind::Begin {
self.open_report_node_menu(idx);
return;
}
let path = row.path.clone();
if self.open_report_node_request(idx) {
return;
}
if self.open_report_node_envs(idx) {
return;
}
if self.open_report_node_files(idx) {
return;
}
if self.open_report_node_vars(idx) {
return;
}
if self.open_report_node_computed(idx) {
return;
}
if self.open_report_node_assign(idx) {
return;
}
if self.open_report_node_list(idx) {
return;
}
if self.open_report_node_folder(idx) {
return;
}
self.open_report_node_line_prompt(idx, &path);
}
fn build_report_node_request_form(
&self,
idx: usize,
path: Vec<usize>,
node: &FlowNode,
) -> Option<RequestForm> {
let report_id = self.reports[idx].report.id;
let (name, report, alias, response, current_show, current_hide, with) = match node {
FlowNode::Request { name } => (
name.clone(),
false,
None,
None,
Vec::new(),
Vec::new(),
Vec::new(),
),
FlowNode::Report(ReportStmt::Request {
name,
alias,
response_fmt,
show,
hide,
with,
}) => (
name.clone(),
true,
alias.clone(),
*response_fmt,
show.clone(),
hide.clone(),
with.clone(),
),
_ => return None,
};
let report_fields = self.request_report_fields(report_id, &name);
let titles = self.bound_request_titles(report_id);
Some(RequestForm::build(
report_id,
path,
name,
titles,
report,
alias,
response,
¤t_show,
&report_fields,
with,
current_hide,
))
}
fn open_report_node_request(&mut self, idx: usize) -> bool {
let Ok(rows) = self.report_node_rows(idx) else {
return false;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else {
return false;
};
let path = row.path.clone();
let Ok(flow) = self.reports[idx].report.flow() else {
return false;
};
let Some(node) = node_at(&flow, &path).cloned() else {
return false;
};
let Some(form) = self.build_report_node_request_form(idx, path, &node) else {
return false; };
self.overlay = Some(Overlay::ReportNodeRequest(Box::new(form)));
true
}
fn request_report_fields(&self, report_id: u64, name: &str) -> Vec<String> {
let Some(idx) = self.report_index_by_id(report_id) else {
return Vec::new();
};
let rt = &self.reports[idx];
let Some(ci) = self.resolve_bound_collection(&rt.report) else {
return Vec::new();
};
crate::report::run::resolve_title(&self.collections[ci].entries, name)
.map(|e| e.reports.iter().map(|(f, _)| f.clone()).collect())
.unwrap_or_default()
}
pub(crate) fn apply_report_node_request(&mut self, form: RequestForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let node = if form.report {
FlowNode::Report(ReportStmt::Request {
name: form.request.clone(),
alias: form.alias_opt(),
response_fmt: form.response,
show: form.show(),
hide: form.hide(),
with: form.with.clone(),
})
} else {
FlowNode::Request {
name: form.request.clone(),
}
};
self.apply_node_replace(idx, &form.path, node);
}
fn open_report_node_vars(&mut self, idx: usize) -> bool {
let Some((report_id, path, node)) = self.selected_node(idx) else {
return false;
};
let (chosen, alias, stats) = match &node {
FlowNode::Report(ReportStmt::Vars(vars)) => (vars.clone(), None, Vec::new()),
FlowNode::Report(ReportStmt::VarAs { var, name, stats }) => {
(vec![var.clone()], Some(name.clone()), stats.clone())
}
_ => return false,
};
let entries = self
.resolve_bound_collection(&self.reports[idx].report)
.map(|ci| self.collections[ci].entries.clone())
.unwrap_or_default();
let in_scope = match self.reports[idx].report.flow() {
Ok(flow) => crate::report::edit::vars_in_scope(&flow, &path, &entries),
Err(_) => Vec::new(),
};
self.overlay = Some(Overlay::ReportNodeVars(Box::new(VarsForm::build(
report_id, path, &chosen, alias, &stats, in_scope,
))));
true
}
pub(crate) fn apply_report_node_vars(&mut self, form: VarsForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let Some(node) = form.node() else { return };
self.apply_node_replace(idx, &form.path, node);
}
pub(crate) fn report_node_vars_key_handler(&mut self, key: KeyEvent, mut form: Box<VarsForm>) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeVars(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_vars(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(VarsRow::Var(vi)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some(row) = form.vars.get_mut(vi)
{
row.included = !row.included;
form.selected = form.selected.min(form.last_row());
}
keep(self, form);
}
Some(VarsRow::Other) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
form.other.push(c)
}
KeyCode::Backspace => {
form.other.pop();
}
_ => {}
}
form.selected = form.selected.min(form.last_row());
keep(self, form);
}
Some(VarsRow::Alias) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
form.alias.push(c)
}
KeyCode::Backspace => {
form.alias.pop();
}
_ => {}
}
keep(self, form);
}
Some(VarsRow::Stat(si)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some((_, on)) = form.stats.get_mut(si)
{
*on = !*on;
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
fn open_report_node_computed(&mut self, idx: usize) -> bool {
let Some((report_id, path, node)) = self.selected_node(idx) else {
return false;
};
let FlowNode::Report(ReportStmt::Computed {
template,
name,
stats,
}) = node
else {
return false;
};
self.overlay = Some(Overlay::ReportNodeComputed(Box::new(ComputedForm {
report_id,
path,
template,
alias: name,
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
selected: 0,
})));
true
}
pub(crate) fn apply_report_node_computed(&mut self, form: ComputedForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let Some(node) = form.node() else { return };
self.apply_node_replace(idx, &form.path, node);
}
pub(crate) fn report_node_computed_key_handler(
&mut self,
key: KeyEvent,
mut form: Box<ComputedForm>,
) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeComputed(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_computed(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(ComputedRow::Template) => {
match key.code {
KeyCode::Char(c) => form.template.push(c),
KeyCode::Backspace => {
form.template.pop();
}
_ => {}
}
keep(self, form);
}
Some(ComputedRow::Alias) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
form.alias.push(c)
}
KeyCode::Backspace => {
form.alias.pop();
}
_ => {}
}
keep(self, form);
}
Some(ComputedRow::Stat(si)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some((_, on)) = form.stats.get_mut(si)
{
*on = !*on;
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
fn open_report_node_assign(&mut self, idx: usize) -> bool {
let Some((report_id, path, node)) = self.selected_node(idx) else {
return false;
};
let FlowNode::Assign { key, value } = node else {
return false;
};
self.overlay = Some(Overlay::ReportNodeAssign(Box::new(AssignForm {
report_id,
path,
key,
value,
selected: 0,
})));
true
}
pub(crate) fn apply_report_node_assign(&mut self, form: AssignForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let Some(node) = form.node() else { return };
self.apply_node_replace(idx, &form.path, node);
}
pub(crate) fn report_node_assign_key_handler(
&mut self,
key: KeyEvent,
mut form: Box<AssignForm>,
) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeAssign(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_assign(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
let target = match rows.get(sel).copied() {
Some(AssignRow::Key) => &mut form.key,
Some(AssignRow::Value) => &mut form.value,
None => {
keep(self, form);
return;
}
};
match key.code {
KeyCode::Char(c) => target.push(c),
KeyCode::Backspace => {
target.pop();
}
_ => {}
}
keep(self, form);
}
}
}
fn open_report_node_list(&mut self, idx: usize) -> bool {
let Some((report_id, path, node)) = self.selected_node(idx) else {
return false;
};
let FlowNode::ListDecl {
name,
producer: Producer::List(elems),
} = node
else {
return false;
};
let mut values = Vec::with_capacity(elems.len());
for e in &elems {
match e {
Element::Scalar(v) => values.push(v.clone()),
Element::Tuple(_) => return false, }
}
self.overlay = Some(Overlay::ReportNodeList(Box::new(ListForm {
report_id,
path,
name,
values,
selected: 0,
})));
true
}
pub(crate) fn apply_report_node_list(&mut self, form: ListForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let Some(node) = form.node() else { return };
self.apply_node_replace(idx, &form.path, node);
}
pub(crate) fn report_node_list_key_handler(&mut self, key: KeyEvent, mut form: Box<ListForm>) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeList(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_list(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(ListRow::Name) => {
match key.code {
KeyCode::Char(c) => form.name.push(c),
KeyCode::Backspace => {
form.name.pop();
}
_ => {}
}
keep(self, form);
}
Some(ListRow::Value(vi)) => {
match key.code {
KeyCode::Delete => {
if vi < form.values.len() {
form.values.remove(vi);
}
form.selected = form.selected.min(form.last_row());
}
KeyCode::Char(c) => form.values[vi].push(c),
KeyCode::Backspace => {
form.values[vi].pop();
}
_ => {}
}
keep(self, form);
}
Some(ListRow::Add) => {
if matches!(key.code, KeyCode::Char(' ')) {
form.values.push(String::new());
form.selected = form.values.len();
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
fn selected_node(&self, idx: usize) -> Option<(u64, Vec<usize>, FlowNode)> {
let rows = self.report_node_rows(idx).ok()?;
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let path = rows.get(sel)?.path.clone();
let flow = self.reports[idx].report.flow().ok()?;
let node = node_at(&flow, &path)?.clone();
Some((self.reports[idx].report.id, path, node))
}
pub(crate) fn report_node_with_field_key_handler(
&mut self,
key: KeyEvent,
mut form: Box<WithFieldForm>,
) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeWithField(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => {
let (report_id, path) = (form.report_id, form.path.clone());
self.apply_report_node_with_field(*form);
self.reopen_report_node_request(report_id, &path);
}
KeyCode::Esc => {
let (report_id, path) = (form.report_id, form.path.clone());
self.reopen_report_node_request(report_id, &path);
}
_ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(WithFieldRow::Name) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
form.name.push(c)
}
KeyCode::Backspace => {
form.name.pop();
}
_ => {}
}
keep(self, form);
}
Some(WithFieldRow::Query) => {
match key.code {
KeyCode::Char(c) => form.query.push(c),
KeyCode::Backspace => {
form.query.pop();
}
_ => {}
}
keep(self, form);
}
Some(WithFieldRow::Stat(si)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some((_, on)) = form.stats.get_mut(si)
{
*on = !*on;
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
pub(crate) fn apply_report_node_with_field(&mut self, form: WithFieldForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let Some(item) = form.item() else {
return;
};
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
let Some(FlowNode::Report(ReportStmt::Request {
name,
alias,
response_fmt,
show,
hide,
with,
})) = node_at(&flow, &form.path)
else {
return;
};
let mut with = with.clone();
match form.index {
Some(i) if i < with.len() => with[i] = item,
_ => with.push(item),
}
let node = FlowNode::Report(ReportStmt::Request {
name: name.clone(),
alias: alias.clone(),
response_fmt: *response_fmt,
show: show.clone(),
hide: hide.clone(),
with,
});
self.apply_node_replace(idx, &form.path, node);
}
fn reopen_report_node_request(&mut self, report_id: u64, path: &[usize]) {
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
let Some(node) = node_at(&flow, path).cloned() else {
return;
};
if let Some(form) = self.build_report_node_request_form(idx, path.to_vec(), &node) {
self.overlay = Some(Overlay::ReportNodeRequest(Box::new(form)));
}
}
fn open_report_node_envs(&mut self, idx: usize) -> bool {
let Ok(rows) = self.report_node_rows(idx) else {
return false;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else {
return false;
};
let path = row.path.clone();
let report_id = self.reports[idx].report.id;
let (var, clause, parallel, body) = {
let Ok(flow) = self.reports[idx].report.flow() else {
return false;
};
match node_at(&flow, &path) {
Some(FlowNode::ForEnvs {
var,
clause,
parallel,
body,
}) => (var.clone(), clause.clone(), *parallel, body.clone()),
_ => return false, }
};
let choices: Vec<String> = self.global_envs.iter().map(|e| e.name.clone()).collect();
let snapshots = self.discover_report_snapshots(idx);
let selected_show = match &clause {
crate::report::flow::EnvClause::Roles { baseline_show, .. } => baseline_show.clone(),
crate::report::flow::EnvClause::Plain(_) => Vec::new(),
};
let show_choices = match self.resolve_bound_collection(&self.reports[idx].report) {
Some(ci) => crate::report::edit::baseline_show_choices(
&self.collections[ci].entries,
&body,
&selected_show,
),
None => crate::report::edit::baseline_show_choices(&[], &body, &selected_show),
};
let form = EnvsForm::build(
report_id,
path,
var,
&clause,
parallel,
choices,
snapshots,
show_choices,
);
self.overlay = Some(Overlay::ReportNodeEnvs(Box::new(form)));
true
}
fn discover_report_snapshots(&self, idx: usize) -> Vec<String> {
let (root, _) = super::reports::report_base_dir(&self.reports[idx].report);
let mut out: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&root) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "baseline")
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
out.push(name.to_string());
}
}
}
out.sort();
out
}
pub(crate) fn apply_report_node_envs(&mut self, form: EnvsForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let Some(clause) = form.clause() else {
return;
};
let body = {
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
match node_at(&flow, &form.path) {
Some(FlowNode::ForEnvs { body, .. }) => body.clone(),
_ => return,
}
};
let node = FlowNode::ForEnvs {
var: form.var_or_default(),
clause,
body,
parallel: form.parallel_spec(),
};
self.apply_node_replace(idx, &form.path, node);
}
fn open_report_node_files(&mut self, idx: usize) -> bool {
let Ok(rows) = self.report_node_rows(idx) else {
return false;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else {
return false;
};
let path = row.path.clone();
let report_id = self.reports[idx].report.id;
let (var, dir, glob, parallel, folders, roles) = {
let Ok(flow) = self.reports[idx].report.flow() else {
return false;
};
match node_at(&flow, &path) {
Some(FlowNode::ForEach {
pattern,
producer: Producer::Files { dir, glob },
parallel,
..
}) if pattern.is_single() => (
pattern.named().next().unwrap_or("FILE").to_string(),
dir.clone(),
glob.clone(),
*parallel,
false,
Vec::new(),
),
Some(FlowNode::ForEach {
pattern,
producer: Producer::Folders { dir, roles },
parallel,
..
}) if pattern.is_single() => (
pattern.named().next().unwrap_or("FOLDER").to_string(),
dir.clone(),
None,
*parallel,
true,
roles.clone(),
),
_ => return false, }
};
let form = FilesForm::build(report_id, path, var, dir, glob, parallel, folders, roles);
self.overlay = Some(Overlay::ReportNodeFiles(Box::new(form)));
true
}
pub(crate) fn apply_report_node_files(&mut self, form: &FilesForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let body = {
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
match node_at(&flow, &form.path) {
Some(FlowNode::ForEach { body, .. }) => body.clone(),
_ => return,
}
};
let node = FlowNode::ForEach {
pattern: Pattern::single(form.var_or_default()),
producer: form.producer(),
body,
parallel: form.parallel_spec(),
};
self.apply_node_replace(idx, &form.path, node);
}
pub(crate) fn report_node_files_key_handler(
&mut self,
key: KeyEvent,
mut form: Box<FilesForm>,
) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeFiles(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
if rows.get(sel).copied() == Some(FilesRow::Folder) {
self.apply_report_node_files(&form);
self.open_files_form_folder(&form);
} else {
self.apply_report_node_files(&form);
}
}
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(FilesRow::Var) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => form.var.push(c),
KeyCode::Backspace => {
form.var.pop();
}
_ => {}
}
keep(self, form);
}
Some(FilesRow::Match) => {
match key.code {
KeyCode::Char(c) => form.glob.push(c),
KeyCode::Backspace => {
form.glob.pop();
}
_ => {}
}
keep(self, form);
}
Some(FilesRow::Folder) => {
if matches!(key.code, KeyCode::Char(' ')) {
self.apply_report_node_files(&form);
self.open_files_form_folder(&form);
} else {
keep(self, form);
}
}
Some(FilesRow::Parallel) => {
if matches!(
key.code,
KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right
) {
form.toggle_parallel();
form.selected = form.selected.min(form.last_row());
}
keep(self, form);
}
Some(FilesRow::Degree) => {
match key.code {
KeyCode::Char(c) if c.is_ascii_digit() => form.degree.push(c),
KeyCode::Backspace => {
form.degree.pop();
}
_ => {}
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
fn open_files_form_folder(&mut self, form: &FilesForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let start = {
let p = std::path::Path::new(&form.dir);
if !form.dir.trim().is_empty() && p.is_dir() {
Some(p.to_path_buf())
} else if let Some(base) = self.active_report_base_dir() {
let joined = base.join(&form.dir);
Some(if joined.is_dir() { joined } else { base })
} else {
None
}
};
if let Some(dir) = start {
self.last_browse_dir = Some(dir);
}
self.pending_node_folder = Some((form.report_id, form.path.clone()));
let _ = idx;
self.open_browser(crate::tui::app::FileAction::PickReportNodeFolder);
}
pub(crate) fn report_node_envs_key_handler(&mut self, key: KeyEvent, mut form: Box<EnvsForm>) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeEnvs(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_envs(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(EnvsRow::Var) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => form.var.push(c),
KeyCode::Backspace => {
form.var.pop();
}
_ => {}
}
keep(self, form);
}
Some(EnvsRow::Mode) => {
if matches!(
key.code,
KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right
) {
form.toggle_mode();
}
keep(self, form);
}
Some(EnvsRow::Parallel) => {
if matches!(
key.code,
KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right
) {
form.toggle_parallel();
form.selected = form.selected.min(form.last_row());
}
keep(self, form);
}
Some(EnvsRow::Degree) => {
match key.code {
KeyCode::Char(c) if c.is_ascii_digit() => form.degree.push(c),
KeyCode::Backspace => {
form.degree.pop();
}
_ => {}
}
keep(self, form);
}
Some(EnvsRow::BaselineShow(fi)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some(row) = form.baseline_show.get_mut(fi)
{
row.included = !row.included;
}
keep(self, form);
}
Some(EnvsRow::Env(ei)) => {
match key.code {
KeyCode::Char(' ') | KeyCode::Right => form.cycle_entry(ei, true),
KeyCode::Left => form.cycle_entry(ei, false),
KeyCode::Char('b') => form.toggle_baseline(ei),
KeyCode::Char('f') => form.toggle_file(ei),
KeyCode::Char('n') => {
form.add_entry();
form.selected = form.last_row();
}
KeyCode::Char('x') | KeyCode::Delete => {
form.remove_entry(ei);
form.selected = form.selected.min(form.last_row());
}
_ => {}
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
pub(crate) fn report_node_request_key_handler(
&mut self,
key: KeyEvent,
mut form: Box<RequestForm>,
) {
let last = form.last_row();
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeRequest(form));
};
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_request(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(FormRow::Name) => match key.code {
KeyCode::Char(' ') | KeyCode::Right => {
form.cycle_name(true);
keep(self, form);
}
KeyCode::Left => {
form.cycle_name(false);
keep(self, form);
}
_ => keep(self, form),
},
Some(FormRow::Report) => {
if matches!(
key.code,
KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right
) {
form.report = !form.report;
form.selected = form.selected.min(form.last_row());
}
keep(self, form);
}
Some(FormRow::Response) => match key.code {
KeyCode::Char(' ') | KeyCode::Right => {
form.cycle_response(true);
keep(self, form);
}
KeyCode::Left => {
form.cycle_response(false);
keep(self, form);
}
_ => keep(self, form),
},
Some(FormRow::Alias) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
form.alias.push(c)
}
KeyCode::Backspace => {
form.alias.pop();
}
_ => {}
}
keep(self, form);
}
Some(FormRow::Field(fi)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some(row) = form.fields.get_mut(fi)
{
row.included = !row.included;
}
keep(self, form);
}
Some(FormRow::Hidden(fi)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some(row) = form.hide_fields.get_mut(fi)
{
row.included = !row.included;
}
keep(self, form);
}
Some(FormRow::With(wi)) => match key.code {
KeyCode::Char(' ') => {
let existing = form.with.get(wi).cloned();
let sub = WithFieldForm::build(
form.report_id,
form.path.clone(),
Some(wi),
existing.as_ref(),
);
self.apply_report_node_request(*form);
self.overlay = Some(Overlay::ReportNodeWithField(Box::new(sub)));
}
KeyCode::Char('x') | KeyCode::Delete => {
if wi < form.with.len() {
form.with.remove(wi);
}
form.selected = form.selected.min(form.last_row());
keep(self, form);
}
_ => keep(self, form),
},
Some(FormRow::AddWith) => {
if matches!(key.code, KeyCode::Char(' ')) {
let sub =
WithFieldForm::build(form.report_id, form.path.clone(), None, None);
self.apply_report_node_request(*form);
self.overlay = Some(Overlay::ReportNodeWithField(Box::new(sub)));
} else {
keep(self, form);
}
}
None => keep(self, form),
}
}
}
}
fn edit_selected_node(&mut self, idx: usize) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else { return };
if row.kind == RowKind::Begin {
self.open_report_node_menu(idx);
return;
}
let path = row.path.clone();
self.open_report_node_line_prompt(idx, &path);
}
fn open_report_node_line_prompt(&mut self, idx: usize, path: &[usize]) {
let report_id = self.reports[idx].report.id;
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
let Some(node) = node_at(&flow, path) else {
return;
};
let line = node.header_line();
let s = Strings::for_language(&self.language);
self.overlay = Some(Overlay::Prompt {
kind: PromptKind::ReportNodeLine {
report_id,
path: path.to_vec(),
},
editor: Editor::new(&line, false),
title: format!(
"{} ({})",
s.report_node_edit_title, s.report_node_edit_hint
),
mask: false,
reset_to: None,
secret_intact: false,
secret_checkbox: None,
});
}
pub(crate) fn report_node_menu_key_handler(&mut self, key: KeyEvent, mut menu: Box<NodeMenu>) {
let last = menu.options.len().saturating_sub(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
menu.selected = menu.selected.saturating_sub(1);
self.overlay = Some(Overlay::ReportNodeMenu(menu));
}
KeyCode::Down | KeyCode::Char('j') => {
menu.selected = (menu.selected + 1).min(last);
self.overlay = Some(Overlay::ReportNodeMenu(menu));
}
KeyCode::Home => {
menu.selected = 0;
self.overlay = Some(Overlay::ReportNodeMenu(menu));
}
KeyCode::End => {
menu.selected = last;
self.overlay = Some(Overlay::ReportNodeMenu(menu));
}
KeyCode::Enter => match menu.step {
NodeMenuStep::PickKind => self.node_menu_pick_kind(*menu),
NodeMenuStep::PickRequest => self.node_menu_pick_request(*menu),
},
_ => {}
}
}
fn node_menu_pick_kind(&mut self, mut menu: NodeMenu) {
let Some(&kind) = NodeKind::ALL.get(menu.selected) else {
return;
};
let Some(idx) = self.report_index_by_id(menu.report_id) else {
return;
};
if kind.needs_request() {
let report_kind = matches!(kind, NodeKind::ReportRequest);
let titles = self.bound_request_titles(menu.report_id);
if titles.is_empty() {
let path = self.apply_node_insert(idx, &menu.pos, request_node("", report_kind));
self.open_report_node_line_prompt(idx, &path);
return;
}
menu.step = NodeMenuStep::PickRequest;
menu.options = titles;
menu.selected = 0;
menu.report_kind = report_kind;
self.overlay = Some(Overlay::ReportNodeMenu(Box::new(menu)));
} else if let Some(node) = kind.template() {
self.apply_node_insert(idx, &menu.pos, node);
self.configure_selected_node(idx);
}
}
fn node_menu_pick_request(&mut self, menu: NodeMenu) {
let Some(idx) = self.report_index_by_id(menu.report_id) else {
return;
};
let Some(name) = menu.options.get(menu.selected) else {
return;
};
let node = request_node(name, menu.report_kind);
match &menu.edit_path {
Some(path) => self.apply_node_replace(idx, path, node),
None => {
self.apply_node_insert(idx, &menu.pos, node);
}
}
}
fn apply_node_insert(&mut self, idx: usize, pos: &InsertPos, node: FlowNode) -> Vec<usize> {
let mut path = pos.parent.clone();
path.push(pos.index);
{
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return path;
};
insert_node(&mut flow, pos, node);
let text = flow.to_text();
rt.set_text_undoable(text);
}
self.revalidate_report(idx);
self.select_node_path(idx, &path);
self.save_state();
path
}
fn apply_node_replace(&mut self, idx: usize, path: &[usize], node: FlowNode) {
{
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return;
};
if !replace_node(&mut flow, path, node) {
return;
}
let text = flow.to_text();
rt.set_text_undoable(text);
}
self.revalidate_report(idx);
self.select_node_path(idx, path);
self.save_state();
}
fn delete_selected_node(&mut self, idx: usize) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else { return };
if row.kind == RowKind::Begin {
return; }
let path = row.path.clone();
{
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return;
};
if !remove_node(&mut flow, &path) {
return;
}
let text = flow.to_text();
rt.set_text_undoable(text);
}
self.revalidate_report(idx);
self.save_state();
}
fn move_selected_node(&mut self, idx: usize, up: bool) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else { return };
if row.kind == RowKind::Begin {
return;
}
let path = row.path.clone();
let new_path = {
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return;
};
let Some(np) = move_node(&mut flow, &path, up) else {
return; };
let text = flow.to_text();
rt.set_text_undoable(text);
np
};
self.revalidate_report(idx);
self.select_node_path(idx, &new_path);
self.save_state();
}
fn undo_report_node(&mut self, idx: usize) {
let Some(snap) = self.reports[idx].node_undo.pop() else {
let s = Strings::for_language(&self.language);
self.status = Some(Status::ReportNodeNothingToUndo(
s.report_node_undo_empty.to_string(),
));
return;
};
{
let rt = &mut self.reports[idx];
rt.report.set_text(snap.text);
rt.node_selected = snap.node_selected;
}
self.revalidate_report(idx);
self.save_state();
let s = Strings::for_language(&self.language);
self.status = Some(Status::ReportNodeUndone(s.report_node_undone.to_string()));
}
pub(crate) fn commit_report_node_line(&mut self, report_id: u64, path: &[usize], text: String) {
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
let was_loop = self.reports[idx]
.report
.flow()
.ok()
.and_then(|flow| node_at(&flow, path).map(FlowNode::is_loop))
.unwrap_or(false);
match parse_one_node(&text, was_loop) {
Some(node) => self.apply_node_replace(idx, path, node),
None => {
let s = Strings::for_language(&self.language);
self.status = Some(Status::ReportRunBlocked(
s.report_node_line_invalid.to_string(),
));
}
}
}
fn select_node_path(&mut self, idx: usize, path: &[usize]) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let target = rows
.iter()
.position(|r| r.path == path && r.kind != RowKind::LoopEnd)
.unwrap_or_else(|| rows.len().saturating_sub(1));
self.reports[idx].node_selected = target;
}
}
pub(crate) fn draw_report_nodes(
f: &mut Frame,
area: Rect,
app: &mut TuiApp,
idx: usize,
s: &Strings,
th: &Theme,
) {
let focused = app.report_body_focused();
let title = format!("{} — {}", s.report_nodes_heading, s.report_nodes_hint);
let block = panel(title, focused, th);
let inner = block.inner(area);
f.render_widget(block, area);
app.report_pane_areas[crate::tui::reports::ReportPane::Source.idx()] = Rect::default();
app.report_pane_bars[crate::tui::reports::ReportPane::Source.idx()] = Rect::default();
if inner.width == 0 || inner.height == 0 {
return;
}
let rows = match app.report_node_rows(idx) {
Ok(rows) => rows,
Err(e) => {
let lines = vec![
Line::from(Span::styled(
s.report_nodes_parse_error,
Style::default().fg(th.err),
)),
Line::from(Span::styled(e, Style::default().fg(th.dim))),
];
f.render_widget(Paragraph::new(lines), inner);
return;
}
};
let sel = app.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
app.reports[idx].node_selected = sel;
let h = inner.height as usize;
let w = inner.width as usize;
let first = if sel >= h { sel + 1 - h } else { 0 };
let lines: Vec<Line> = rows
.iter()
.enumerate()
.skip(first)
.take(h)
.map(|(i, row)| render_node_row(row, i == sel, w, s, th))
.collect();
f.render_widget(Paragraph::new(lines), inner);
app.push_mouse_hit(
MouseLayer::Base,
inner,
MouseHitTarget::Scroll(MouseScrollTarget::ReportPane(
crate::tui::reports::ReportPane::Source,
)),
);
for row in first..rows.len().min(first + h) {
app.push_mouse_hit(
MouseLayer::Base,
Rect::new(inner.x, inner.y + (row - first) as u16, inner.width, 1),
MouseHitTarget::ReportNodeRow(row),
);
}
if rows.len() > h {
let bar = Rect {
x: area.x + area.width - 1,
y: inner.y,
width: 1,
height: inner.height,
};
draw_scrollbar(f, bar, rows.len(), h, first, th);
}
}
fn render_node_row(
row: &NodeRow,
selected: bool,
width: usize,
s: &Strings,
th: &Theme,
) -> Line<'static> {
let indent = " ".repeat(row.depth);
let (text, base, bold) = match row.kind {
RowKind::Begin => (s.report_node_begin.to_string(), th.accent, true),
RowKind::LoopHead => (row.label.clone(), th.accent, false),
RowKind::LoopEnd => ("END".to_string(), th.accent, false),
RowKind::Leaf => (row.label.clone(), th.text, false),
};
let colour = match row.req_ok {
Some(true) => th.ok,
Some(false) => th.pending,
None => base,
};
let mut content = format!("{indent}{text}");
if selected {
let len = content.chars().count();
if len < width {
content.extend(std::iter::repeat_n(' ', width - len));
}
}
let mut style = if selected {
Style::default().fg(th.select_fg).bg(th.select_bg)
} else {
Style::default().fg(colour)
};
if bold {
style = style.add_modifier(Modifier::BOLD);
}
Line::from(Span::styled(content, style))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envs_form_round_trip_preserves_baseline_show() {
let clause = EnvClause::Roles {
baseline: vec![RoleRef::Env("prod".into())],
comparisons: vec![RoleRef::Env("staging".into())],
baseline_show: vec!["Time".into()],
};
let form = EnvsForm::build(1, vec![], "T".into(), &clause, None, vec![], vec![], vec![]);
assert_eq!(
form.selected_baseline_show(),
vec!["Time".to_string()],
"a SHOW field the clause names must come back ticked"
);
let rebuilt = form.clause().expect("clause must be Some");
assert_eq!(
rebuilt,
EnvClause::Roles {
baseline: vec![RoleRef::Env("prod".into())],
comparisons: vec![RoleRef::Env("staging".into())],
baseline_show: vec!["Time".into()],
}
);
}
#[test]
fn envs_form_preserves_and_rebuilds_a_file_role() {
let clause = EnvClause::Roles {
baseline: vec![RoleRef::File("prod.baseline".into())],
comparisons: vec![RoleRef::Env("staging".into())],
baseline_show: vec![],
};
let form = EnvsForm::build(1, vec![], "T".into(), &clause, None, vec![], vec![], vec![]);
assert!(
form.snapshots.iter().any(|s| s == "prod.baseline"),
"existing FILE path must be seeded into the cycle"
);
let rebuilt = form.clause().expect("clause must be Some");
assert_eq!(rebuilt, clause);
}
#[test]
fn envs_form_toggle_file_switches_a_role_to_a_snapshot() {
let clause = EnvClause::Roles {
baseline: vec![RoleRef::Env("prod".into())],
comparisons: vec![RoleRef::Env("staging".into())],
baseline_show: vec![],
};
let mut form = EnvsForm::build(
1,
vec![],
"T".into(),
&clause,
None,
vec!["prod".into(), "staging".into()],
vec!["snap.baseline".into()],
vec![],
);
form.toggle_file(0);
assert!(form.entries[0].file);
assert_eq!(form.entries[0].name, "snap.baseline");
match form.clause().expect("clause") {
EnvClause::Roles { baseline, .. } => {
assert_eq!(baseline, vec![RoleRef::File("snap.baseline".into())]);
}
other => panic!("expected roles, got {other:?}"),
}
form.toggle_file(0);
assert!(!form.entries[0].file);
assert_eq!(form.entries[0].name, "prod");
}
}