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::{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::{
EnvClause, FlowNode, Pattern, Producer, ReportFlow, ReportStmt, ResponseFmt, WithItem,
};
use crate::report::parse_flow;
pub(crate) struct NodeRow {
pub(crate) depth: usize,
pub(crate) label: String,
pub(crate) kind: RowKind,
pub(crate) path: Vec<usize>,
pub(crate) req_ok: Option<bool>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum RowKind {
Begin,
Leaf,
LoopHead,
LoopEnd,
}
pub(crate) fn flatten(flow: &ReportFlow, resolves: &impl Fn(&str) -> bool) -> Vec<NodeRow> {
let mut rows = vec![NodeRow {
depth: 0,
label: String::new(),
kind: RowKind::Begin,
path: Vec::new(),
req_ok: None,
}];
let mut prefix = Vec::new();
push_nodes(&flow.nodes, &mut prefix, 1, resolves, &mut rows);
rows
}
fn push_nodes(
nodes: &[FlowNode],
prefix: &mut Vec<usize>,
depth: usize,
resolves: &impl Fn(&str) -> bool,
rows: &mut Vec<NodeRow>,
) {
for (i, node) in nodes.iter().enumerate() {
prefix.push(i);
let req_ok = node.request_name().map(resolves);
if let Some(body) = loop_body(node) {
rows.push(NodeRow {
depth,
label: node.label(),
kind: RowKind::LoopHead,
path: prefix.clone(),
req_ok,
});
push_nodes(body, prefix, depth + 1, resolves, rows);
rows.push(NodeRow {
depth,
label: String::new(),
kind: RowKind::LoopEnd,
path: prefix.clone(),
req_ok: None,
});
} else {
rows.push(NodeRow {
depth,
label: node.label(),
kind: RowKind::Leaf,
path: prefix.clone(),
req_ok,
});
}
prefix.pop();
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct InsertPos {
pub(crate) parent: Vec<usize>,
pub(crate) index: usize,
}
pub(crate) fn insert_pos_after(rows: &[NodeRow], sel: usize) -> InsertPos {
let Some(row) = rows.get(sel) else {
return InsertPos {
parent: Vec::new(),
index: 0,
};
};
match row.kind {
RowKind::Begin => InsertPos {
parent: Vec::new(),
index: 0,
},
RowKind::LoopHead => InsertPos {
parent: row.path.clone(),
index: 0,
},
RowKind::Leaf | RowKind::LoopEnd => {
let (last, rest) = row.path.split_last().unwrap_or((&0, &[]));
InsertPos {
parent: rest.to_vec(),
index: last + 1,
}
}
}
}
fn loop_body(node: &FlowNode) -> Option<&Vec<FlowNode>> {
match node {
FlowNode::ForEach { body, .. } | FlowNode::ForEnvs { body, .. } => Some(body),
_ => None,
}
}
fn loop_producer_dir(node: &FlowNode) -> Option<&str> {
match node {
FlowNode::ForEach {
producer: Producer::Files { dir, .. } | Producer::Folders { dir, .. },
..
} => Some(dir),
_ => None,
}
}
fn loop_producer_dir_mut(node: &mut FlowNode) -> Option<&mut String> {
match node {
FlowNode::ForEach {
producer: Producer::Files { dir, .. } | Producer::Folders { dir, .. },
..
} => Some(dir),
_ => None,
}
}
fn body_at_mut<'a>(flow: &'a mut ReportFlow, parent: &[usize]) -> Option<&'a mut Vec<FlowNode>> {
let mut body = &mut flow.nodes;
for &i in parent {
body = body.get_mut(i)?.body_mut()?;
}
Some(body)
}
fn node_at<'a>(flow: &'a ReportFlow, path: &[usize]) -> Option<&'a FlowNode> {
let (last, rest) = path.split_last()?;
let mut body = &flow.nodes;
for &i in rest {
body = loop_body(body.get(i)?)?;
}
body.get(*last)
}
fn node_at_mut<'a>(flow: &'a mut ReportFlow, path: &[usize]) -> Option<&'a mut FlowNode> {
let (last, rest) = path.split_last()?;
let body = body_at_mut(flow, rest)?;
body.get_mut(*last)
}
fn insert_node(flow: &mut ReportFlow, pos: &InsertPos, node: FlowNode) {
if let Some(body) = body_at_mut(flow, &pos.parent) {
let idx = pos.index.min(body.len());
body.insert(idx, node);
}
}
fn remove_node(flow: &mut ReportFlow, path: &[usize]) -> bool {
let Some((last, rest)) = path.split_last() else {
return false;
};
if let Some(body) = body_at_mut(flow, rest)
&& *last < body.len()
{
body.remove(*last);
return true;
}
false
}
fn move_node(flow: &mut ReportFlow, path: &[usize], up: bool) -> Option<Vec<usize>> {
let (last, rest) = path.split_last()?;
let body = body_at_mut(flow, rest)?;
let target = if up {
last.checked_sub(1)?
} else if last + 1 < body.len() {
last + 1
} else {
return None;
};
body.swap(*last, target);
let mut new_path = rest.to_vec();
new_path.push(target);
Some(new_path)
}
fn replace_node(flow: &mut ReportFlow, path: &[usize], mut new_node: FlowNode) -> bool {
let Some(slot) = node_at_mut(flow, path) else {
return false;
};
let old_body = slot.body_mut().map(std::mem::take);
if let (Some(ob), Some(nb)) = (old_body, new_node.body_mut()) {
*nb = ob;
}
*slot = new_node;
true
}
fn parse_one_node(text: &str, prefer_loop: bool) -> Option<FlowNode> {
let t = text.trim();
if t.is_empty() {
return None;
}
let bare = format!("{t}\n");
let looped = format!("{t}\nEND\n");
let attempts = if prefer_loop {
[looped, bare]
} else {
[bare, looped]
};
for wrap in attempts {
if let Ok(flow) = parse_flow(&wrap)
&& flow.nodes.len() == 1
{
return flow.nodes.into_iter().next();
}
}
None
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum NodeKind {
Request,
ReportRequest,
ReportVar,
Assign,
ForFiles,
ForFolders,
ForEnvs,
List,
}
impl NodeKind {
pub(crate) const ALL: [NodeKind; 8] = [
NodeKind::Request,
NodeKind::ReportRequest,
NodeKind::ReportVar,
NodeKind::Assign,
NodeKind::ForFiles,
NodeKind::ForFolders,
NodeKind::ForEnvs,
NodeKind::List,
];
fn label(self, s: &Strings) -> &'static str {
match self {
NodeKind::Request => s.node_kind_request,
NodeKind::ReportRequest => s.node_kind_report_request,
NodeKind::ReportVar => s.node_kind_report_var,
NodeKind::Assign => s.node_kind_assign,
NodeKind::ForFiles => s.node_kind_for_files,
NodeKind::ForFolders => s.node_kind_for_folders,
NodeKind::ForEnvs => s.node_kind_for_envs,
NodeKind::List => s.node_kind_list,
}
}
fn needs_request(self) -> bool {
matches!(self, NodeKind::Request | NodeKind::ReportRequest)
}
fn template(self) -> Option<FlowNode> {
Some(match self {
NodeKind::Request | NodeKind::ReportRequest => return None,
NodeKind::ReportVar => FlowNode::Report(ReportStmt::Vars(vec!["VAR".into()])),
NodeKind::Assign => FlowNode::Assign {
key: "NAME".into(),
value: String::new(),
},
NodeKind::ForFiles => FlowNode::ForEach {
pattern: Pattern::single("FILE"),
producer: Producer::Files {
dir: String::new(),
glob: None,
},
body: Vec::new(),
parallel: None,
},
NodeKind::ForFolders => FlowNode::ForEach {
pattern: Pattern::single("FOLDER"),
producer: Producer::Folders {
dir: String::new(),
roles: Vec::new(),
},
body: Vec::new(),
parallel: None,
},
NodeKind::ForEnvs => FlowNode::ForEnvs {
var: "TARGET".into(),
clause: EnvClause::Roles {
baseline: vec!["baseline".into()],
comparisons: vec!["candidate".into()],
baseline_show: Vec::new(),
},
body: Vec::new(),
parallel: None,
},
NodeKind::List => FlowNode::ListDecl {
name: "ITEMS".into(),
producer: Producer::List(Vec::new()),
},
})
}
}
fn request_node(name: &str, report: bool) -> FlowNode {
if report {
FlowNode::Report(ReportStmt::Request {
name: name.to_string(),
alias: None,
response_fmt: None,
show: Vec::new(),
hide: Vec::new(),
with: Vec::new(),
})
} else {
FlowNode::Request {
name: name.to_string(),
}
}
}
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),
}
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: Vec<String>,
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);
}
let all = current_show.is_empty();
let fields = names
.into_iter()
.map(|name| {
let included = all || current_show.iter().any(|s| s == &name);
ShowRow { name, included }
})
.collect();
RequestForm {
report_id,
path,
request,
titles,
report,
response,
alias: alias.unwrap_or_default(),
fields,
with,
hide,
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
}
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 EnvsRow {
Var,
Mode,
Env(usize),
}
pub(crate) struct EnvEntry {
pub(crate) name: String,
pub(crate) baseline: 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) entries: Vec<EnvEntry>,
pub(crate) choices: Vec<String>,
pub(crate) selected: usize,
pub(crate) baseline_show: Vec<String>,
}
impl EnvsForm {
fn build(
report_id: u64,
path: Vec<usize>,
var: String,
clause: &EnvClause,
choices: Vec<String>,
) -> Self {
let (compare, mut entries, baseline_show) = match clause {
EnvClause::Plain(names) => (
false,
names
.iter()
.map(|n| EnvEntry {
name: n.clone(),
baseline: false,
})
.collect::<Vec<_>>(),
Vec::new(),
),
EnvClause::Roles {
baseline,
comparisons,
baseline_show,
} => {
let mut es: Vec<EnvEntry> = baseline
.iter()
.map(|n| EnvEntry {
name: n.clone(),
baseline: true,
})
.collect();
es.extend(comparisons.iter().map(|n| EnvEntry {
name: n.clone(),
baseline: false,
}));
(true, es, baseline_show.clone())
}
};
if entries.is_empty() {
entries.push(EnvEntry {
name: choices.first().cloned().unwrap_or_default(),
baseline: compare,
});
}
EnvsForm {
report_id,
path,
var,
compare,
entries,
choices,
selected: 0,
baseline_show,
}
}
pub(crate) fn visible_rows(&self) -> Vec<EnvsRow> {
let mut rows = vec![EnvsRow::Var, EnvsRow::Mode];
rows.extend((0..self.entries.len()).map(EnvsRow::Env));
rows
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn cycle_entry(&mut self, i: usize, forward: bool) {
let n = self.choices.len();
if n == 0 {
return;
}
let cur = &self.entries[i].name;
let next = match self.choices.iter().position(|c| c == cur) {
Some(p) if forward => (p + 1) % n,
Some(p) => (p + n - 1) % n,
None => 0,
};
self.entries[i].name = self.choices[next].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 add_entry(&mut self) {
self.entries.push(EnvEntry {
name: self.choices.first().cloned().unwrap_or_default(),
baseline: 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> {
let named = |want_baseline: Option<bool>| -> Vec<String> {
self.entries
.iter()
.filter(|e| want_baseline.is_none_or(|b| e.baseline == b))
.map(|e| e.name.trim().to_string())
.filter(|n| !n.is_empty())
.collect()
};
if self.compare {
let baseline = named(Some(true));
let comparisons = named(Some(false));
if baseline.is_empty() && comparisons.is_empty() {
return None;
}
Some(EnvClause::Roles {
baseline,
comparisons,
baseline_show: self.baseline_show.clone(),
})
} else {
let names = named(None);
if names.is_empty() {
return None;
}
Some(EnvClause::Plain(names))
}
}
}
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_folder(idx) {
return;
}
self.open_report_node_line_prompt(idx, &path);
}
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 report_id = self.reports[idx].report.id;
let (name, report, alias, response, current_show, current_hide, with) = {
let Ok(flow) = self.reports[idx].report.flow() else {
return false;
};
match node_at(&flow, &path) {
Some(FlowNode::Request { name }) => (
name.clone(),
false,
None,
None,
Vec::new(),
Vec::new(),
Vec::new(),
),
Some(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 false, }
};
let report_fields = self.request_report_fields(report_id, &name);
let titles = self.bound_request_titles(report_id);
let form = RequestForm::build(
report_id,
path,
name,
titles,
report,
alias,
response,
¤t_show,
&report_fields,
with,
current_hide,
);
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.clone(),
with: form.with.clone(),
})
} else {
FlowNode::Request {
name: form.request.clone(),
}
};
self.apply_node_replace(idx, &form.path, node);
}
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) = {
let Ok(flow) = self.reports[idx].report.flow() else {
return false;
};
match node_at(&flow, &path) {
Some(FlowNode::ForEnvs { var, clause, .. }) => (var.clone(), clause.clone()),
_ => return false, }
};
let choices: Vec<String> = self.global_envs.iter().map(|e| e.name.clone()).collect();
let form = EnvsForm::build(report_id, path, var, &clause, choices);
self.overlay = Some(Overlay::ReportNodeEnvs(Box::new(form)));
true
}
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, parallel) = {
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
match node_at(&flow, &form.path) {
Some(FlowNode::ForEnvs { body, parallel, .. }) => (body.clone(), *parallel),
_ => return,
}
};
let node = FlowNode::ForEnvs {
var: form.var_or_default(),
clause,
body,
parallel,
};
self.apply_node_replace(idx, &form.path, node);
}
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::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('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);
}
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() {
let path = self.apply_node_insert(idx, &menu.pos, node);
self.open_report_node_line_prompt(idx, &path);
}
}
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);
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);
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::*;
fn flow(src: &str) -> ReportFlow {
parse_flow(src).expect("test flow must parse")
}
fn always_ok(_: &str) -> bool {
true
}
#[test]
fn flatten_marks_begin_body_and_loop_end() {
let f = flow("REQUEST A\nFOR X IN FILES \"/d\"\n REQUEST B\nEND\n");
let rows = flatten(&f, &always_ok);
let kinds: Vec<RowKind> = rows.iter().map(|r| r.kind).collect();
assert_eq!(
kinds,
vec![
RowKind::Begin,
RowKind::Leaf,
RowKind::LoopHead,
RowKind::Leaf,
RowKind::LoopEnd,
]
);
assert_eq!(rows[2].path, rows[4].path);
assert_eq!(rows[3].path, vec![1, 0]);
}
#[test]
fn insert_pos_after_targets_the_right_body() {
let f = flow("REQUEST A\nFOR X IN FILES \"/d\"\n REQUEST B\nEND\n");
let rows = flatten(&f, &always_ok);
let p = insert_pos_after(&rows, 0);
assert_eq!(p.parent, Vec::<usize>::new());
assert_eq!(p.index, 0);
let p = insert_pos_after(&rows, 1);
assert_eq!(p.parent, Vec::<usize>::new());
assert_eq!(p.index, 1);
let p = insert_pos_after(&rows, 2);
assert_eq!(p.parent, vec![1]);
assert_eq!(p.index, 0);
let p = insert_pos_after(&rows, 4);
assert_eq!(p.parent, Vec::<usize>::new());
assert_eq!(p.index, 2);
}
#[test]
fn insert_and_remove_round_trip() {
let mut f = flow("REQUEST A\n");
let pos = InsertPos {
parent: Vec::new(),
index: 1,
};
insert_node(&mut f, &pos, request_node("B", false));
assert_eq!(
f.nodes.iter().map(|n| n.request_name()).collect::<Vec<_>>(),
vec![Some("A"), Some("B")]
);
assert!(remove_node(&mut f, &[0]));
assert_eq!(
f.nodes.iter().map(|n| n.request_name()).collect::<Vec<_>>(),
vec![Some("B")]
);
}
#[test]
fn move_node_swaps_siblings_and_reports_boundaries() {
let mut f = flow("REQUEST A\nREQUEST B\n");
let np = move_node(&mut f, &[1], true).expect("can move up");
assert_eq!(np, vec![0]);
assert_eq!(
f.nodes.iter().map(|n| n.request_name()).collect::<Vec<_>>(),
vec![Some("B"), Some("A")]
);
assert!(move_node(&mut f, &[0], true).is_none());
}
#[test]
fn replace_node_keeps_a_loops_body() {
let mut f = flow("FOR X IN FILES \"/a\"\n REQUEST Inner\nEND\n");
let edited = parse_one_node("FOR X IN FILES \"/b\"", true).expect("loop parses");
assert!(replace_node(&mut f, &[0], edited));
let body = match &f.nodes[0] {
FlowNode::ForEach { body, .. } => body,
other => panic!("expected a loop, got {other:?}"),
};
assert_eq!(body.len(), 1);
assert_eq!(body[0].request_name(), Some("Inner"));
assert!(f.to_text().contains("\"/b\""));
}
#[test]
fn parse_one_node_needs_exactly_one_statement() {
assert!(parse_one_node("REQUEST A", false).is_some());
assert!(parse_one_node("FOR X IN FILES \"/d\"", true).is_some());
assert!(parse_one_node("REQUEST A\nREQUEST B", false).is_none());
assert!(parse_one_node("FOR", false).is_none());
assert!(parse_one_node(" ", false).is_none());
}
#[test]
fn parse_one_node_accepts_report_var_as() {
use crate::report::flow::{FlowNode, ReportStmt};
match parse_one_node("REPORT FILE AS \"Pretty name\"", false) {
Some(FlowNode::Report(ReportStmt::VarAs { var, name })) => {
assert_eq!(var, "FILE");
assert_eq!(name, "Pretty name");
}
other => panic!("expected a VarAs node, got {other:?}"),
}
}
#[test]
fn envs_form_round_trip_preserves_baseline_show() {
let clause = EnvClause::Roles {
baseline: vec!["prod".into()],
comparisons: vec!["staging".into()],
baseline_show: vec!["Time".into()],
};
let form = EnvsForm::build(1, vec![], "T".into(), &clause, vec![]);
assert_eq!(form.baseline_show, vec!["Time".to_string()]);
let rebuilt = form.clause().expect("clause must be Some");
assert_eq!(
rebuilt,
EnvClause::Roles {
baseline: vec!["prod".into()],
comparisons: vec!["staging".into()],
baseline_show: vec!["Time".into()],
}
);
}
}