use crate::tui::model::{
jobs::JobState, query::QueryModel, session::SessionModel, settings::SettingsModel, Model,
Popup,
};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
Frame,
};
const ERROR_POPUP_WIDTH: u16 = 60;
const ERROR_POPUP_HEIGHT: u16 = 30;
const SETTINGS_EDIT_POPUP_WIDTH: u16 = 60;
const SETTINGS_EDIT_POPUP_HEIGHT: u16 = 25;
const JOB_NAME_INPUT_POPUP_WIDTH: u16 = 50;
const JOB_NAME_INPUT_POPUP_HEIGHT: u16 = 20;
const SESSION_NAME_INPUT_POPUP_WIDTH: u16 = 50;
const SESSION_NAME_INPUT_POPUP_HEIGHT: u16 = 20;
const JOB_DETAILS_POPUP_WIDTH: u16 = 80;
const JOB_DETAILS_POPUP_HEIGHT: u16 = 80;
pub fn render(f: &mut Frame, popup: &Popup, model: &Model) {
match popup {
Popup::Error(msg) => render_error(f, msg),
Popup::Success(msg) => render_success(f, msg),
Popup::SettingsEdit => render_settings_edit(f, &model.settings),
Popup::JobNameInput => render_job_name_input(f, &model.query),
Popup::SessionNameInput => render_session_name_input(f, &model.sessions),
Popup::JobDetails(job_idx) => {
if let Some(job) = model.jobs.jobs.get(*job_idx) {
render_job_details(f, job);
}
}
}
}
fn render_error(f: &mut Frame, msg: &str) {
let area = centered_rect(ERROR_POPUP_WIDTH, ERROR_POPUP_HEIGHT, f.area());
let paragraph = Paragraph::new(msg)
.block(
Block::default()
.borders(Borders::ALL)
.title("Error")
.style(Style::default().bg(Color::Black).fg(Color::Red)),
)
.wrap(Wrap { trim: false });
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
fn render_success(f: &mut Frame, msg: &str) {
let area = centered_rect(ERROR_POPUP_WIDTH, ERROR_POPUP_HEIGHT, f.area());
let paragraph = Paragraph::new(msg)
.block(
Block::default()
.borders(Borders::ALL)
.title("Success")
.style(Style::default().bg(Color::Black).fg(Color::Green)),
)
.wrap(Wrap { trim: false });
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
fn render_settings_edit(f: &mut Frame, settings: &SettingsModel) {
let area = centered_rect(
SETTINGS_EDIT_POPUP_WIDTH,
SETTINGS_EDIT_POPUP_HEIGHT,
f.area(),
);
let input = settings.editing.as_deref().unwrap_or("");
let text = format!(
"Edit {}\n\nValue: {}_\n\nPress Enter to save, Esc to cancel",
settings.get_selected_name(),
input
);
let paragraph = Paragraph::new(text).block(
Block::default()
.borders(Borders::ALL)
.title("Edit Setting")
.style(Style::default().bg(Color::Black)),
);
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
fn render_job_name_input(f: &mut Frame, query: &QueryModel) {
let area = centered_rect(
JOB_NAME_INPUT_POPUP_WIDTH,
JOB_NAME_INPUT_POPUP_HEIGHT,
f.area(),
);
let input = query.job_name_input.as_deref().unwrap_or("");
let text = format!("Job Name: {}_", input);
let paragraph = Paragraph::new(text).block(
Block::default()
.borders(Borders::ALL)
.title("Enter Job Name")
.style(Style::default().bg(Color::Black)),
);
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
fn render_session_name_input(f: &mut Frame, sessions: &SessionModel) {
let area = centered_rect(
SESSION_NAME_INPUT_POPUP_WIDTH,
SESSION_NAME_INPUT_POPUP_HEIGHT,
f.area(),
);
let input = sessions.name_input.as_deref().unwrap_or("");
let text = format!(
"Session Name: {}_\n\nPress Enter to save, Esc to cancel",
input
);
let paragraph = Paragraph::new(text).block(
Block::default()
.borders(Borders::ALL)
.title("New Session")
.style(Style::default().bg(Color::Black)),
);
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
fn render_job_details(f: &mut Frame, job: &JobState) {
use crate::tui::model::jobs::JobStatus;
let area = centered_rect(JOB_DETAILS_POPUP_WIDTH, JOB_DETAILS_POPUP_HEIGHT, f.area());
let can_retry = matches!(job.status, JobStatus::Failed | JobStatus::Completed)
&& job.retry_context.is_some();
let max_text_width = area.width.saturating_sub(6) as usize;
let label_style = Style::default().fg(Color::Rgb(255, 191, 0)); let value_style = Style::default().fg(Color::White);
let mut lines = vec![Line::from("")];
lines.push(Line::from(vec![
Span::styled(" Status: ", label_style),
Span::styled(
job.status.as_str(),
Style::default()
.fg(job.status.color())
.add_modifier(Modifier::BOLD),
),
]));
if let Some(ref result) = job.result {
lines.push(Line::from(vec![
Span::styled(" Workspace: ", label_style),
Span::styled(
format!("{} ({})", result.workspace_name, job.workspace_name),
value_style,
),
]));
lines.push(Line::from(vec![
Span::styled(" Workspace ID: ", label_style),
Span::styled(&result.workspace_id, value_style),
]));
lines.push(Line::from(Span::styled(" Query:", label_style)));
let wrapped_query = wrap_text_with_indent(&result.query, 4, max_text_width);
for wrapped_line in wrapped_query {
lines.push(Line::from(Span::styled(wrapped_line, value_style)));
}
lines.push(Line::from(vec![
Span::styled(" Duration: ", label_style),
Span::styled(format!("{:.2}s", result.elapsed.as_secs_f64()), value_style),
]));
lines.push(Line::from(vec![
Span::styled(" Timestamp: ", label_style),
Span::styled(result.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(), value_style),
]));
match &result.result {
Ok(success) => {
lines.push(Line::from(vec![
Span::styled(" Rows: ", label_style),
Span::styled(success.row_count.to_string(), value_style),
]));
lines.push(Line::from(vec![
Span::styled(" Output: ", label_style),
Span::styled(success.output_path.display().to_string(), value_style),
]));
lines.push(Line::from(vec![
Span::styled(" Size: ", label_style),
Span::styled(format!("{} bytes", success.file_size), value_style),
]));
}
Err(_) => {
let error_message = if let Some(ref error) = job.error {
error.detailed_description()
} else {
result.result.as_ref().unwrap_err().to_string()
};
lines.push(Line::from(Span::styled(" Error:", label_style)));
let wrapped_error = wrap_text_with_indent(&error_message, 4, max_text_width);
for wrapped_line in wrapped_error {
lines.push(Line::from(Span::styled(
wrapped_line,
Style::default().fg(Color::Red),
)));
}
}
}
} else {
lines.push(Line::from(vec![
Span::styled(" Workspace: ", label_style),
Span::styled(&job.workspace_name, value_style),
]));
lines.push(Line::from(Span::styled(" Query:", label_style)));
let wrapped_query = wrap_text_with_indent(&job.query_preview, 4, max_text_width);
for wrapped_line in wrapped_query {
lines.push(Line::from(Span::styled(wrapped_line, value_style)));
}
}
if can_retry {
lines.push(Line::from(""));
let (retry_text, retry_color) = if let Some(error) = &job.error {
if error.is_retryable() {
(" Press 'r' to retry this job", Color::Yellow)
} else {
(" (Cannot retry: query syntax error - fix query first)", Color::DarkGray)
}
} else {
(" Press 'r' to retry this job", Color::Yellow)
};
lines.push(Line::from(Span::styled(
retry_text,
Style::default().fg(retry_color),
)));
} else if matches!(job.status, JobStatus::Failed | JobStatus::Completed) {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" (Cannot retry: missing context)",
Style::default().fg(Color::DarkGray),
)));
}
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title("Job Details")
.style(Style::default().bg(Color::Black)),
);
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
fn wrap_text_with_indent(text: &str, indent: usize, max_width: usize) -> Vec<String> {
const MAX_LINES: usize = 1000;
let mut wrapped_lines = Vec::new();
let indent_str = " ".repeat(indent);
for line in text.lines() {
if wrapped_lines.len() >= MAX_LINES {
wrapped_lines.push(format!("{}... (output truncated after {} lines)", indent_str, MAX_LINES));
break;
}
if line.is_empty() {
wrapped_lines.push(indent_str.clone());
continue;
}
let available_width = max_width.saturating_sub(indent);
if available_width == 0 {
wrapped_lines.push(format!("{}{}", indent_str, line));
continue;
}
let mut remaining = line;
while !remaining.is_empty() {
if wrapped_lines.len() >= MAX_LINES {
wrapped_lines.push(format!("{}... (output truncated after {} lines)", indent_str, MAX_LINES));
return wrapped_lines;
}
if remaining.len() <= available_width {
wrapped_lines.push(format!("{}{}", indent_str, remaining));
break;
}
let mut split_at = available_width;
if let Some(pos) = remaining[..available_width].rfind(' ') {
split_at = pos;
}
wrapped_lines.push(format!("{}{}", indent_str, &remaining[..split_at].trim_end()));
remaining = remaining[split_at..].trim_start();
}
}
wrapped_lines
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
const MIN_POPUP_WIDTH: u16 = 20;
const MIN_POPUP_HEIGHT: u16 = 10;
let target_width = (r.width * percent_x) / 100;
let target_height = (r.height * percent_y) / 100;
let actual_width = target_width.max(MIN_POPUP_WIDTH).min(r.width);
let actual_height = target_height.max(MIN_POPUP_HEIGHT).min(r.height);
let x_offset = r.x + (r.width.saturating_sub(actual_width)) / 2;
let y_offset = r.y + (r.height.saturating_sub(actual_height)) / 2;
Rect {
x: x_offset,
y: y_offset,
width: actual_width,
height: actual_height,
}
}