use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
use tui_panel_select::{MultiSelectPanel, WrapMode};
use super::app::{
ConfirmAction, MouseHitTarget, MouseLayer, MouseScrollTarget, Overlay, Pane, PromptKind, TuiApp,
};
use super::draw::{centered_rect, panel};
use super::editor::{
Editor, apply_edit_key_full, render_editor_highlighted, word_left, word_right,
};
use super::new_request::draw_scrollbar;
use super::report_nodes::{SettingMenu, SettingMenuStep};
use super::theme::Theme;
use crate::i18n::{Status, Strings};
use crate::report::Report;
use crate::report::flow::Header;
use crate::report::indent::{
INDENT_UNIT, ReformatError, indent_for_new_line, is_end_line, matching_opener_indent,
};
use crate::report::model::{ReportResult, ReportRow, TARGET_COLUMN, parse_columns};
use crate::report::params::ParamRow;
use crate::report::run::{
DryRunner, EntryRunner, LiveRunner, RowEvent, RunContext, finalize, run_flow, run_flow_raw,
};
use crate::report::validate::{Diagnostic, Severity};
use crate::report::writer::{
CsvWriter, export_path, report_output_extension, writer_for_extension,
};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
pub(crate) enum ReportRunUpdate {
Skeleton {
report_id: u64,
result: ReportResult,
},
RowStarted {
report_id: u64,
path: Vec<(usize, usize)>,
},
Row { report_id: u64, row: Box<ReportRow> },
Done {
report_id: u64,
result: ReportResult,
},
}
impl ReportRunUpdate {
fn report_id(&self) -> u64 {
match self {
ReportRunUpdate::Skeleton { report_id, .. }
| ReportRunUpdate::RowStarted { report_id, .. }
| ReportRunUpdate::Row { report_id, .. }
| ReportRunUpdate::Done { report_id, .. } => *report_id,
}
}
}
use crate::report::context::ReportRunInputs;
struct CancellableRunner<R: EntryRunner> {
inner: R,
cancel: Arc<AtomicBool>,
}
impl<R: EntryRunner> EntryRunner for CancellableRunner<R> {
fn run(
&self,
base: &crate::hurl::HurlEntry,
vars: &HashMap<String, String>,
) -> crate::hurl::RunOutput {
if self.cancel.load(Ordering::Relaxed) {
return crate::hurl::RunOutput {
entries: Vec::new(),
error: Some("cancelled".to_string()),
};
}
self.inner.run(base, vars)
}
}
pub(crate) struct ReportTab {
pub(crate) report: Report,
pub(crate) diagnostics: Vec<Diagnostic>,
pub(crate) helpers: Vec<crate::report::run::HelperCollection>,
pub(crate) parse_error: Option<String>,
pub(crate) parse_error_line: Option<usize>,
pub(crate) editor: Option<Editor>,
pub(crate) edit_cursor: Option<(usize, usize)>,
pub(crate) source_panel: MultiSelectPanel,
pub(crate) validation_panel: MultiSelectPanel,
pub(crate) view: ReportView,
pub(crate) editor_view: ReportView,
pub(crate) node_selected: usize,
pub(crate) node_setting: Option<usize>,
pub(crate) node_undo: Vec<NodeUndo>,
pub(crate) param_values: crate::report::params::ParamValues,
pub(crate) params_seeded: bool,
pub(crate) params_open: bool,
pub(crate) params_answered: Option<String>,
pub(crate) param_selected: usize,
pub(crate) result_params: Vec<(String, String)>,
pub(crate) result: Option<ReportResult>,
pub(crate) dry_run: Option<Box<DryRunReport>>,
pub(crate) results_exported: bool,
pub(crate) last_export: Option<std::path::PathBuf>,
pub(crate) run_progress: Option<RunProgress>,
pub(crate) results_panel: MultiSelectPanel,
pub(crate) cell_cursor: Option<(usize, usize)>,
pub(crate) results_scrolled_to: Option<(usize, usize)>,
pub(crate) results_filter: crate::report::filter::RowFilter,
pub(crate) results_col_offset: usize,
pub(crate) workspace_root: Option<std::path::PathBuf>,
pub(crate) embedded_active: bool,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum RowState {
Scheduled,
Running,
Finished,
}
pub(crate) struct RunProgress {
pub(crate) states: Vec<RowState>,
pub(crate) index: HashMap<Vec<(usize, usize)>, usize>,
pub(crate) done: usize,
}
#[derive(Clone)]
pub(crate) struct NodeUndo {
pub(crate) text: String,
pub(crate) node_selected: usize,
}
const NODE_UNDO_LIMIT: usize = 200;
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub(crate) enum ReportView {
#[default]
Source,
Nodes,
Results,
}
impl ReportView {
pub(crate) fn is_editor(self) -> bool {
matches!(self, ReportView::Source | ReportView::Nodes)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ReportPane {
Source,
Validation,
Results,
}
impl ReportPane {
pub(crate) fn idx(self) -> usize {
match self {
ReportPane::Source => 0,
ReportPane::Validation => 1,
ReportPane::Results => 2,
}
}
pub(crate) const ALL: [ReportPane; 3] = [
ReportPane::Source,
ReportPane::Validation,
ReportPane::Results,
];
}
impl ReportTab {
pub(crate) fn new(report: Report) -> Self {
let mut results_panel = MultiSelectPanel::new();
results_panel.set_wrap_mode(WrapMode::Clip);
let mut source_panel = MultiSelectPanel::new();
source_panel.set_wrap_mode(WrapMode::Clip);
Self {
report,
diagnostics: Vec::new(),
helpers: Vec::new(),
parse_error: None,
parse_error_line: None,
editor: None,
edit_cursor: None,
source_panel,
validation_panel: MultiSelectPanel::new(),
view: ReportView::Source,
editor_view: ReportView::Source,
node_selected: 0,
node_setting: None,
node_undo: Vec::new(),
param_values: Default::default(),
result_params: Vec::new(),
params_seeded: false,
params_open: false,
params_answered: None,
param_selected: 0,
result: None,
dry_run: None,
results_exported: false,
last_export: None,
run_progress: None,
results_panel,
cell_cursor: None,
results_scrolled_to: None,
results_filter: crate::report::filter::RowFilter::All,
results_col_offset: 0,
workspace_root: None,
embedded_active: true,
}
}
pub(crate) fn new_in_workspace(report: Report, root: std::path::PathBuf) -> Self {
let mut rt = Self::new(report);
rt.workspace_root = Some(root);
rt
}
pub(crate) fn set_text_undoable(&mut self, new_text: String) {
self.node_undo.push(NodeUndo {
text: self.report.text.clone(),
node_selected: self.node_selected,
});
if self.node_undo.len() > NODE_UNDO_LIMIT {
let overflow = self.node_undo.len() - NODE_UNDO_LIMIT;
self.node_undo.drain(0..overflow);
}
self.report.set_text(new_text);
}
pub(crate) fn visible_result_rows(&self) -> Vec<usize> {
let Some(result) = &self.result else {
return Vec::new();
};
let n = result.rows.len();
if self.run_progress.is_some()
|| self.results_filter == crate::report::filter::RowFilter::All
{
return (0..n).collect();
}
let header = self.report.flow().map(|f| f.header).unwrap_or_default();
let columns = result.resolved_columns(&header);
let labels = crate::report::labels::LabelMap::parse(&header.labels());
crate::report::filter::visible_rows(result, &columns, &labels, &self.results_filter, "")
}
pub(crate) fn result_filters(&self) -> Vec<crate::report::filter::RowFilter> {
match &self.result {
Some(result) if self.run_progress.is_none() => {
crate::report::filter::RowFilter::available(result)
}
_ => Vec::new(),
}
}
}
impl TuiApp {
pub(crate) fn standalone_report_indices(&self) -> Vec<usize> {
self.reports
.iter()
.enumerate()
.filter(|(_, rt)| rt.workspace_root.is_none())
.map(|(i, _)| i)
.collect()
}
pub(crate) fn embedded_report_index(&self, ci: usize) -> Option<usize> {
let root = self.collections.get(ci)?.workspace_root.as_deref()?;
self.reports
.iter()
.position(|rt| rt.embedded_active && rt.workspace_root.as_deref() == Some(root))
}
pub(crate) fn tab_count(&self) -> usize {
self.collections.len() + self.standalone_report_indices().len()
}
pub(crate) fn active_is_report(&self) -> bool {
self.active_report_index().is_some()
}
pub(crate) fn active_is_strip_report(&self) -> bool {
self.active_tab >= self.collections.len()
}
pub(crate) fn active_report_index(&self) -> Option<usize> {
let c = self.collections.len();
if self.active_tab >= c {
self.standalone_report_indices()
.into_iter()
.nth(self.active_tab - c)
} else {
self.embedded_report_index(self.active_tab)
}
}
pub(crate) fn active_report(&self) -> Option<&ReportTab> {
self.active_report_index().and_then(|i| self.reports.get(i))
}
pub(crate) fn report_body_focused(&self) -> bool {
if self.active_is_strip_report() {
true
} else {
self.focus == Pane::Main
}
}
pub(crate) fn new_report_tab(&mut self) {
let s = Strings::for_language(&self.language);
let report = Report::scratch(s.report_default_name);
self.reports.push(ReportTab::new(report));
self.active_tab = self.collections.len() + self.standalone_report_indices().len() - 1;
self.focus = Pane::Tabs;
let idx = self.reports.len() - 1;
self.revalidate_report(idx);
self.save_state();
}
pub(crate) fn open_loaded_report(&mut self, report: Report) {
self.reports.push(ReportTab::new(report));
self.active_tab = self.collections.len() + self.standalone_report_indices().len() - 1;
self.focus = Pane::Tabs;
let idx = self.reports.len() - 1;
self.revalidate_report(idx);
self.save_state();
self.status = Some(Status::Loaded);
}
pub(crate) fn show_embedded_report(
&mut self,
path: std::path::PathBuf,
root: std::path::PathBuf,
) {
let ci = self.workspace_collection_tab(root.clone());
for rt in self
.reports
.iter_mut()
.filter(|rt| rt.workspace_root.as_deref() == Some(root.as_path()))
{
rt.embedded_active = false;
}
if let Some(i) = self.reports.iter().position(|rt| {
rt.workspace_root.as_deref() == Some(root.as_path())
&& rt.report.path.as_deref() == Some(path.as_path())
}) {
self.reports[i].embedded_active = true;
self.revalidate_report(i);
} else {
match Report::load_local(&path) {
Ok(report) => {
self.reports.push(ReportTab::new_in_workspace(report, root));
let idx = self.reports.len() - 1;
self.revalidate_report(idx);
}
Err(e) => {
self.status = Some(Status::Error(e));
return;
}
}
}
self.finish_show_embedded_report(ci);
}
fn finish_show_embedded_report(&mut self, ci: usize) {
self.active_tab = ci;
self.focus = Pane::List;
}
pub(crate) fn workspace_collection_tab(&mut self, root: std::path::PathBuf) -> usize {
if let Some(ci) = self
.collections
.iter()
.position(|c| c.workspace_root.as_deref() == Some(root.as_path()))
{
return ci;
}
let name = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| root.to_string_lossy().into_owned());
let mut col = crate::collection::Collection::new(name, Vec::new());
col.workspace_root = Some(root);
self.collections.push(col);
self.collections.len() - 1
}
pub(crate) fn focus_workspace_tree_on_report(&mut self, ci: usize, path: &std::path::Path) {
let Some(col) = self.collections.get_mut(ci) else {
return;
};
let Some(root) = col.workspace_root.clone() else {
return;
};
if let Some(parent) = path.parent()
&& let Ok(rel) = parent.strip_prefix(&root)
{
let mut cur = root.clone();
for component in rel.components() {
cur.push(component);
col.workspace_expanded.insert(cur.clone());
}
}
col.list_cursor = col
.ws_rows()
.iter()
.position(
|r| matches!(r, crate::collection::WsRow::Report { path: p, .. } if p == path),
)
.unwrap_or(0);
}
fn discard_embedded_report(&mut self, i: usize) {
let rt = self.reports.remove(i);
let report_id = rt.report.id;
if let Some(cancel) = self.running_reports.remove(&report_id) {
cancel.store(true, Ordering::Relaxed);
self.pending_report_runs.retain(|(id, _)| *id != report_id);
}
}
pub(crate) fn discard_embedded_reports_for_root(&mut self, root: &std::path::Path) {
while let Some(i) = self
.reports
.iter()
.position(|rt| rt.workspace_root.as_deref() == Some(root))
{
self.discard_embedded_report(i);
}
}
pub(crate) fn hide_embedded_report_for_root(&mut self, root: &std::path::Path) {
for rt in self
.reports
.iter_mut()
.filter(|rt| rt.workspace_root.as_deref() == Some(root))
{
rt.embedded_active = false;
}
}
pub(crate) fn create_workspace_report(&mut self, ci: usize, rel: String) {
let Some(root) = self
.collections
.get(ci)
.and_then(|c| c.workspace_root.clone())
else {
return;
};
let rel = rel.trim();
if rel.is_empty() {
return;
}
let mut rel_path = std::path::PathBuf::from(rel);
if rel_path.extension().is_none() {
rel_path.set_extension("trail");
}
let safe = rel_path.components().all(|c| {
matches!(
c,
std::path::Component::Normal(_) | std::path::Component::CurDir
)
});
if !safe {
self.status = Some(Status::Error(rel_path.display().to_string()));
return;
}
let full_path = root.join(&rel_path);
if crate::workspace::escapes_root(&root, &full_path) {
self.status = Some(Status::WorkspaceReportEscaped(
rel_path.display().to_string(),
));
return;
}
let s = Strings::for_language(&self.language);
let mut report = Report::scratch(s.report_default_name);
if let Some(parent) = full_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
self.status = Some(Status::Error(format!("{}: {e}", parent.display())));
return;
}
if let Err(e) = report.save_local(&full_path) {
self.status = Some(Status::Error(e));
return;
}
self.show_embedded_report(full_path.clone(), root);
self.focus_workspace_tree_on_report(ci, &full_path);
self.save_state();
self.status = Some(Status::WorkspaceReportCreated(
rel_path.display().to_string(),
));
}
pub(crate) fn create_report_at_path(&mut self, path: &std::path::Path) {
let mut path = path.to_path_buf();
if path.extension().is_none() {
path.set_extension("trail");
}
let enclosing = self
.collections
.iter()
.enumerate()
.filter_map(|(ci, c)| {
let root = c.workspace_root.as_ref()?;
let rel = path.strip_prefix(root).ok()?;
Some((ci, root.components().count(), rel.to_path_buf()))
})
.max_by_key(|(_, depth, _)| *depth);
if let Some((ci, _, rel)) = enclosing
&& let Some(rel_str) = rel.to_str()
{
self.create_workspace_report(ci, rel_str.to_string());
return;
}
let s = Strings::for_language(&self.language);
let mut report = Report::scratch(s.report_default_name);
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& let Err(e) = std::fs::create_dir_all(parent)
{
self.status = Some(Status::Error(format!("{}: {e}", parent.display())));
return;
}
if let Err(e) = report.save_local(&path) {
self.status = Some(Status::Error(e));
return;
}
self.open_loaded_report(report);
}
pub(crate) fn resolve_bound_collection(&self, report: &Report) -> Option<usize> {
let cref = report.collection_ref()?;
if cref.starts_with("git:") {
return None;
}
let target = resolve_ref_path(report.path.as_deref(), &cref);
self.collections.iter().position(|c| {
c.path.as_ref().is_some_and(|p| paths_equal(p, &target)) || c.name == cref
})
}
pub(crate) fn revalidate_report(&mut self, idx: usize) {
self.seed_report_params(idx);
let (parse_error, parse_error_line, diagnostics, helpers) = {
let Some(rt) = self.reports.get(idx) else {
return;
};
match rt.report.flow() {
Err(e) => (Some(e.to_string()), Some(e.line), Vec::new(), Vec::new()),
Ok(flow) => {
let diags = crate::report::context::report_diagnostics(
&self.collections,
&self.global_envs,
self.active_env_id,
&flow,
rt.report.path.as_deref(),
&crate::i18n::Strings::for_language(&self.language),
);
let (helpers, _) = crate::report::context::load_helpers(
&self.collections,
&flow,
rt.report.path.as_deref(),
&crate::i18n::Strings::for_language(&self.language),
);
(None, None, diags, helpers)
}
}
};
if let Some(rt) = self.reports.get_mut(idx) {
rt.parse_error = parse_error;
rt.parse_error_line = parse_error_line;
rt.diagnostics = diagnostics;
rt.helpers = helpers;
}
}
pub(crate) fn report_run_blocker(&self, idx: usize) -> Option<String> {
let s = Strings::for_language(&self.language);
let rt = self.reports.get(idx)?;
if let Some(err) = &rt.parse_error {
return Some(format!("{} {err}", s.report_run_parse_error));
}
if self.resolve_bound_collection(&rt.report).is_none() {
return Some(s.report_run_unbound.to_string());
}
if rt.diagnostics.iter().any(|d| d.severity == Severity::Error) {
return Some(s.report_run_has_errors.to_string());
}
None
}
#[cfg(test)]
pub(crate) fn run_report_flow(
&self,
idx: usize,
runner: &dyn EntryRunner,
) -> Result<ReportResult, String> {
if let Some(reason) = self.report_run_blocker(idx) {
return Err(reason);
}
self.flow_result(idx, runner)
}
pub(crate) fn dry_run_report_flow(&self, idx: usize) -> Result<ReportResult, String> {
let s = Strings::for_language(&self.language);
let rt = self.reports.get(idx).ok_or(s.report_run_unbound)?;
if let Some(err) = &rt.parse_error {
return Err(format!("{} {err}", s.report_run_parse_error));
}
if self.resolve_bound_collection(&rt.report).is_none() {
return Err(s.report_run_unbound.to_string());
}
self.flow_result(idx, &DryRunner)
}
fn flow_result(&self, idx: usize, runner: &dyn EntryRunner) -> Result<ReportResult, String> {
let inputs = self.build_report_run_inputs(idx)?;
let strings = Strings::for_language(&self.language);
let ctx = RunContext {
entries: &inputs.entries,
helpers: &inputs.helpers,
base_vars: inputs.base_vars,
named_envs: inputs.named_envs,
root: inputs.root,
runner,
strings: &strings,
params: inputs.params,
sink: None,
};
Ok(run_flow(&inputs.flow, &ctx))
}
fn build_report_run_inputs(&self, idx: usize) -> Result<ReportRunInputs, String> {
let s = Strings::for_language(&self.language);
let rt = self.reports.get(idx).ok_or(s.report_run_unbound)?;
let flow = rt.report.flow().map_err(|e| e.to_string())?;
crate::report::context::report_run_inputs(
&self.collections,
&self.global_envs,
self.active_env_id,
&flow,
rt.report.path.as_deref(),
)
.map(|mut inputs| {
inputs.language = self.language.clone();
inputs.params = self
.report_param_rows(idx)
.into_iter()
.map(|r| (r.name, r.value))
.collect();
inputs
})
.map_err(|e| match e {
crate::report::context::RunInputError::Unbound => s.report_run_unbound.to_string(),
})
}
pub(crate) fn run_active_report(&mut self) {
if self.report_run_needs_settings() {
self.open_report_run_settings();
self.status = Some(Status::ReportRunSettingsFirst);
return;
}
if self.rerun_would_discard_unexported() {
self.overlay = Some(Overlay::Confirm {
action: ConfirmAction::RerunReport,
sel: 0,
});
return;
}
self.start_active_report_run();
}
pub(crate) fn start_active_report_run(&mut self) {
if let Some((report_id, inputs)) = self.prepare_report_run() {
self.spawn_report_run(report_id, inputs, |file_root| LiveRunner { file_root });
}
}
pub(crate) fn report_run_needs_settings(&self) -> bool {
let Some(idx) = self.active_report_index() else {
return false;
};
!self
.running_reports
.contains_key(&self.reports[idx].report.id)
&& !self.reports[idx].params_open
&& !self.report_params_answered(idx)
&& self.report_has_params(idx)
}
pub(crate) fn rerun_would_discard_unexported(&self) -> bool {
let Some(idx) = self.active_report_index() else {
return false;
};
let rt = &self.reports[idx];
rt.result.is_some()
&& !rt.results_exported
&& !self.running_reports.contains_key(&rt.report.id)
}
#[cfg(test)]
pub(crate) fn start_report_run_faked<R, F>(&mut self, make_runner: F)
where
R: EntryRunner + Send + Sync + 'static,
F: FnOnce(Option<PathBuf>) -> R + Send + 'static,
{
if let Some((report_id, inputs)) = self.prepare_report_run() {
self.spawn_report_run(report_id, inputs, make_runner);
}
}
fn prepare_report_run(&mut self) -> Option<(u64, ReportRunInputs)> {
let idx = self.active_report_index()?;
let report_id = self.reports[idx].report.id;
if let Some(cancel) = self.running_reports.remove(&report_id) {
cancel.store(true, Ordering::Relaxed);
self.pending_report_runs.retain(|(id, _)| *id != report_id);
let rt = &mut self.reports[idx];
rt.run_progress = None;
self.status = Some(Status::ReportRunStopped);
return None;
}
if let Some(reason) = self.report_run_blocker(idx) {
self.status = Some(Status::ReportRunBlocked(reason));
return None;
}
self.seed_report_params(idx);
if self.report_run_needs_settings() {
self.reports[idx].params_open = true;
self.status = Some(Status::ReportRunSettingsFirst);
return None;
}
if let Some(problem) = self
.report_param_rows(idx)
.into_iter()
.find_map(|r| r.problem.map(|p| format!("{}: {p}", r.prompt)))
{
self.status = Some(Status::ReportRunBlocked(problem));
self.seed_report_params(idx);
self.reports[idx].params_open = true;
return None;
}
self.remember_active_report_params(idx);
self.record_result_params(idx);
match self.build_report_run_inputs(idx) {
Ok(inputs) => Some((report_id, inputs)),
Err(reason) => {
self.status = Some(Status::ReportRunBlocked(reason));
None
}
}
}
pub(crate) fn record_result_params(&mut self, idx: usize) {
let now: Vec<(String, String)> = self
.report_param_rows(idx)
.into_iter()
.map(|r| (r.name, r.value))
.collect();
if let Some(rt) = self.reports.get_mut(idx) {
rt.result_params = now;
}
}
pub(crate) fn report_params_changed_since_result(&self, idx: usize) -> bool {
let Some(rt) = self.reports.get(idx) else {
return false;
};
if rt.result_params.is_empty() || (rt.result.is_none() && rt.dry_run.is_none()) {
return false;
}
let now: Vec<(String, String)> = self
.report_param_rows(idx)
.into_iter()
.map(|r| (r.name, r.value))
.collect();
now != rt.result_params
}
fn spawn_report_run<R, F>(&mut self, report_id: u64, inputs: ReportRunInputs, make_runner: F)
where
R: EntryRunner + Send + Sync + 'static,
F: FnOnce(Option<PathBuf>) -> R + Send + 'static,
{
let cancel = Arc::new(AtomicBool::new(false));
let cancel_worker = cancel.clone();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let ReportRunInputs {
flow,
entries,
helpers,
base_vars,
named_envs,
root,
file_root,
language,
params,
} = inputs;
let strings = crate::i18n::Strings::for_language(&language);
let skeleton = {
let dry_ctx = RunContext {
entries: &entries,
helpers: &helpers,
base_vars: base_vars.clone(),
named_envs: named_envs.clone(),
root: root.clone(),
runner: &DryRunner,
strings: &strings,
params: params.clone(),
sink: None,
};
run_flow_raw(&flow, &dry_ctx)
};
if tx
.send(ReportRunUpdate::Skeleton {
report_id,
result: skeleton,
})
.is_err()
{
return; }
let runner = CancellableRunner {
inner: make_runner(file_root),
cancel: cancel_worker,
};
let row_tx = Mutex::new(tx.clone());
let sink = move |ev: RowEvent| {
if let Ok(tx) = row_tx.lock() {
let msg = match ev {
RowEvent::Started(path) => ReportRunUpdate::RowStarted {
report_id,
path: path.to_vec(),
},
RowEvent::Completed(row) => ReportRunUpdate::Row {
report_id,
row: Box::new(row.clone()),
},
};
let _ = tx.send(msg);
}
};
let ctx = RunContext {
entries: &entries,
helpers: &helpers,
base_vars,
named_envs,
root,
runner: &runner,
strings: &strings,
params: params.clone(),
sink: Some(&sink),
};
let mut result = run_flow_raw(&flow, &ctx);
finalize(&mut result, &flow, &ctx);
let _ = tx.send(ReportRunUpdate::Done { report_id, result });
});
self.running_reports.insert(report_id, cancel);
self.pending_report_runs.push((report_id, rx));
self.status = Some(Status::ReportRunning);
}
pub(crate) fn poll_report_run_updates(&mut self) {
if self.pending_report_runs.is_empty() {
return;
}
let mut still = Vec::new();
for (report_id, rx) in std::mem::take(&mut self.pending_report_runs) {
let mut alive = true;
loop {
match rx.try_recv() {
Ok(update) => self.apply_report_run_update(update),
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.running_reports.remove(&report_id);
if let Some(rt) = self
.report_index_by_id(report_id)
.map(|i| &mut self.reports[i])
{
rt.run_progress = None;
}
alive = false;
break;
}
}
}
if alive {
still.push((report_id, rx));
}
}
self.pending_report_runs = still;
}
fn apply_report_run_update(&mut self, update: ReportRunUpdate) {
let report_id = update.report_id();
match update {
ReportRunUpdate::Skeleton { result, .. } => {
if self.report_run_cancelled(report_id) {
return;
}
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
let n = result.rows.len();
let index = result
.rows
.iter()
.enumerate()
.map(|(i, row)| (row.path.clone(), i))
.collect();
let rt = &mut self.reports[idx];
let mut result = result;
result.pending = (0..n).collect();
rt.result = Some(result);
rt.dry_run = None;
rt.results_exported = false;
rt.last_export = None;
rt.run_progress = Some(RunProgress {
states: vec![RowState::Scheduled; n],
index,
done: 0,
});
rt.cell_cursor = None;
rt.results_col_offset = 0;
rt.results_filter = crate::report::filter::RowFilter::All;
if rt.editor.is_none() {
rt.view = ReportView::Results;
rt.results_panel.set_scroll(0);
}
self.status = Some(Status::ReportRunProgress { done: 0, total: n });
}
ReportRunUpdate::RowStarted { path, .. } => {
if self.report_run_cancelled(report_id) {
return;
}
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
let rt = &mut self.reports[idx];
let Some(prog) = rt.run_progress.as_mut() else {
return;
};
if let Some(&ri) = prog.index.get(&path)
&& prog.states.get(ri) == Some(&RowState::Scheduled)
{
prog.states[ri] = RowState::Running;
}
}
ReportRunUpdate::Row { row, .. } => {
if self.report_run_cancelled(report_id) {
return;
}
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
let rt = &mut self.reports[idx];
let (Some(result), Some(prog)) = (rt.result.as_mut(), rt.run_progress.as_mut())
else {
return;
};
if let Some(&ri) = prog.index.get(&row.path)
&& ri < result.rows.len()
{
result.rows[ri] = *row;
result.pending.remove(&ri);
if prog.states[ri] != RowState::Finished {
prog.states[ri] = RowState::Finished;
prog.done += 1;
}
}
let done = prog.done;
let total = prog.states.len();
self.status = Some(Status::ReportRunProgress { done, total });
}
ReportRunUpdate::Done { result, .. } => {
let cancelled = self
.running_reports
.remove(&report_id)
.map(|c| c.load(Ordering::Relaxed))
.unwrap_or(false);
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
let rt = &mut self.reports[idx];
let _progress = rt.run_progress.take();
if cancelled {
self.status = Some(Status::ReportRunStopped);
return;
}
let rows = result.rows.len();
let errors = result.errors.len();
rt.result = Some(result);
rt.dry_run = None;
rt.results_exported = false;
rt.last_export = None;
rt.cell_cursor = None;
rt.results_col_offset = 0;
rt.results_filter = crate::report::filter::RowFilter::All;
if rt.editor.is_none() {
rt.view = ReportView::Results;
rt.results_panel.set_scroll(0);
}
self.status = Some(Status::ReportRunDone { rows, errors });
}
}
}
fn report_run_cancelled(&self, report_id: u64) -> bool {
self.running_reports
.get(&report_id)
.map(|c| c.load(Ordering::Relaxed))
.unwrap_or(true)
}
#[cfg(test)]
pub(crate) fn apply_report_run(&mut self, idx: usize, runner: &dyn EntryRunner) {
match self.run_report_flow(idx, runner) {
Ok(result) => {
let rows = result.rows.len();
let errors = result.errors.len();
let rt = &mut self.reports[idx];
rt.result = Some(result);
rt.dry_run = None;
rt.results_exported = false;
rt.last_export = None;
rt.cell_cursor = None;
rt.results_col_offset = 0;
rt.results_filter = crate::report::filter::RowFilter::All;
rt.view = ReportView::Results;
rt.results_panel.set_scroll(0);
self.status = Some(Status::ReportRunDone { rows, errors });
}
Err(reason) => self.status = Some(Status::ReportRunBlocked(reason)),
}
}
pub(crate) fn report_param_rows(&self, idx: usize) -> Vec<ParamRow> {
let Some(rt) = self.reports.get(idx) else {
return Vec::new();
};
let Ok(flow) = rt.report.flow() else {
return Vec::new();
};
let s = Strings::for_language(&self.language);
crate::report::params::rows(&flow.params(), &rt.param_values, &s)
}
pub(crate) fn report_has_params(&self, idx: usize) -> bool {
self.reports
.get(idx)
.and_then(|rt| rt.report.flow().ok())
.is_some_and(|f| !f.params().is_empty())
}
pub(crate) fn open_report_run_settings(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
if self.reports[idx].params_open {
self.reports[idx].params_open = false;
return;
}
if !self.report_has_params(idx) {
self.status = Some(Status::ReportRunBlocked(
Strings::for_language(&self.language)
.param_none_declared
.to_string(),
));
return;
}
self.seed_report_params(idx);
self.reports[idx].params_open = true;
}
pub(crate) fn report_params_answered(&self, idx: usize) -> bool {
self.reports[idx]
.params_answered
.as_ref()
.is_some_and(|sig| Some(sig) == self.report_param_signature(idx).as_ref())
}
fn report_param_signature(&self, idx: usize) -> Option<String> {
let flow = self.reports[idx].report.flow().ok()?;
Some(
flow.params()
.iter()
.map(|p| {
format!(
"{}:{:?}:{}",
p.name,
p.kind,
p.default.as_deref().unwrap_or("")
)
})
.collect::<Vec<_>>()
.join("\n"),
)
}
pub(crate) fn answer_report_params(&mut self, idx: usize) {
self.reports[idx].params_answered = self.report_param_signature(idx);
self.reports[idx].params_open = false;
}
pub(crate) fn report_param_spans<'a>(
&self,
idx: usize,
s: &'a Strings,
th: &Theme,
) -> Vec<Span<'a>> {
let mut spans = Vec::new();
for (i, r) in self.report_param_rows(idx).iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" · ", Style::default().fg(th.dim)));
}
spans.push(Span::styled(
format!("{}=", r.name),
Style::default().fg(th.dim),
));
let (text, color) = if r.problem.is_some() {
(s.param_value_unset.to_string(), th.err)
} else if r.value.is_empty() {
(s.param_value_unset.to_string(), th.pending)
} else {
(r.value.clone(), th.text)
};
spans.push(Span::styled(text, Style::default().fg(color)));
}
spans
}
pub(crate) fn seed_report_params(&mut self, idx: usize) {
if self.reports.get(idx).is_none_or(|rt| rt.params_seeded) {
return;
}
let key = self.reports[idx].report.param_key();
let remembered = self.remembered_params(&key);
let declared: Vec<(String, Option<String>)> = self.reports[idx]
.report
.flow()
.map(|f| {
f.params()
.into_iter()
.map(|p| (p.name.clone(), p.default.clone()))
.collect()
})
.unwrap_or_default();
let rt = &mut self.reports[idx];
for (name, default) in declared {
if rt.param_values.contains_key(&name) {
continue;
}
let value = remembered
.get(&name)
.cloned()
.or(default)
.unwrap_or_default();
rt.param_values.insert(name, value);
}
rt.params_seeded = true;
}
pub(crate) fn param_cursor_move(&mut self, delta: i32) {
let Some(idx) = self.active_report_index() else {
return;
};
let last = self.report_param_rows(idx).len().saturating_sub(1);
let rt = &mut self.reports[idx];
let next = (rt.param_selected as i32 + delta).clamp(0, last as i32);
rt.param_selected = next as usize;
}
pub(crate) fn configure_selected_param(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
let rows = self.report_param_rows(idx);
let sel = self.reports[idx].param_selected.min(rows.len());
let Some(row) = rows.get(sel) else { return };
let report_id = self.reports[idx].report.id;
match &row.kind {
crate::report::flow::ParamKind::Choice(options) if !options.is_empty() => {
let selected = options.iter().position(|o| *o == row.value).unwrap_or(0);
self.overlay = Some(Overlay::ReportSettingMenu(Box::new(SettingMenu {
step: SettingMenuStep::PickParam {
name: row.name.clone(),
},
options: options.clone(),
filter: String::new(),
selected,
report_id,
})));
}
crate::report::flow::ParamKind::Env => {
let options: Vec<String> =
self.global_envs.iter().map(|e| e.name.clone()).collect();
if options.is_empty() {
self.status = Some(Status::ReportSettingNoChoices);
self.open_param_text_prompt(report_id, row);
return;
}
let selected = options.iter().position(|o| *o == row.value).unwrap_or(0);
self.overlay = Some(Overlay::ReportSettingMenu(Box::new(SettingMenu {
step: SettingMenuStep::PickParam {
name: row.name.clone(),
},
options,
filter: String::new(),
selected,
report_id,
})));
}
crate::report::flow::ParamKind::Folder | crate::report::flow::ParamKind::File => {
self.pending_param_path = Some((report_id, row.name.clone()));
if let Some(dir) = self.active_report_base_dir() {
self.last_browse_dir = Some(dir);
}
self.open_browser(match row.kind {
crate::report::flow::ParamKind::Folder => {
super::app::FileAction::PickReportParamFolder
}
_ => super::app::FileAction::PickReportParamFile,
});
}
_ => self.open_param_text_prompt(report_id, row),
}
}
fn open_param_text_prompt(&mut self, report_id: u64, row: &ParamRow) {
self.overlay = Some(Overlay::Prompt {
kind: PromptKind::ReportParamValue {
report_id,
name: row.name.clone(),
},
editor: Editor::new(&row.value, false),
title: row.prompt.clone(),
mask: false,
reset_to: None,
secret_intact: false,
secret_checkbox: None,
});
}
pub(crate) fn set_report_param(&mut self, report_id: u64, name: &str, value: String) {
if let Some(rt) = self.reports.iter_mut().find(|rt| rt.report.id == report_id) {
rt.param_values.insert(name.to_string(), value);
}
}
fn remember_active_report_params(&mut self, idx: usize) {
let rows = self.report_param_rows(idx);
if rows.is_empty() {
return;
}
let key = self.reports[idx].report.param_key();
let values: crate::report::params::ParamValues =
rows.into_iter().map(|r| (r.name, r.value)).collect();
if self.session.remember_params(&key, &values) {
self.save_state();
}
}
fn on_key_report_run_settings(&mut self, key: KeyEvent, idx: usize) -> bool {
match key.code {
KeyCode::Up | KeyCode::Char('k') => self.param_cursor_move(-1),
KeyCode::Down | KeyCode::Char('j') => self.param_cursor_move(1),
KeyCode::Home => self.param_cursor_move(i32::MIN),
KeyCode::End => self.param_cursor_move(i32::MAX),
KeyCode::Enter | KeyCode::Char(' ') => self.configure_selected_param(),
KeyCode::Char('r') | KeyCode::F(5) => {
self.answer_report_params(idx);
self.run_active_report();
}
KeyCode::Char('d') => {
self.answer_report_params(idx);
self.open_report_dry_run();
}
KeyCode::Esc | KeyCode::Char('p') => self.reports[idx].params_open = false,
_ => return false,
}
true
}
pub(crate) fn toggle_report_view(&mut self) {
if let Some(idx) = self.active_report_index() {
let rt = &mut self.reports[idx];
rt.view = match rt.view {
v if v.is_editor() && rt.result.is_some() => {
rt.editor_view = v;
ReportView::Results
}
ReportView::Results => rt.editor_view,
v => v,
};
}
}
pub(crate) fn open_report_node_editor(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
if self.reports[idx].report.flow().is_err() {
self.enter_report_edit();
return;
}
let rt = &mut self.reports[idx];
rt.view = ReportView::Nodes;
rt.editor_view = ReportView::Nodes;
}
pub(crate) fn export_active_report_csv(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
let s = Strings::for_language(&self.language);
if self.reports[idx].result.is_none() {
self.status = Some(Status::ReportRunBlocked(
s.report_export_no_result.to_string(),
));
return;
}
self.open_browser(super::app::FileAction::SaveReportCsvChooseFolder);
}
pub(crate) fn default_report_csv_filename(&self) -> String {
match self.active_report_index() {
Some(idx) => export_path(
&self.reports[idx].report,
&report_output_extension(&self.reports[idx].report),
)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "report.csv".to_string()),
None => "report.csv".to_string(),
}
}
pub(crate) fn active_report_base_dir(&self) -> Option<std::path::PathBuf> {
self.active_report_index()
.map(|idx| report_base_dir(&self.reports[idx].report).0)
}
pub(crate) fn write_active_report_csv(&mut self, path: &std::path::Path) {
let Some(idx) = self.active_report_index() else {
return;
};
let s = Strings::for_language(&self.language);
let rt = &self.reports[idx];
let Some(result) = &rt.result else {
self.status = Some(Status::ReportRunBlocked(
s.report_export_no_result.to_string(),
));
return;
};
let header = rt.report.flow().map(|f| f.header).unwrap_or_default();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("csv")
.to_ascii_lowercase();
let writer = writer_for_extension(&ext).unwrap_or_else(|| Box::new(CsvWriter));
let bytes = match writer.write(result, &header) {
Ok(b) => b,
Err(e) => {
self.status = Some(Status::Error(format!("{}: {e}", path.display())));
return;
}
};
match std::fs::write(path, bytes) {
Ok(()) => {
self.reports[idx].results_exported = true;
self.reports[idx].last_export = Some(path.to_path_buf());
self.status = Some(Status::ReportExported(path.display().to_string()));
}
Err(e) => self.status = Some(Status::Error(format!("{}: {e}", path.display()))),
}
}
pub(crate) fn save_active_report_baseline(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
let s = Strings::for_language(&self.language);
if self.reports[idx].result.is_none() {
self.status = Some(Status::ReportRunBlocked(
s.report_baseline_no_result.to_string(),
));
return;
}
self.open_browser(super::app::FileAction::SaveReportBaselineChooseFolder);
}
pub(crate) fn default_report_baseline_filename(&self) -> String {
match self.active_report_index() {
Some(idx) => export_path(&self.reports[idx].report, "baseline")
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "report.baseline".to_string()),
None => "report.baseline".to_string(),
}
}
pub(crate) fn write_active_report_baseline(&mut self, path: &std::path::Path) {
let Some(idx) = self.active_report_index() else {
return;
};
let s = Strings::for_language(&self.language);
let Some(result) = &self.reports[idx].result else {
self.status = Some(Status::ReportRunBlocked(
s.report_baseline_no_result.to_string(),
));
return;
};
let baseline = crate::report::Baseline::from_result(result);
match baseline.save(path) {
Ok(()) => {
self.reports[idx].results_exported = true;
self.status = Some(Status::ReportBaselineSaved(path.display().to_string()));
}
Err(e) => self.status = Some(Status::Error(format!("{}: {e}", path.display()))),
}
}
pub(crate) fn open_exported_report(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
let s = Strings::for_language(&self.language);
let Some(path) = self.reports[idx].last_export.clone() else {
self.status = Some(Status::ReportRunBlocked(
s.report_open_no_export.to_string(),
));
return;
};
match crate::shared_utils::open_in_desktop(&path) {
Ok(()) => self.status = Some(Status::ReportOpened(path.display().to_string())),
Err(e) => self.status = Some(Status::Error(format!("{}: {e}", path.display()))),
}
}
pub(crate) fn open_report_dry_run(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
if self.report_run_needs_settings() {
self.open_report_run_settings();
self.status = Some(Status::ReportRunSettingsFirst);
return;
}
self.seed_report_params(idx);
self.record_result_params(idx);
match self.dry_run_report_flow(idx) {
Ok(result) => {
let header = self.reports[idx]
.report
.flow()
.map(|f| f.header)
.unwrap_or_default();
let var_warnings: Vec<String> = self.reports[idx]
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Warning)
.map(|d| d.message.clone())
.collect();
let preview = DryRunReport::from_result(result, header, var_warnings);
self.reports[idx].dry_run = Some(Box::new(preview));
self.reports[idx].view = ReportView::Results;
self.reports[idx].results_panel.set_scroll(0);
self.reports[idx].results_col_offset = 0;
}
Err(reason) => self.status = Some(Status::ReportRunBlocked(reason)),
}
}
fn preview_scroll_cols(&mut self, dc: i32) {
let Some(idx) = self.active_report_index() else {
return;
};
let Some(preview) = self.reports[idx].dry_run.as_ref() else {
return;
};
let ncols = preview.result.resolved_columns(&preview.header).len();
if ncols == 0 {
return;
}
let cur = self.reports[idx].results_col_offset as i32;
self.reports[idx].results_col_offset = (cur + dc).clamp(0, ncols as i32 - 1) as usize;
}
pub(crate) fn result_cursor_move(&mut self, dr: i32, dc: i32) {
let Some(idx) = self.active_report_index() else {
return;
};
let Some(result) = &self.reports[idx].result else {
return;
};
let header = self.reports[idx]
.report
.flow()
.map(|f| f.header)
.unwrap_or_default();
let ncols = result.resolved_columns(&header).len();
let nrows = self.reports[idx].visible_result_rows().len();
if nrows == 0 || ncols == 0 {
return;
}
let (cur_row, cur_col) = self.reports[idx].cell_cursor.unwrap_or((0, 0));
let new_row = (cur_row as i32 + dr).clamp(0, nrows as i32 - 1) as usize;
let new_col = (cur_col as i32 + dc).clamp(0, ncols as i32 - 1) as usize;
self.reports[idx].cell_cursor = Some((new_row, new_col));
}
pub(crate) fn cycle_report_row_filter(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
if self.reports[idx].view != ReportView::Results {
return;
}
let filters = self.reports[idx].result_filters();
if filters.len() < 2 {
return;
}
let at = filters
.iter()
.position(|f| *f == self.reports[idx].results_filter)
.unwrap_or(0);
let next = filters[(at + 1) % filters.len()].clone();
self.reports[idx].results_filter = next;
self.reports[idx].cell_cursor = None;
self.reports[idx].results_scrolled_to = None;
self.reports[idx].results_panel.set_scroll(0);
}
pub(crate) fn result_cursor_page(&mut self, dir: i32) {
let inner_h = self.report_pane_areas[ReportPane::Results.idx()].height;
let page = (inner_h.saturating_sub(2)).max(1) as i32;
self.result_cursor_move(dir * page, 0);
}
fn result_cursor_jump_home(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
if !self.reports[idx].visible_result_rows().is_empty() {
let col = self.reports[idx].cell_cursor.map(|(_, c)| c).unwrap_or(0);
self.reports[idx].cell_cursor = Some((0, col));
}
}
fn result_cursor_jump_end(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
let nrows = self.reports[idx].visible_result_rows().len();
if nrows > 0 {
let col = self.reports[idx].cell_cursor.map(|(_, c)| c).unwrap_or(0);
self.reports[idx].cell_cursor = Some((nrows - 1, col));
}
}
pub(crate) fn open_result_cell_popup(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
let Some((row, col)) = self.reports[idx].cell_cursor else {
self.result_cursor_move(0, 0); return;
};
let visible = self.reports[idx].visible_result_rows();
let Some(result) = &self.reports[idx].result else {
return;
};
let header = self.reports[idx]
.report
.flow()
.map(|f| f.header)
.unwrap_or_default();
let columns = result.resolved_columns(&header);
let Some(col_def) = columns.get(col) else {
return;
};
let Some(data_row) = visible.get(row).and_then(|&r| result.rows.get(r)) else {
return;
};
let title = col_def.header.clone();
let content = col_def.value(data_row, &result.no_match_marker);
let content = pretty_print_json_cell(&content);
let mut panel = Box::new(MultiSelectPanel::new());
panel.set_wrap_mode(WrapMode::Wrap);
self.overlay = Some(Overlay::ReportCellPopup {
title,
content,
panel,
});
}
pub(crate) fn result_cell_popup_key_handler(
&mut self,
key: KeyEvent,
title: String,
content: String,
mut panel: Box<MultiSelectPanel>,
) {
let keep = |app: &mut TuiApp, title, content, panel| {
app.overlay = Some(Overlay::ReportCellPopup {
title,
content,
panel,
});
};
match key.code {
KeyCode::Esc => {}
KeyCode::Up => {
panel.set_scroll(panel.scroll().saturating_sub(1));
keep(self, title, content, panel);
}
KeyCode::Down => {
panel.set_scroll(panel.scroll().saturating_add(1));
keep(self, title, content, panel);
}
KeyCode::PageUp => {
panel.set_scroll(panel.scroll().saturating_sub(10));
keep(self, title, content, panel);
}
KeyCode::PageDown => {
panel.set_scroll(panel.scroll().saturating_add(10));
keep(self, title, content, panel);
}
KeyCode::Home => {
panel.set_scroll(0);
keep(self, title, content, panel);
}
KeyCode::End => {
panel.set_scroll(u16::MAX);
keep(self, title, content, panel);
}
KeyCode::Char('y') => {
use crate::tui::clipboard::copy_to_clipboard;
let text = panel
.selected_text(None)
.filter(|t| !t.is_empty())
.unwrap_or_else(|| content.clone());
if !text.is_empty() {
copy_to_clipboard(&text);
self.status = Some(crate::i18n::Status::Copied);
}
keep(self, title, content, panel);
}
_ => keep(self, title, content, panel),
}
}
pub(crate) fn reformat_active_report(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
match crate::report::indent::reformat(&self.reports[idx].report.text) {
Ok(Some(text)) => {
self.reports[idx].set_text_undoable(text);
self.revalidate_report(idx);
self.save_state();
self.status = Some(Status::ReportReformatted);
}
Ok(None) => self.status = Some(Status::ReportAlreadyTidy),
Err(ReformatError::Unparseable(msg)) => {
self.status = Some(Status::ReportReformatFailed(msg));
}
Err(ReformatError::WouldChangeMeaning) => {
let s = Strings::for_language(&self.language);
self.status = Some(Status::ReportReformatFailed(
s.report_reformat_unsafe.to_string(),
));
}
}
}
pub(crate) fn open_report_columns(&mut self) {
let Some(idx) = self.active_report_index() else {
return;
};
let rt = &self.reports[idx];
let Some(result) = &rt.result else {
self.status = Some(Status::ReportColumnsNeedRun);
return;
};
let Ok(flow) = rt.report.flow() else {
self.status = Some(Status::ReportColumnsNeedRun);
return;
};
let picker = ColumnPicker::build(&flow.header, result);
self.overlay = Some(Overlay::ReportColumns(Box::new(picker)));
}
pub(crate) fn report_columns_key_handler(
&mut self,
key: KeyEvent,
mut picker: Box<ColumnPicker>,
) {
let keep = |app: &mut TuiApp, picker| {
app.overlay = Some(Overlay::ReportColumns(picker));
};
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let last = picker.rows.len().saturating_sub(1);
match key.code {
KeyCode::Up if shift => {
if picker.selected > 0 {
picker.rows.swap(picker.selected, picker.selected - 1);
picker.selected -= 1;
}
keep(self, picker);
}
KeyCode::Down if shift => {
if picker.selected < last {
picker.rows.swap(picker.selected, picker.selected + 1);
picker.selected += 1;
}
keep(self, picker);
}
KeyCode::Up => {
picker.selected = picker.selected.saturating_sub(1);
keep(self, picker);
}
KeyCode::Down => {
picker.selected = (picker.selected + 1).min(last);
keep(self, picker);
}
KeyCode::Home => {
picker.selected = 0;
keep(self, picker);
}
KeyCode::End => {
picker.selected = last;
keep(self, picker);
}
KeyCode::Char(' ') | KeyCode::Char('x') => {
if let Some(row) = picker.rows.get_mut(picker.selected) {
row.included = !row.included;
}
keep(self, picker);
}
KeyCode::Enter => self.apply_report_columns(picker),
_ => {}
}
}
fn apply_report_columns(&mut self, picker: Box<ColumnPicker>) {
let Some(idx) = self.active_report_index() else {
return;
};
let Some(spec) = picker.spec() else {
self.status = Some(Status::ReportColumnsNoneSelected);
self.overlay = Some(Overlay::ReportColumns(picker));
return;
};
let new_text = set_flow_columns_directive(&self.reports[idx].report.text, &spec);
self.reports[idx].report.text = new_text;
self.revalidate_report(idx);
self.save_state();
self.status = Some(Status::ReportColumnsApplied);
}
pub(crate) fn open_report_bind(&mut self) {
if self.active_report_index().is_none() {
self.status = Some(Status::NotReport);
return;
}
if self.collections.is_empty() {
self.status = Some(Status::ReportBindNoCollections);
return;
}
let options: Vec<BindOption> = self
.collections
.iter()
.map(|c| BindOption {
name: c.name.clone(),
path: c.path.clone(),
})
.collect();
let selected = self
.active_report()
.and_then(|rt| self.resolve_bound_collection(&rt.report))
.unwrap_or(0)
.min(options.len().saturating_sub(1));
self.overlay = Some(Overlay::ReportBind(Box::new(ReportBindPicker {
options,
selected,
})));
}
pub(crate) fn report_bind_key_handler(
&mut self,
key: KeyEvent,
mut picker: Box<ReportBindPicker>,
) {
let last = picker.options.len().saturating_sub(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
picker.selected = picker.selected.saturating_sub(1);
self.overlay = Some(Overlay::ReportBind(picker));
}
KeyCode::Down | KeyCode::Char('j') => {
picker.selected = (picker.selected + 1).min(last);
self.overlay = Some(Overlay::ReportBind(picker));
}
KeyCode::Home => {
picker.selected = 0;
self.overlay = Some(Overlay::ReportBind(picker));
}
KeyCode::End => {
picker.selected = last;
self.overlay = Some(Overlay::ReportBind(picker));
}
KeyCode::Enter => self.apply_report_bind(*picker),
_ => {}
}
}
fn apply_report_bind(&mut self, picker: ReportBindPicker) {
let Some(idx) = self.active_report_index() else {
return;
};
let Some(option) = picker.options.get(picker.selected) else {
return;
};
let report_path = self.reports[idx].report.path.clone();
let cref =
collection_ref_for_report(report_path.as_deref(), option.path.as_deref(), &option.name);
let name = option.name.clone();
let new_text = set_flow_directive(&self.reports[idx].report.text, "collection", &cref);
self.reports[idx].report.set_text(new_text);
self.revalidate_report(idx);
self.save_state();
self.status = Some(Status::ReportBound(name));
}
pub(crate) fn enter_report_edit(&mut self) {
if let Some(idx) = self.active_report_index() {
self.reports[idx].view = ReportView::Source;
let text = self.reports[idx].report.text.clone();
let mut editor = Editor::new(&text, true);
if let Some((row, col)) = self.reports[idx].edit_cursor {
editor.set_cursor(row, col);
}
self.reports[idx].editor = Some(editor);
}
if self.active_report_index().is_some() && !self.active_is_strip_report() {
self.focus = Pane::Main;
}
}
pub(crate) fn close_active_report_tab(&mut self) {
let Some(ridx) = self.active_report_index() else {
return;
};
let slot = self
.standalone_report_indices()
.iter()
.position(|&i| i == ridx)
.unwrap_or(0);
let mut rt = self.reports.remove(ridx);
let report_id = rt.report.id;
if let Some(cancel) = self.running_reports.remove(&report_id) {
cancel.store(true, Ordering::Relaxed);
self.pending_report_runs.retain(|(id, _)| *id != report_id);
rt.run_progress = None;
}
self.closed_tabs
.push(super::app::ClosedTab::Report(ridx, Box::new(rt)));
if self.closed_tabs.len() > 20 {
self.closed_tabs.remove(0);
}
let base = self.collections.len();
let remaining = self.standalone_report_indices().len();
self.active_tab = if remaining == 0 {
base.saturating_sub(1)
} else {
base + slot.min(remaining - 1)
};
self.focus = Pane::Tabs;
self.status = Some(crate::i18n::Status::TabClosed);
self.save_state();
}
pub(crate) fn on_key_report(&mut self, key: KeyEvent) {
self.handle_report_key(key, false);
}
pub(crate) fn on_key_report_body(&mut self, key: KeyEvent) {
self.handle_report_key(key, true);
}
fn handle_report_key(&mut self, key: KeyEvent, embedded: bool) {
if let Some(idx) = self.active_report_index()
&& self.reports[idx].editor.is_some()
{
self.on_key_report_editing(key, idx);
return;
}
if let Some(idx) = self.active_report_index()
&& self.reports[idx].view == ReportView::Nodes
&& self.on_key_report_nodes(key, idx)
{
return;
}
if let Some(idx) = self.active_report_index()
&& self.reports[idx].params_open
&& self.on_key_report_run_settings(key, idx)
{
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
if let Some(idx) = self.active_report_index()
&& self.reports[idx].view == ReportView::Results
&& self.reports[idx].dry_run.is_some()
&& !ctrl
&& !shift
{
match key.code {
KeyCode::Left => {
self.preview_scroll_cols(-1);
return;
}
KeyCode::Right => {
self.preview_scroll_cols(1);
return;
}
_ => {}
}
}
if let Some(idx) = self.active_report_index()
&& self.reports[idx].view == ReportView::Results
&& self.reports[idx].result.is_some()
&& !ctrl
&& !shift
{
match key.code {
KeyCode::Up => {
self.result_cursor_move(-1, 0);
return;
}
KeyCode::Down => {
self.result_cursor_move(1, 0);
return;
}
KeyCode::Left => {
self.result_cursor_move(0, -1);
return;
}
KeyCode::Right => {
self.result_cursor_move(0, 1);
return;
}
KeyCode::Home => {
self.result_cursor_jump_home();
return;
}
KeyCode::End => {
self.result_cursor_jump_end();
return;
}
KeyCode::Enter => {
self.open_result_cell_popup();
return;
}
_ => {}
}
}
if let Some(idx) = self.active_report_index()
&& self.reports[idx].view == ReportView::Results
&& self.reports[idx].result.is_some()
&& ctrl
&& !shift
{
match key.code {
KeyCode::Up => {
self.result_cursor_page(-1);
return;
}
KeyCode::Down => {
self.result_cursor_page(1);
return;
}
_ => {}
}
}
match key.code {
KeyCode::Char('q') => self.request_quit(),
KeyCode::Char('[') | KeyCode::PageUp => self.cycle_tab(false),
KeyCode::Char(']') | KeyCode::PageDown => self.cycle_tab(true),
KeyCode::Left if ctrl && shift && !embedded => self.move_active_tab(false),
KeyCode::Right if ctrl && shift && !embedded => self.move_active_tab(true),
KeyCode::Left if ctrl && !embedded => self.cycle_tab(false),
KeyCode::Right if ctrl && !embedded => self.cycle_tab(true),
KeyCode::Left if shift => self.extend_report_selection(KeyCode::Left),
KeyCode::Right if shift => self.extend_report_selection(KeyCode::Right),
KeyCode::Up if shift => self.extend_report_selection(KeyCode::Up),
KeyCode::Down if shift => self.extend_report_selection(KeyCode::Down),
KeyCode::Left | KeyCode::Right if !embedded => {
if key.code == KeyCode::Left {
self.cycle_tab(false);
} else {
self.cycle_tab(true);
}
}
KeyCode::Left | KeyCode::Right => {}
KeyCode::Char('w') if ctrl => self.close_active_tab(),
KeyCode::Char('u') if !embedded => self.reopen_closed_tab(),
KeyCode::Char('/') => self.cycle_report_row_filter(),
KeyCode::Char('o') if ctrl => self.open_exported_report(),
KeyCode::Char('f') => self.overlay = Some(Overlay::FileMenu(0)),
KeyCode::Char('s') if !ctrl => self.overlay = Some(Overlay::Options(0)),
KeyCode::Char('?') | KeyCode::F(1) => {
self.overlay = Some(Overlay::Help(0));
self.help_scroll = 0;
}
KeyCode::Char('R') => self.new_report_tab(),
KeyCode::Char('e') if !ctrl => self.enter_report_edit(),
KeyCode::Enter => self.open_report_node_editor(),
KeyCode::Esc => {
if let Some(idx) = self.active_report_index() {
if self.reports[idx].dry_run.is_some() {
self.reports[idx].dry_run = None;
self.reports[idx].results_panel.set_scroll(0);
self.reports[idx].results_col_offset = 0;
} else if self.reports[idx].view == ReportView::Nodes {
self.reports[idx].view = ReportView::Source;
self.reports[idx].editor_view = ReportView::Source;
} else if self.has_any_selection() {
self.clear_selections();
} else {
self.request_quit();
}
} else {
self.request_quit();
}
}
KeyCode::Char('r') | KeyCode::F(5) => self.run_active_report(),
KeyCode::Char('d') => self.open_report_dry_run(),
KeyCode::Char('c') => self.open_report_columns(),
KeyCode::Char('b') => self.open_report_bind(),
KeyCode::Char('p') => self.open_report_run_settings(),
KeyCode::Char('v') => self.toggle_report_view(),
KeyCode::Char('F') => self.reformat_active_report(),
KeyCode::Tab if embedded => self.cycle_focus(true),
KeyCode::BackTab if embedded => self.cycle_focus(false),
KeyCode::Tab | KeyCode::BackTab => {}
KeyCode::Char('e') if ctrl => self.export_active_report_csv(),
KeyCode::Char('s') if ctrl => self.save_active(),
KeyCode::Char('B') => self.save_active_report_baseline(),
KeyCode::Char('y') => self.copy_report_selection_to_clipboard(),
KeyCode::Up => self.scroll_report(-1),
KeyCode::Down => self.scroll_report(1),
KeyCode::Home => self.scroll_report(i32::MIN),
KeyCode::End => self.scroll_report(i32::MAX),
KeyCode::Char('>') => {
self.list_width = (self.list_width + 2).min(80);
self.save_state();
}
KeyCode::Char('<') => {
self.list_width = self.list_width.saturating_sub(2).max(20);
self.save_state();
}
_ => {}
}
}
fn scroll_report(&mut self, delta: i32) {
if let Some(idx) = self.active_report_index() {
let rt = &mut self.reports[idx];
let panel = match rt.view {
ReportView::Results => &mut rt.results_panel,
ReportView::Source => &mut rt.source_panel,
ReportView::Nodes => return,
};
let next = (panel.scroll() as i32).saturating_add(delta).max(0);
panel.set_scroll(next.min(u16::MAX as i32) as u16);
}
}
fn on_key_report_editing(&mut self, key: KeyEvent, idx: usize) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let completion = if matches!(key.code, KeyCode::Right | KeyCode::Tab) && !ctrl && !shift {
self.report_completion(idx)
} else {
None
};
let mut leave = false;
let mut leave_cursor: Option<(usize, usize)> = None;
let mut copy: Option<String> = None;
let new_text: Option<String> = {
let Some(rt) = self.reports.get_mut(idx) else {
return;
};
let Some(editor) = rt.editor.as_mut() else {
return;
};
match key.code {
KeyCode::Esc => {
leave = true;
leave_cursor = Some((editor.row, editor.col));
Some(editor.text())
}
KeyCode::Left if ctrl => {
editor.set_selecting(shift);
word_left(editor);
None
}
KeyCode::Right if ctrl => {
editor.set_selecting(shift);
word_right(editor);
None
}
KeyCode::Right | KeyCode::Tab if completion.is_some() => {
editor.checkpoint();
accept_request_completion(editor, completion.as_ref().unwrap());
Some(editor.text())
}
KeyCode::Tab if !ctrl && !shift => {
editor.checkpoint();
editor.insert_str(INDENT_UNIT);
reindent_end_line(editor);
Some(editor.text())
}
KeyCode::Backspace if !ctrl => {
let chars: Vec<char> = editor.lines[editor.row].chars().collect();
if editor.col > 0 && chars.get(editor.col - 1) == Some(&' ') {
let mut run_start = editor.col;
while run_start > 0 && chars[run_start - 1] == ' ' {
run_start -= 1;
}
let within = editor.col - run_start;
let remove = (within - 1) % 4 + 1;
editor.checkpoint();
for _ in 0..remove {
editor.backspace();
}
reindent_end_line(editor);
Some(editor.text())
} else {
let resp = apply_edit_key_full(editor, key);
copy = resp.copy;
if resp.changed {
reindent_end_line(editor);
Some(editor.text())
} else {
None
}
}
}
KeyCode::Enter if !ctrl && !shift => {
let line = &editor.lines[editor.row];
if editor.col == line.chars().count() {
let new_indent = indent_for_new_line(line);
editor.checkpoint();
editor.newline();
editor.insert_str(&new_indent);
Some(editor.text())
} else {
let resp = apply_edit_key_full(editor, key);
copy = resp.copy;
if resp.changed {
Some(editor.text())
} else {
None
}
}
}
_ => {
let resp = apply_edit_key_full(editor, key);
copy = resp.copy;
if resp.changed {
reindent_end_line(editor);
Some(editor.text())
} else {
None
}
}
}
};
if let Some(text) = copy {
crate::tui::clipboard::copy_to_clipboard(&text);
self.status = Some(crate::i18n::Status::Copied);
}
if let Some(text) = new_text {
if let Some(rt) = self.reports.get_mut(idx) {
if leave {
rt.editor = None;
rt.edit_cursor = leave_cursor;
}
rt.report.set_text(text);
}
self.revalidate_report(idx);
if leave {
self.save_state();
}
}
}
pub(crate) fn report_completion(&self, idx: usize) -> Option<RequestCompletion> {
let rt = self.reports.get(idx)?;
let editor = rt.editor.as_ref()?;
let line = editor.lines.get(editor.row)?;
if editor.col != line.chars().count() {
return None;
}
if let Some(partial) = request_name_partial(line) {
let entries = self
.resolve_bound_collection(&rt.report)
.map(|ci| self.collections[ci].entries.as_slice())
.unwrap_or(&[]);
let names = crate::report::context::request_choices(entries, &rt.helpers)
.into_iter()
.map(|c| c.qualified);
return complete_name(&partial, names, false);
}
if let Some(partial) = env_name_partial(line) {
let envs = self.global_envs.iter().map(|e| e.name.clone());
return complete_name(&partial, envs, true);
}
None
}
}
fn complete_name(
partial: &NamePartial,
mut candidates: impl Iterator<Item = String>,
always_quote: bool,
) -> Option<RequestCompletion> {
match partial {
NamePartial::Bare { text: p, start } => {
let pl = p.to_lowercase();
let pchars = p.chars().count();
let t = candidates.find(|t| t.chars().count() > pchars && ci_prefix(t, &pl))?;
let suffix: String = t.chars().skip(pchars).collect();
let quote = always_quote || t.chars().any(char::is_whitespace);
Some(RequestCompletion {
ghost: suffix,
replacement: if quote { format!("\"{t}\"") } else { t },
start: *start,
})
}
NamePartial::Quoted { text: p, start } => {
let pl = p.to_lowercase();
let pchars = p.chars().count();
let t = candidates.find(|t| t.chars().count() >= pchars && ci_prefix(t, &pl))?;
let suffix: String = t.chars().skip(pchars).collect();
Some(RequestCompletion {
ghost: format!("{suffix}\""),
replacement: format!("\"{t}\""),
start: *start,
})
}
}
}
fn ci_prefix(candidate: &str, lower: &str) -> bool {
candidate.to_lowercase().starts_with(lower)
}
pub(crate) struct RequestCompletion {
pub(crate) ghost: String,
start: usize,
replacement: String,
}
fn accept_request_completion(ed: &mut Editor, comp: &RequestCompletion) {
ed.clear_selection();
let line = &ed.lines[ed.row];
let start = Editor::byte_idx(line, comp.start);
let end = Editor::byte_idx(line, ed.col);
ed.lines[ed.row].replace_range(start..end, &comp.replacement);
ed.col = comp.start + comp.replacement.chars().count();
}
enum NamePartial {
Bare { text: String, start: usize },
Quoted { text: String, start: usize },
}
fn pretty_print_json_cell(raw: &str) -> String {
let trimmed = raw.trim();
if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
return raw.to_string();
}
match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| raw.to_string()),
Err(_) => raw.to_string(),
}
}
fn reindent_end_line(ed: &mut Editor) {
let row = ed.row;
let Some(line) = ed.lines.get(row) else {
return;
};
if !is_end_line(line) {
return;
}
let Some(indent) = matching_opener_indent(&ed.lines[..row]) else {
return;
};
let trimmed = ed.lines[row].trim_start().to_string();
ed.lines[row] = format!("{indent}{trimmed}");
ed.col = ed.lines[row].chars().count();
}
fn strip_keyword<'a>(s: &'a str, kw: &str) -> Option<&'a str> {
let head = s.get(..kw.len())?;
if head.eq_ignore_ascii_case(kw) {
let rest: &str = &s[kw.len()..];
if rest.starts_with(char::is_whitespace) {
return Some(rest.trim_start());
}
}
None
}
fn request_name_partial(line: &str) -> Option<NamePartial> {
let t = line.trim_start();
let after_report = strip_keyword(t, "REPORT").unwrap_or(t);
let name = strip_keyword(after_report, "REQUEST")?;
if let Some(inner) = name.strip_prefix('"') {
if inner.contains('"') {
return None; }
let start = name.as_ptr() as usize - line.as_ptr() as usize;
Some(NamePartial::Quoted {
text: inner.to_string(),
start,
})
} else if name.is_empty() {
None
} else {
let start = name.as_ptr() as usize - line.as_ptr() as usize;
Some(NamePartial::Bare {
text: name.to_string(),
start,
})
}
}
fn keyword_word_end(line: &str, kw: &str) -> Option<usize> {
let up = line.to_ascii_uppercase();
let kwu = kw.to_ascii_uppercase();
let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let bytes = up.as_bytes();
let mut from = 0;
while let Some(rel) = up[from..].find(&kwu) {
let s = from + rel;
let e = s + kwu.len();
let before_ok = s == 0 || !is_word(bytes[s - 1]);
let after_ok = e == bytes.len() || !is_word(bytes[e]);
if before_ok && after_ok {
return Some(e);
}
from = e;
}
None
}
fn env_name_partial(line: &str) -> Option<NamePartial> {
let envs_end = keyword_word_end(line, "ENVS")?;
let region_start = match line.rfind([',', '(']) {
Some(pos) if pos + 1 > envs_end => pos + 1,
_ => envs_end,
};
let region = &line[region_start..];
let lead = region.len() - region.trim_start().len();
let token_byte = region_start + lead;
let token = &line[token_byte..];
let start = line[..token_byte].chars().count();
if let Some(inner) = token.strip_prefix('"') {
if inner.contains('"') {
return None; }
Some(NamePartial::Quoted {
text: inner.to_string(),
start,
})
} else if token.is_empty() {
None
} else {
Some(NamePartial::Bare {
text: token.to_string(),
start,
})
}
}
use crate::report::context::{paths_equal, resolve_ref_path};
fn collection_ref_for_report(
report_path: Option<&std::path::Path>,
collection_path: Option<&std::path::Path>,
collection_name: &str,
) -> String {
let Some(cpath) = collection_path else {
return collection_name.to_string();
};
if let Some(dir) = report_path.and_then(|rp| rp.parent())
&& let Some(rel) = relative_path(dir, cpath)
{
return rel.to_string_lossy().into_owned();
}
cpath.to_string_lossy().into_owned()
}
fn relative_path(from_dir: &std::path::Path, to: &std::path::Path) -> Option<std::path::PathBuf> {
let from = from_dir.canonicalize().ok()?;
let to = to.canonicalize().ok()?;
let from_comps: Vec<_> = from.components().collect();
let to_comps: Vec<_> = to.components().collect();
if from_comps.first() != to_comps.first() {
return None;
}
let common = from_comps
.iter()
.zip(&to_comps)
.take_while(|(a, b)| a == b)
.count();
let mut rel = std::path::PathBuf::new();
for _ in common..from_comps.len() {
rel.push("..");
}
for comp in &to_comps[common..] {
rel.push(comp.as_os_str());
}
if rel.as_os_str().is_empty() {
None
} else {
Some(rel)
}
}
use crate::report::dry_run::DryRunReport;
impl DryRunReport {
pub(crate) fn line_sections(
&self,
s: &Strings,
th: &Theme,
col_offset: usize,
) -> (Vec<Line<'static>>, Vec<Line<'static>>, Vec<Line<'static>>) {
let mut head: Vec<Line<'static>> = Vec::new();
head.push(Line::from(Span::styled(
s.report_dry_run_preview_notice.to_string(),
Style::default().fg(th.dim),
)));
head.push(Line::from(""));
head.push(Line::from(Span::styled(
format!("{} {}", s.report_dry_run_rows, self.rows),
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
)));
head.push(Line::from(""));
let mut grid: Vec<Line<'static>> = Vec::new();
if self.rows == 0 {
head.push(Line::from(Span::styled(
s.report_dry_run_no_rows.to_string(),
Style::default().fg(th.dim),
)));
} else {
let visible: Vec<usize> = (0..self.result.rows.len()).collect();
grid.extend(report_grid_lines(
&self.result,
&self.header,
None,
th,
None,
col_offset,
&visible,
));
}
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(""));
let has_warnings = !self.var_warnings.is_empty();
let has_errors = !self.errors.is_empty();
if !has_warnings && !has_errors {
lines.push(Line::from(Span::styled(
s.report_dry_run_no_problems.to_string(),
Style::default().fg(th.accent),
)));
} else {
if has_warnings {
lines.push(Line::from(Span::styled(
s.report_dry_run_warnings_heading.to_string(),
Style::default().fg(th.pending).add_modifier(Modifier::BOLD),
)));
for w in &self.var_warnings {
lines.push(Line::from(vec![
Span::styled("! ", Style::default().fg(th.pending)),
Span::styled(w.clone(), Style::default().fg(th.text)),
]));
}
if has_errors {
lines.push(Line::from(""));
}
}
if has_errors {
lines.push(Line::from(Span::styled(
s.report_dry_run_problems_heading.to_string(),
Style::default().fg(th.err).add_modifier(Modifier::BOLD),
)));
for err in &self.errors {
lines.push(Line::from(Span::styled(
format!("• {err}"),
Style::default().fg(th.err),
)));
}
}
}
(head, grid, lines)
}
}
pub(crate) struct ColumnChoice {
pub(crate) header: String,
pub(crate) sources: Vec<String>,
pub(crate) included: bool,
}
impl ColumnChoice {
fn to_spec(&self) -> String {
let src = self.sources.join("|");
if self.header == self.sources.first().map(String::as_str).unwrap_or("") {
src
} else {
format!("{src} AS {}", quote_header(&self.header))
}
}
}
fn quote_header(h: &str) -> String {
if h.is_empty()
|| h.chars()
.any(|c| c.is_whitespace() || matches!(c, ',' | '|' | '"'))
{
let escaped = h.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
} else {
h.to_string()
}
}
pub(crate) struct ColumnPicker {
pub(crate) rows: Vec<ColumnChoice>,
pub(crate) selected: usize,
}
pub(crate) struct BindOption {
pub(crate) name: String,
pub(crate) path: Option<std::path::PathBuf>,
}
pub(crate) struct ReportBindPicker {
pub(crate) options: Vec<BindOption>,
pub(crate) selected: usize,
}
impl ColumnPicker {
pub(crate) fn build(header: &Header, result: &ReportResult) -> Self {
let mut available: Vec<String> = result.column_order.clone();
if result.rows.iter().any(|r| r.target.is_some())
&& !available.iter().any(|k| k == TARGET_COLUMN)
{
available.push(TARGET_COLUMN.to_string());
}
let mut rows: Vec<ColumnChoice> = Vec::new();
let mut covered: std::collections::HashSet<String> = std::collections::HashSet::new();
match header.columns() {
Some(spec) => {
for col in parse_columns(spec) {
for s in &col.sources {
covered.insert(s.clone());
}
rows.push(ColumnChoice {
header: col.header,
sources: col.sources,
included: true,
});
}
}
None => {
for key in &available {
covered.insert(key.clone());
rows.push(ColumnChoice {
header: key.clone(),
sources: vec![key.clone()],
included: true,
});
}
}
}
for key in available {
if !covered.contains(&key) {
covered.insert(key.clone());
rows.push(ColumnChoice {
header: key.clone(),
sources: vec![key.clone()],
included: false,
});
}
}
ColumnPicker { rows, selected: 0 }
}
fn spec(&self) -> Option<String> {
let parts: Vec<String> = self
.rows
.iter()
.filter(|c| c.included)
.map(ColumnChoice::to_spec)
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join(", "))
}
}
}
fn set_flow_columns_directive(text: &str, spec: &str) -> String {
set_flow_directive(text, "columns", spec)
}
fn set_flow_directive(text: &str, key: &str, value: &str) -> String {
let new_line = format!("# {key}: {value}");
let prefix = format!("{key}:");
let trailing_nl = text.ends_with('\n');
let mut lines: Vec<String> = text.lines().map(String::from).collect();
for line in &mut lines {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix('#') {
let rest = rest.trim_start();
if rest
.as_bytes()
.get(..prefix.len())
.is_some_and(|b| b.eq_ignore_ascii_case(prefix.as_bytes()))
{
*line = new_line.clone();
return rejoin(lines, trailing_nl);
}
}
}
let mut insert_at = 0;
for line in &lines {
if line.trim_start().starts_with('#') {
insert_at += 1;
} else {
break;
}
}
lines.insert(insert_at, new_line);
rejoin(lines, trailing_nl)
}
fn rejoin(lines: Vec<String>, trailing_nl: bool) -> String {
let mut out = lines.join("\n");
if trailing_nl {
out.push('\n');
}
out
}
pub(crate) fn draw_report_body(
f: &mut Frame,
area: Rect,
app: &mut TuiApp,
s: &Strings,
th: &Theme,
) {
let Some(idx) = app.active_report_index() else {
return;
};
draw_report_content(f, area, app, idx, s, th);
}
pub(crate) fn draw_report_content(
f: &mut Frame,
area: Rect,
app: &mut TuiApp,
idx: usize,
s: &Strings,
th: &Theme,
) {
app.report_pane_areas = [Rect::default(); 3];
app.report_pane_bars = [Rect::default(); 3];
let bind_h = binding_height(app, idx, area.width, s, th);
if app.reports[idx].view == ReportView::Results {
let rows = Layout::vertical([Constraint::Min(3), Constraint::Length(bind_h)]).split(area);
draw_report_results(f, rows[0], app, idx, s, th);
draw_report_binding(f, rows[1], app, idx, s, th);
return;
}
let diag_h = validation_height(&app.reports[idx], s, th, area);
let rows = Layout::vertical([
Constraint::Min(3),
Constraint::Length(bind_h),
Constraint::Length(diag_h),
])
.split(area);
if app.reports[idx].view == ReportView::Nodes {
super::report_nodes::draw_report_nodes(f, rows[0], app, idx, s, th);
} else {
draw_report_source(f, rows[0], app, idx, s, th);
}
draw_report_binding(f, rows[1], app, idx, s, th);
draw_report_validation(f, rows[2], app, idx, s, th);
}
pub(crate) fn draw_report_run_settings_overlay(
f: &mut Frame,
app: &mut TuiApp,
idx: usize,
s: &Strings,
th: &Theme,
) {
let rows = app.report_param_rows(idx);
if rows.is_empty() {
return;
}
let box_w = f.area().width.saturating_sub(6).clamp(40, 100);
let box_h = (rows.len() as u16 + 2)
.min(f.area().height.saturating_sub(2))
.max(3);
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let title = format!("{} — {}", s.param_view_title, s.param_view_hint);
let block = panel(title, true, th);
let inner = block.inner(area);
f.render_widget(block, area);
app.set_mouse_layer(MouseLayer::Overlay);
if inner.width == 0 || inner.height == 0 {
return;
}
let sel = app.reports[idx]
.param_selected
.min(rows.len().saturating_sub(1));
app.reports[idx].param_selected = sel;
let prompt_w = rows
.iter()
.map(|r| r.prompt.chars().count())
.max()
.unwrap_or(0);
let lines: Vec<Line> = rows
.iter()
.enumerate()
.map(|(i, row)| {
let selected = i == sel;
let mark = if selected { "> " } else { " " };
let mut spans = vec![
Span::styled(mark, Style::default().fg(th.accent)),
Span::styled(
format!("{:<prompt_w$} ", row.prompt),
if selected {
Style::default().fg(th.text).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
},
),
];
if row.value.is_empty() {
spans.push(Span::styled(
s.param_value_unset.to_string(),
Style::default().fg(th.dim),
));
} else {
spans.push(Span::styled(row.value.clone(), Style::default().fg(th.ok)));
}
spans.push(Span::styled(
format!(" {} {}", row.name, row.kind.keyword()),
Style::default().fg(th.dim),
));
if let Some(problem) = &row.problem {
spans.push(Span::styled(
format!(" {problem}"),
Style::default().fg(th.err),
));
}
Line::from(spans)
})
.collect();
let h = inner.height as usize;
let first = sel.saturating_sub(h.saturating_sub(1));
let visible: Vec<Line> = lines.into_iter().skip(first).take(h).collect();
let shown = visible.len();
f.render_widget(Paragraph::new(visible), inner);
for i in 0..shown {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + i as u16, inner.width, 1),
MouseHitTarget::ReportParamRow(first + i),
);
}
}
fn draw_report_results(
f: &mut Frame,
area: Rect,
app: &mut TuiApp,
idx: usize,
s: &Strings,
th: &Theme,
) {
let inner_h = area.height.saturating_sub(2) as usize;
let preview_lines = app.reports[idx].dry_run.as_ref().map(|preview| {
let col_offset = app.reports[idx].results_col_offset;
let (head, grid, tail) = preview.line_sections(s, th, col_offset);
let width = area.width.saturating_sub(2);
let mut lines = crate::tui::draw::wrap_lines_with_marker(head, width, th);
lines.extend(grid);
lines.extend(crate::tui::draw::wrap_lines_with_marker(tail, width, th));
lines
});
if let Some(lines) = preview_lines {
let title = format!("{} — {}", s.report_dry_run_title, s.report_dry_run_hint);
let focused = app.report_body_focused();
let block = panel(title, focused, th);
let (inner, bar) = draw_report_panel(
f,
area,
block,
&mut app.reports[idx].results_panel,
&lines,
th,
);
app.report_pane_areas[ReportPane::Results.idx()] = inner;
app.report_pane_bars[ReportPane::Results.idx()] = bar;
return;
}
if let Some(result) = &app.reports[idx].result {
let rt = &app.reports[idx];
let header = rt.report.flow().map(|flow| flow.header).unwrap_or_default();
let widths = result_column_widths(result, &header, &rt.visible_result_rows());
let show_icons = rt.run_progress.is_some();
let avail =
(area.width.saturating_sub(2) as usize).saturating_sub(if show_icons { 2 } else { 0 });
let cursor_col = rt.cell_cursor.map(|(_, c)| c).unwrap_or(0);
app.reports[idx].results_col_offset =
follow_col_offset(&widths, cursor_col, avail, rt.results_col_offset);
}
let (lines, head, title) = {
let rt = &app.reports[idx];
match &rt.result {
None => (
vec![Line::from(Span::styled(
s.report_results_empty.to_string(),
Style::default().fg(th.dim),
))],
Vec::new(),
s.report_results_heading.to_string(),
),
Some(result) => {
let header = rt.report.flow().map(|flow| flow.header).unwrap_or_default();
let visible = rt.visible_result_rows();
let states = rt.run_progress.as_ref().map(|p| p.states.as_slice());
let lines = report_grid_lines(
result,
&header,
states,
th,
rt.cell_cursor,
rt.results_col_offset,
&visible,
);
let count = if result.errors.is_empty() {
format!("{}", result.rows.len())
} else {
format!(
"{}, {} {}",
result.rows.len(),
result.errors.len(),
s.report_status_errors
)
};
let filter = filter_title(rt, result, visible.len(), s)
.map(|f| format!(" — {f}"))
.unwrap_or_default();
let title = format!(
"{} ({}){} — {}",
s.report_results_heading, count, filter, s.report_hint_results
);
let head = results_head_lines(result, &header, s, th);
(lines, head, title)
}
}
};
let head: Vec<Line<'static>> = if inner_h >= head.len() + 3 {
head
} else {
Vec::new()
};
let sticky = app.reports[idx].result.is_some() && lines.len() > 1 && inner_h >= 2;
let pinned = head.len() + usize::from(sticky);
let cursor = app.reports[idx].cell_cursor;
if cursor != app.reports[idx].results_scrolled_to
&& let Some((cursor_row, _)) = cursor
{
let scroll = app.reports[idx].results_panel.scroll() as usize;
if sticky {
let body_h = inner_h.saturating_sub(pinned);
if body_h > 0 {
if cursor_row < scroll {
app.reports[idx].results_panel.set_scroll(cursor_row as u16);
} else if cursor_row >= scroll + body_h {
let new_scroll = (cursor_row + 1).saturating_sub(body_h) as u16;
app.reports[idx].results_panel.set_scroll(new_scroll);
}
app.reports[idx].results_scrolled_to = cursor;
}
} else if inner_h > 0 {
let grid_line = cursor_row + 1;
if grid_line < scroll {
app.reports[idx].results_panel.set_scroll(grid_line as u16);
} else if grid_line >= scroll + inner_h {
let new_scroll = (grid_line + 1).saturating_sub(inner_h) as u16;
app.reports[idx].results_panel.set_scroll(new_scroll);
}
app.reports[idx].results_scrolled_to = cursor;
}
}
let focused = app.report_body_focused();
let block = panel(title, focused, th);
let (inner, bar) = if sticky {
let inner = block.inner(area);
f.render_widget(block, area);
let mut pinned_lines = head.clone();
pinned_lines.push(lines[0].clone());
let header_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: pinned as u16,
};
f.render_widget(
Paragraph::new(pinned_lines.clone()).style(Style::default().fg(th.text)),
header_area,
);
mark_clipped_rows(f, header_area, &pinned_lines, 0, th);
let body_area = Rect {
x: inner.x,
y: inner.y + pinned as u16,
width: inner.width,
height: inner.height - pinned as u16,
};
let bar = render_panel_lines(
f,
body_area,
area.x + area.width - 1,
&mut app.reports[idx].results_panel,
&lines[1..],
th,
);
(inner, bar)
} else {
draw_report_panel(
f,
area,
block,
&mut app.reports[idx].results_panel,
&lines,
th,
)
};
app.report_pane_areas[ReportPane::Results.idx()] = inner;
app.report_pane_bars[ReportPane::Results.idx()] = bar;
app.push_mouse_hit(
MouseLayer::Base,
bar,
MouseHitTarget::Scroll(MouseScrollTarget::ReportPane(ReportPane::Results)),
);
app.push_mouse_hit(
MouseLayer::Base,
inner,
MouseHitTarget::FocusPane(Pane::Main),
);
app.push_mouse_hit(MouseLayer::Base, inner, MouseHitTarget::ReportResultsCell);
}
fn results_head_lines(
result: &ReportResult,
header: &crate::report::flow::Header,
s: &Strings,
th: &Theme,
) -> Vec<Line<'static>> {
let mut out = Vec::new();
let columns = result.resolved_columns(header);
if let Some(metrics) = result.metrics(&columns, header) {
let label = Style::default().fg(th.dim);
let value = Style::default().fg(th.text).add_modifier(Modifier::BOLD);
if let Some(mv) = &metrics.movement {
let mut spans = vec![Span::styled(
format!("{} ", s.report_metric_movement),
value,
)];
if mv.is_still() {
spans.push(Span::styled(
s.report_metric_nothing_moved.to_string(),
Style::default().fg(th.dim),
));
} else {
spans.push(Span::styled(format!("{} ", s.report_metric_fixed), label));
spans.push(Span::styled(
format!("{} ", mv.fixed),
Style::default().fg(th.ok).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!("{} ", s.report_metric_regressed),
label,
));
spans.push(Span::styled(
format!("{} ", mv.regressed),
if mv.regressed > 0 {
Style::default().fg(th.err).add_modifier(Modifier::BOLD)
} else {
value
},
));
}
if mv.still_wrong > 0 {
spans.push(Span::styled(
format!("{} ", s.report_metric_still_wrong),
label,
));
spans.push(Span::styled(format!("{}", mv.still_wrong), value));
}
out.push(Line::from(spans));
}
for m in metrics.overall.iter().chain(metrics.columns.iter()) {
out.push(Line::from(vec![
Span::styled(format!("{} ", m.header), value),
Span::styled(format!("{} ", s.report_metric_compared), label),
Span::styled(format!("{}/{} ", m.compared, m.total), value),
Span::styled(format!("{} ", s.report_metric_incorrect), label),
Span::styled(format!("{} ", m.incorrect), value),
Span::styled(format!("{} ", s.report_metric_accuracy), label),
Span::styled(
m.accuracy_text().unwrap_or_else(|| "\u{2014}".to_string()),
match m.accuracy() {
Some(a) if a >= 1.0 => Style::default().fg(th.ok),
Some(_) => Style::default().fg(th.pending),
None => value,
},
),
]));
}
}
out
}
fn filter_title(
rt: &ReportTab,
result: &ReportResult,
visible: usize,
s: &Strings,
) -> Option<String> {
if rt.result_filters().len() < 2 {
return None;
}
let rows = s
.report_rows_shown
.replace("{shown}", &visible.to_string())
.replace("{total}", &result.rows.len().to_string());
Some(
s.report_filter_title
.replace("{f}", &filter_label(s, &rt.results_filter))
.replace("{r}", &rows),
)
}
#[cfg(test)]
pub(crate) fn results_head_text(rt: &ReportTab, s: &Strings) -> Vec<String> {
let Some(result) = &rt.result else {
return Vec::new();
};
let header = rt.report.flow().map(|f| f.header).unwrap_or_default();
let th = crate::tui::theme::theme(&crate::i18n::Language::English);
results_head_lines(result, &header, s, &th)
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.collect()
}
#[cfg(test)]
pub(crate) fn results_title_filter(rt: &ReportTab, s: &Strings) -> Option<String> {
let result = rt.result.as_ref()?;
filter_title(rt, result, rt.visible_result_rows().len(), s)
}
fn filter_label(s: &Strings, f: &crate::report::filter::RowFilter) -> String {
use crate::report::filter::RowFilter;
match f {
RowFilter::All => s.report_filter_all.to_string(),
RowFilter::Differ => s.report_filter_differences.to_string(),
RowFilter::Incorrect => s.report_filter_incorrect.to_string(),
RowFilter::Regressed => s.report_filter_regressions.to_string(),
RowFilter::Fixed => s.report_filter_improvements.to_string(),
RowFilter::MatrixCell { .. } => f.label(),
}
}
fn report_grid_lines(
result: &ReportResult,
header: &crate::report::flow::Header,
states: Option<&[RowState]>,
th: &Theme,
cursor: Option<(usize, usize)>, col_offset: usize,
visible: &[usize],
) -> Vec<Line<'static>> {
let columns = result.resolved_columns(header);
if columns.is_empty() {
return vec![Line::from(Span::styled(
String::new(),
Style::default().fg(th.dim),
))];
}
let headers: Vec<String> = columns.iter().map(|c| c.header.clone()).collect();
let body: Vec<Vec<String>> = visible
.iter()
.filter_map(|&r| result.rows.get(r))
.map(|row| {
columns
.iter()
.map(|c| flatten_cell(&c.value(row, &result.no_match_marker)))
.collect()
})
.collect();
let summary_body = summary_grid_body(result, &columns);
let widths = grid_column_widths(&headers, &body, &summary_body);
let cursor_style = Style::default().bg(th.select_bg).fg(th.select_fg);
let mut lines = Vec::with_capacity(body.len() + 1);
let show_icons = states.is_some();
let header_style = Style::default().fg(th.accent).add_modifier(Modifier::BOLD);
let mut header_spans: Vec<Span<'static>> = Vec::new();
if show_icons {
header_spans.push(Span::styled(" ".to_string(), header_style));
}
header_spans.extend(grid_row_cell_spans(
&headers,
&widths,
header_style,
None,
cursor_style,
col_offset,
));
lines.push(Line::from(header_spans));
for (i, row) in body.iter().enumerate() {
let state = states
.and_then(|s| visible.get(i).and_then(|&r| s.get(r)))
.copied();
let text_style = match state {
Some(RowState::Running) => Style::default().fg(th.pending).add_modifier(Modifier::BOLD),
Some(RowState::Scheduled) => Style::default().fg(th.dim),
_ => Style::default().fg(th.text),
};
let mut spans: Vec<Span<'static>> = Vec::new();
if show_icons {
let (glyph, color) = match state {
Some(RowState::Running) => (ROW_RUNNING_ICON, th.pending),
Some(RowState::Finished) => (ROW_FINISHED_ICON, th.ok),
_ => (ROW_SCHEDULED_ICON, th.dim),
};
spans.push(Span::styled(
format!("{glyph} "),
Style::default().fg(color),
));
}
let cursor_col = cursor.and_then(|(r, c)| if r == i { Some(c) } else { None });
spans.extend(grid_row_cell_spans(
row,
&widths,
text_style,
cursor_col,
cursor_style,
col_offset,
));
lines.push(Line::from(spans));
}
let summary_style = Style::default()
.fg(th.accent)
.add_modifier(Modifier::BOLD | Modifier::ITALIC);
for srow in &summary_body {
let mut spans: Vec<Span<'static>> = Vec::new();
if show_icons {
spans.push(Span::styled(" ".to_string(), summary_style));
}
spans.extend(grid_row_cell_spans(
srow,
&widths,
summary_style,
None,
cursor_style,
col_offset,
));
lines.push(Line::from(spans));
}
lines
}
fn summary_grid_body(
result: &ReportResult,
columns: &[crate::report::model::OutputColumn],
) -> Vec<Vec<String>> {
result
.summary_rows(columns)
.iter()
.map(|sr| {
(0..columns.len())
.map(|c| flatten_cell(&sr.text_cell(c)))
.collect()
})
.collect()
}
const ROW_SCHEDULED_ICON: &str = "\u{00B7}"; const ROW_RUNNING_ICON: &str = "\u{2026}"; const ROW_FINISHED_ICON: &str = "\u{2713}";
const MAX_COL_WIDTH: usize = 32;
fn grid_column_widths(
headers: &[String],
body: &[Vec<String>],
summary: &[Vec<String>],
) -> Vec<usize> {
(0..headers.len())
.map(|c| {
let mut w = headers[c].chars().count();
for row in body.iter().chain(summary.iter()) {
w = w.max(row.get(c).map(|s| s.chars().count()).unwrap_or(0));
}
w.clamp(1, MAX_COL_WIDTH)
})
.collect()
}
pub(crate) fn result_column_widths(
result: &ReportResult,
header: &crate::report::flow::Header,
visible: &[usize],
) -> Vec<usize> {
let columns = result.resolved_columns(header);
let headers: Vec<String> = columns.iter().map(|c| c.header.clone()).collect();
let body: Vec<Vec<String>> = visible
.iter()
.filter_map(|&r| result.rows.get(r))
.map(|row| {
columns
.iter()
.map(|c| flatten_cell(&c.value(row, &result.no_match_marker)))
.collect()
})
.collect();
let summary_body = summary_grid_body(result, &columns);
grid_column_widths(&headers, &body, &summary_body)
}
pub(crate) fn follow_col_offset(
widths: &[usize],
cursor_col: usize,
avail: usize,
current: usize,
) -> usize {
if widths.is_empty() {
return 0;
}
let last = widths.len() - 1;
let cursor_col = cursor_col.min(last);
let mut off = current.min(cursor_col);
let span = |off: usize| -> usize {
widths[off..=cursor_col].iter().sum::<usize>() + 2 * (cursor_col - off)
};
while off < cursor_col && span(off) > avail {
off += 1;
}
off
}
pub(crate) fn grid_col_at_x(
widths: &[usize],
x_off: usize,
show_icons: bool,
col_offset: usize,
) -> usize {
if widths.is_empty() {
return 0;
}
let col_offset = col_offset.min(widths.len() - 1);
let widths = &widths[col_offset..];
let x = if show_icons {
x_off.saturating_sub(2)
} else {
x_off
};
let mut pos = 0usize;
for (ci, &w) in widths.iter().enumerate() {
let next_col_start = pos + w + 2;
if ci + 1 == widths.len() || x < next_col_start {
return col_offset + ci;
}
pos = next_col_start;
}
col_offset + widths.len() - 1
}
fn grid_row_cell_spans(
fields: &[String],
widths: &[usize],
base_style: Style,
cursor_col: Option<usize>,
cursor_style: Style,
col_offset: usize,
) -> Vec<Span<'static>> {
let mut spans = Vec::new();
for (i, field) in fields.iter().enumerate().skip(col_offset) {
if i > col_offset {
spans.push(Span::styled(" ".to_string(), base_style));
}
let w = widths[i];
let count = field.chars().count();
let cell_text: String = if count > w {
let take = w.saturating_sub(1);
let mut s: String = field.chars().take(take).collect();
s.push('…');
s
} else {
let mut s = field.clone();
s.extend(std::iter::repeat_n(' ', w - count));
s
};
let style = if cursor_col == Some(i) {
cursor_style
} else {
base_style
};
spans.push(Span::styled(cell_text, style));
}
spans
}
fn flatten_cell(value: &str) -> String {
if value.contains(['\n', '\r']) {
value.replace("\r\n", "⏎").replace(['\n', '\r'], "⏎")
} else {
value.to_string()
}
}
fn render_panel_lines(
f: &mut Frame,
area: Rect,
bar_x: u16,
panel: &mut MultiSelectPanel,
lines: &[Line<'static>],
th: &Theme,
) -> Rect {
if area.width == 0 || area.height == 0 {
return Rect::default();
}
panel.set_wrap_marker(Some(super::draw::wrap_marker(th)));
panel.set_styled_content(lines, area.width as usize);
panel.clamp_scroll(area.height);
let visible = panel.visible_rows(area.height);
f.render_widget(
Paragraph::new(visible).style(Style::default().fg(th.text)),
area,
);
if panel.wrap_mode() == WrapMode::Clip {
mark_clipped_rows(f, area, lines, panel.scroll(), th);
}
let mut bar_area = Rect::default();
if panel.max_scroll(area.height) > 0 {
let total = panel.total_rows().min(u16::MAX as u32) as usize;
let bar = Rect {
x: bar_x,
y: area.y,
width: 1,
height: area.height,
};
draw_scrollbar(
f,
bar,
total,
area.height as usize,
panel.scroll() as usize,
th,
);
bar_area = bar;
}
bar_area
}
const CLIP_MARKER: char = '\u{2026}';
fn mark_clipped_rows(f: &mut Frame, inner: Rect, lines: &[Line<'static>], scroll: u16, th: &Theme) {
let width = inner.width as usize;
if width == 0 {
return;
}
for row in 0..inner.height {
let Some(line) = lines.get(scroll as usize + row as usize) else {
break;
};
let mut chars = line.spans.iter().flat_map(|sp| sp.content.chars());
if chars.by_ref().take(width).count() < width {
continue;
}
if !chars.any(|c| c != ' ') {
continue;
}
let pos = ratatui::layout::Position::new(inner.x + inner.width - 1, inner.y + row);
if let Some(cell) = f.buffer_mut().cell_mut(pos) {
cell.set_char(CLIP_MARKER);
cell.set_style(Style::default().fg(th.dim));
}
}
}
fn draw_report_panel(
f: &mut Frame,
area: Rect,
block: Block<'static>,
panel: &mut MultiSelectPanel,
lines: &[Line<'static>],
th: &Theme,
) -> (Rect, Rect) {
let inner = block.inner(area);
f.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return (Rect::default(), Rect::default());
}
panel.set_wrap_marker(Some(super::draw::wrap_marker(th)));
panel.set_styled_content(lines, inner.width as usize);
panel.clamp_scroll(inner.height);
let visible = panel.visible_rows(inner.height);
f.render_widget(
Paragraph::new(visible).style(Style::default().fg(th.text)),
inner,
);
if panel.wrap_mode() == WrapMode::Clip {
mark_clipped_rows(f, inner, lines, panel.scroll(), th);
}
let mut bar_area = Rect::default();
if panel.max_scroll(inner.height) > 0 {
let total = panel.total_rows().min(u16::MAX as u32) as usize;
let bar = Rect {
x: area.x + area.width - 1,
y: inner.y,
width: 1,
height: inner.height,
};
draw_scrollbar(
f,
bar,
total,
inner.height as usize,
panel.scroll() as usize,
th,
);
bar_area = bar;
}
(inner, bar_area)
}
pub(crate) fn draw_result_cell_popup_overlay(
f: &mut Frame,
title: &str,
content: &str,
panel: &mut MultiSelectPanel,
s: &Strings,
th: &Theme,
) -> Rect {
use super::draw::centered_rect;
let box_w = f.area().width.saturating_sub(8).clamp(40, 90);
let content_lines: Vec<Line<'static>> = content
.lines()
.map(|l| Line::from(Span::styled(l.to_string(), Style::default().fg(th.text))))
.collect();
let content_lines = if content_lines.is_empty() {
vec![Line::from(Span::styled(
String::new(),
Style::default().fg(th.dim),
))]
} else {
content_lines
};
let inner_w = box_w.saturating_sub(2).max(1) as usize;
let wrapped_rows: usize = content
.lines()
.map(|l| {
let cols = l.chars().count();
if cols == 0 { 1 } else { cols.div_ceil(inner_w) }
})
.sum::<usize>()
.max(1);
let box_h = (wrapped_rows as u16 + 2)
.max(4)
.min(f.area().height.saturating_sub(4).max(4));
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(ratatui::widgets::Clear, area);
let popup_title = format!("{title} ({})", s.report_cell_popup_hint);
let (inner, _bar) = draw_report_panel(
f,
area,
super::draw::panel(popup_title, true, th),
panel,
&content_lines,
th,
);
inner
}
fn draw_editor_ghost(f: &mut Frame, area: Rect, ed: &Editor, ghost: &str, th: &Theme) {
let w = area.width as usize;
let h = area.height as usize;
if w == 0 || h == 0 {
return;
}
let col_off = ed.col.saturating_sub(w.saturating_sub(1));
let row_off = ed.row.saturating_sub(h.saturating_sub(1));
let screen_col = ed.col - col_off;
let screen_row = ed.row - row_off;
let avail = w.saturating_sub(screen_col);
if avail == 0 || screen_row >= h {
return;
}
let shown: String = ghost.chars().take(avail).collect();
let rect = Rect {
x: area.x + screen_col as u16,
y: area.y + screen_row as u16,
width: shown.chars().count() as u16,
height: 1,
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
shown,
Style::default().fg(th.dim).add_modifier(Modifier::DIM),
))),
rect,
);
}
pub(crate) fn report_base_dir(report: &Report) -> (std::path::PathBuf, bool) {
match report.flow() {
Ok(flow) => crate::report::context::report_base_dir(&flow, report.path.as_deref()),
Err(_) => match report.path.as_deref().and_then(|p| p.parent()) {
Some(dir) => (dir.to_path_buf(), true),
None => (std::env::current_dir().unwrap_or_default(), false),
},
}
}
fn binding_height(app: &TuiApp, idx: usize, width: u16, s: &Strings, th: &Theme) -> u16 {
let inner = width.saturating_sub(2).max(1) as usize;
let rows: usize = binding_lines(app, idx, s, th)
.iter()
.map(|l| {
let w: usize = l.spans.iter().map(|sp| sp.content.chars().count()).sum();
w.div_ceil(inner).max(1)
})
.sum();
(rows as u16 + 2).clamp(4, 8)
}
fn binding_lines<'a>(app: &'a TuiApp, idx: usize, s: &'a Strings, th: &Theme) -> Vec<Line<'a>> {
let rt = &app.reports[idx];
let binding = match app.resolve_bound_collection(&rt.report) {
Some(ci) => {
let mut spans = vec![
Span::styled(s.report_bound_prefix, Style::default().fg(th.dim)),
Span::raw(" "),
Span::styled(
app.collections[ci].name.clone(),
Style::default().fg(th.ok).add_modifier(Modifier::BOLD),
),
];
if let Some(env) = rt.report.environment_ref() {
let loaded = app.global_envs.iter().any(|e| e.name == env);
spans.push(Span::styled(" · ", Style::default().fg(th.dim)));
spans.push(Span::styled(
s.report_env_prefix,
Style::default().fg(th.dim),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
env,
Style::default()
.fg(if loaded { th.ok } else { th.pending })
.add_modifier(Modifier::BOLD),
));
if !loaded {
spans.push(Span::raw(" "));
spans.push(Span::styled(
s.report_env_not_loaded,
Style::default().fg(th.pending),
));
}
}
Line::from(spans)
}
None => {
let msg = if rt.report.collection_ref().is_some() {
s.report_collection_missing
} else {
s.report_unbound
};
Line::from(Span::styled(msg, Style::default().fg(th.pending)))
}
};
let (base, anchored) = report_base_dir(&rt.report);
let mut dir_spans = vec![
Span::styled(s.report_base_dir_prefix, Style::default().fg(th.dim)),
Span::raw(" "),
Span::styled(
base.display().to_string(),
Style::default().fg(if anchored { th.text } else { th.pending }),
),
];
if !anchored {
dir_spans.push(Span::raw(" "));
dir_spans.push(Span::styled(
s.report_base_dir_unsaved,
Style::default().fg(th.pending),
));
}
let mut lines = vec![binding, Line::from(dir_spans)];
if app.report_has_params(idx) {
let mut spans = vec![
Span::styled(
format!("{}:", s.param_summary_prefix),
Style::default().fg(th.dim),
),
Span::raw(" "),
];
spans.extend(app.report_param_spans(idx, s, th));
if app.report_params_changed_since_result(idx) {
spans.push(Span::styled(
format!(" · {}", s.param_changed_since_run),
Style::default().fg(th.accent),
));
}
spans.push(Span::styled(" ", Style::default().fg(th.dim)));
spans.push(Span::styled(
s.param_summary_hint,
Style::default().fg(th.accent),
));
lines.push(Line::from(spans));
}
lines
}
fn draw_report_binding(
f: &mut Frame,
area: Rect,
app: &TuiApp,
idx: usize,
s: &Strings,
th: &Theme,
) {
let rt = &app.reports[idx];
let lines = binding_lines(app, idx, s, th);
let title = if app.running_reports.contains_key(&rt.report.id) {
format!(
"{} — {}",
s.report_binding_heading, s.report_running_indicator
)
} else {
s.report_binding_heading.to_string()
};
f.render_widget(
Paragraph::new(lines)
.block(panel(title, false, th))
.wrap(Wrap { trim: true }),
area,
);
}
fn draw_report_source(
f: &mut Frame,
area: Rect,
app: &mut TuiApp,
idx: usize,
s: &Strings,
th: &Theme,
) {
let editing = app.reports[idx].editor.is_some();
let hint = if editing {
s.report_hint_leave.to_string()
} else {
let mut hint = format!(
"{} · {} · {} · {} · {} · {}",
s.report_hint_edit,
s.report_hint_run,
s.report_hint_dry,
s.report_hint_bind,
s.report_hint_nodes,
s.report_hint_format
);
if app.reports[idx].result.is_some() {
hint.push_str(" · ");
hint.push_str(s.report_hint_view);
}
if app.report_has_params(idx) {
hint.push_str(" · ");
hint.push_str(s.param_open_hint);
}
hint
};
let title = format!("{} — {}", s.report_source_heading, hint);
let block = panel(title, editing || app.report_body_focused(), th);
let ctx = super::report_highlight::HlCtx {
error_line: app.reports[idx].parse_error_line,
collection_resolves: app
.resolve_bound_collection(&app.reports[idx].report)
.is_some(),
loaded_envs: app.global_envs.iter().map(|e| e.name.clone()).collect(),
request_names: app
.resolve_bound_collection(&app.reports[idx].report)
.map(|ci| {
app.collections[ci]
.entries
.iter()
.map(|e| e.title.clone())
.collect()
})
.unwrap_or_default(),
};
if editing {
let inner = block.inner(area);
f.render_widget(block, area);
let completion = app.report_completion(idx);
if let Some(editor) = app.reports[idx].editor.as_ref() {
render_editor_highlighted(f, inner, editor, th, |row, line| {
super::report_highlight::highlight_row(row, line, &ctx, th)
});
if let Some(completion) = completion {
draw_editor_ghost(f, inner, editor, &completion.ghost, th);
}
}
return;
}
let body = app.reports[idx].report.text.clone();
let trimmed = body.trim_end();
let lines = if trimmed.is_empty() {
vec![Line::from(Span::styled(
s.report_empty_source,
Style::default().fg(th.dim),
))]
} else {
super::report_highlight::highlight_source(trimmed, &ctx, th)
};
let (inner, bar) = draw_report_panel(
f,
area,
block,
&mut app.reports[idx].source_panel,
&lines,
th,
);
app.report_pane_areas[ReportPane::Source.idx()] = inner;
app.report_pane_bars[ReportPane::Source.idx()] = bar;
app.push_mouse_hit(
MouseLayer::Base,
bar,
MouseHitTarget::Scroll(MouseScrollTarget::ReportPane(ReportPane::Source)),
);
app.push_mouse_hit(
MouseLayer::Base,
inner,
MouseHitTarget::FocusPane(Pane::Main),
);
app.push_mouse_hit(
MouseLayer::Base,
inner,
MouseHitTarget::Scroll(MouseScrollTarget::ReportPane(ReportPane::Source)),
);
}
fn report_validation_lines(rt: &ReportTab, s: &Strings, th: &Theme) -> Vec<Line<'static>> {
if let Some(err) = &rt.parse_error {
return vec![Line::from(Span::styled(
err.clone(),
Style::default().fg(th.err),
))];
}
if rt.diagnostics.is_empty() {
return vec![Line::from(Span::styled(
s.report_no_diagnostics,
Style::default().fg(th.ok),
))];
}
rt.diagnostics
.iter()
.map(|d| {
let (icon, colour) = match d.severity {
Severity::Error => ("✗ ", th.err),
Severity::Warning => ("! ", th.pending),
};
Line::from(vec![
Span::styled(icon, Style::default().fg(colour)),
Span::styled(d.message.clone(), Style::default().fg(th.text)),
])
})
.collect()
}
const VALIDATION_MAX_ROWS: u16 = 5;
fn validation_height(rt: &ReportTab, s: &Strings, th: &Theme, area: Rect) -> u16 {
let lines = report_validation_lines(rt, s, th);
let width = area.width.saturating_sub(2) as usize;
let rows = if width == 0 {
lines.len() as u16
} else {
let mut probe = MultiSelectPanel::new();
probe.set_styled_content(&lines, width);
probe.total_rows().min(u16::MAX as u32) as u16
};
let room = area.height.saturating_sub(4 + 5).max(3);
(rows + 2).min(room).min(VALIDATION_MAX_ROWS + 2).max(3)
}
fn draw_report_validation(
f: &mut Frame,
area: Rect,
app: &mut TuiApp,
idx: usize,
s: &Strings,
th: &Theme,
) {
let lines = report_validation_lines(&app.reports[idx], s, th);
let block = panel(s.report_validation_heading.to_string(), false, th);
let (inner, bar) = draw_report_panel(
f,
area,
block,
&mut app.reports[idx].validation_panel,
&lines,
th,
);
app.report_pane_areas[ReportPane::Validation.idx()] = inner;
app.report_pane_bars[ReportPane::Validation.idx()] = bar;
app.push_mouse_hit(
MouseLayer::Base,
bar,
MouseHitTarget::Scroll(MouseScrollTarget::ReportPane(ReportPane::Validation)),
);
app.push_mouse_hit(
MouseLayer::Base,
inner,
MouseHitTarget::FocusPane(Pane::Main),
);
app.push_mouse_hit(
MouseLayer::Base,
inner,
MouseHitTarget::Scroll(MouseScrollTarget::ReportPane(ReportPane::Validation)),
);
}
#[cfg(test)]
mod export_path_tests {
use super::*;
#[test]
fn time_token_name_overrides_a_saved_report_filename_and_stays_in_its_folder() {
let mut report = Report::from_text("sample", "# name: run_{time}\n# collection: c.hurl\n");
report.path = Some(std::path::PathBuf::from("/tmp/reports/sample.trail"));
let csv = export_path(&report, &report_output_extension(&report));
assert_eq!(csv.parent(), Some(std::path::Path::new("/tmp/reports")));
let file = csv.file_name().unwrap().to_string_lossy().into_owned();
assert!(file.starts_with("run_"), "expanded name used: {file}");
assert!(file.ends_with(".csv"));
assert!(!file.contains("{time}"), "token expanded: {file}");
assert!(
!file.starts_with("sample"),
"name wins over the file stem: {file}"
);
}
#[test]
fn without_a_token_a_saved_report_keeps_its_own_stem() {
let mut report = Report::from_text("sample", "# name: My Report\n# collection: c.hurl\n");
report.path = Some(std::path::PathBuf::from("/tmp/reports/sample.trail"));
assert_eq!(
export_path(&report, &report_output_extension(&report)),
std::path::PathBuf::from("/tmp/reports/sample.csv"),
"unchanged behaviour when the name has no token"
);
}
#[test]
fn set_flow_directive_handles_non_ascii_comment_lines() {
let text = "# aaaaaaaé note\n# collection: old.hurl\nREQUEST A\n";
let bound = set_flow_directive(text, "collection", "new.hurl");
assert!(bound.contains("# collection: new.hurl"));
let cols = set_flow_directive("# café ☕ notes\nREQUEST A\n", "columns", "FILE");
assert!(cols.contains("# columns: FILE"));
}
}
#[cfg(test)]
mod source_panel_tests {
use super::*;
use crate::i18n::Language;
use crate::tui::report_highlight::{HlCtx, highlight_source};
use crate::tui::theme::theme;
#[test]
fn source_panel_keeps_blank_lines_as_rows() {
let th = theme(&Language::English);
let ctx = HlCtx::default();
let text = "REQUEST A\n\nREQUEST B\n\nREQUEST C";
let lines = highlight_source(text, &ctx, &th);
assert_eq!(lines.len(), 5, "five source lines, two of them blank");
let mut panel = ReportTab::new(Report::from_text("t", text)).source_panel;
panel.set_styled_content(&lines, 40);
assert_eq!(
panel.total_rows(),
5,
"the selection geometry counts every line, blanks included"
);
assert_eq!(
panel.visible_rows(10).len(),
5,
"the rendered rows match — blank lines are not dropped from the view"
);
}
}