use std::io;
use std::panic::{self, AssertUnwindSafe};
use std::time::Duration;
use ratatui::crossterm::event::{self, Event};
use ratatui::DefaultTerminal;
use crate::app::{AppState, DispatchOutcome, View};
use crate::error::TuiError;
use crate::provider::{VarMutator, VarProvider};
use crate::theme::Theme;
use crate::views;
const POLL_INTERVAL: Duration = Duration::from_millis(50);
#[allow(clippy::needless_pass_by_value)]
pub fn run_tui<B>(backend: B) -> Result<(), TuiError>
where
B: VarProvider + VarMutator,
{
let mut terminal = ratatui::try_init()?;
let loop_result = event_loop(&mut terminal, &backend);
match (loop_result, ratatui::try_restore()) {
(Ok(()), Ok(())) => Ok(()),
(Ok(()), Err(restore_err)) => Err(TuiError::Terminal(restore_err)),
(Err(loop_err), Ok(())) => Err(loop_err),
(Err(loop_err), Err(restore_err)) => {
#[allow(clippy::print_stderr)]
{
eprintln!("evault-tui: terminal restore failed after loop error: {restore_err}");
}
Err(loop_err)
}
}
}
#[allow(clippy::too_many_lines)]
fn event_loop<B>(terminal: &mut DefaultTerminal, backend: &B) -> Result<(), TuiError>
where
B: VarProvider + VarMutator + ?Sized,
{
let mut app = AppState::new();
let theme = Theme::dark();
app.refresh(backend)?;
while !app.quit_requested() {
terminal.draw(|frame| views::render(frame, &mut app, &theme))?;
let polled = match event::poll(POLL_INTERVAL) {
Ok(b) => b,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(TuiError::Terminal(e)),
};
if !polled {
continue;
}
let ev = match event::read() {
Ok(ev) => ev,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(TuiError::Terminal(e)),
};
#[allow(clippy::match_same_arms)]
match ev {
Event::Key(key) => match app.dispatch_key(key) {
DispatchOutcome::Continue => {}
DispatchOutcome::RefreshRequested => {
match app.refresh(backend) {
Ok(()) => {
let total = app.rows().len();
let msg = if app.is_filter_active() {
let matched = app.visible_row_indices().len();
format!("refreshed ({matched}/{total} vars)")
} else {
format!("refreshed ({total} vars)")
};
app.set_info_toast(msg);
}
Err(e) => app.set_error_toast(e.to_string()),
}
}
DispatchOutcome::CreateRequested(draft) => {
let name = draft.name.clone();
let create_result =
panic::catch_unwind(AssertUnwindSafe(|| backend.create(draft)));
match create_result {
Err(_) => {
app.show_error_modal(
"create failed",
"backend panicked while creating the variable",
Some(
"this is a bug in the backend; restart and \
report the issue if it persists."
.into(),
),
);
}
Ok(Err(e)) => {
let msg = e.to_string();
let hint = create_hint(&msg);
app.show_error_modal("create failed", msg, hint);
}
Ok(Ok(_id)) => {
if let Err(e) = app.refresh(backend) {
app.set_error_toast(format!(
"created `{name}` but refresh failed: {e}"
));
} else {
app.set_info_toast(format!("created `{name}`"));
}
}
}
}
DispatchOutcome::UpdateValueRequested { id, value, name } => {
let update_result =
panic::catch_unwind(AssertUnwindSafe(|| backend.update_value(id, value)));
match update_result {
Err(_) => {
app.show_error_modal(
"update failed",
"backend panicked while updating the value",
Some(
"this is a bug in the backend; restart \
and report the issue if it persists."
.into(),
),
);
}
Ok(Err(e)) => {
let msg = e.to_string();
let hint = update_hint(&msg);
app.show_error_modal("update failed", msg, hint);
}
Ok(Ok(())) => {
if let Err(e) = app.refresh(backend) {
app.set_error_toast(format!(
"updated `{name}` but refresh failed: {e}"
));
} else {
app.set_info_toast(format!("updated `{name}`"));
}
}
}
}
DispatchOutcome::LinkRequested {
id,
name,
project_path,
profile,
materialize,
} => {
let result = panic::catch_unwind(AssertUnwindSafe(|| {
backend.link_to_project(
id,
name.clone(),
project_path.clone(),
profile.clone(),
materialize,
)
}));
match result {
Err(_) => {
app.show_error_modal(
"link failed",
"backend panicked while linking the variable",
Some(
"this is a bug in the backend; restart \
and report the issue if it persists."
.into(),
),
);
}
Ok(Err(e)) => {
let msg = e.to_string();
let hint = link_hint(&msg);
app.show_error_modal("link failed", msg, hint);
}
Ok(Ok(())) => {
let suffix = if materialize { " + .env" } else { "" };
if let Err(e) = app.refresh(backend) {
app.set_error_toast(format!(
"linked `{name}` to {}{suffix} but refresh failed: {e}",
project_path.display()
));
} else {
app.set_info_toast(format!(
"linked `{name}` to {}{suffix}",
project_path.display()
));
}
}
}
}
DispatchOutcome::RunRequested {
project_path,
profile,
program,
args,
} => {
if let Err(e) = ratatui::try_restore() {
app.show_error_modal(
"run failed",
format!("could not restore the terminal before spawning: {e}"),
None,
);
continue;
}
let outcome = panic::catch_unwind(AssertUnwindSafe(|| {
backend.run_in_project(
project_path.clone(),
profile.clone(),
program.clone(),
args.clone(),
)
}));
*terminal = ratatui::try_init().map_err(TuiError::Terminal)?;
match outcome {
Err(_) => {
app.show_error_modal(
"run failed",
"backend panicked while running the command",
Some(
"this is a bug in the backend; restart \
and report the issue if it persists."
.into(),
),
);
}
Ok(Err(e)) => {
let msg = e.to_string();
let hint = run_hint(&msg);
app.show_error_modal("run failed", msg, hint);
}
Ok(Ok(code)) => {
let cmd_repr = if args.is_empty() {
program.clone()
} else {
format!("{program} {}", args.join(" "))
};
let msg = match code {
Some(0) => format!("ran `{cmd_repr}` (exit 0)"),
Some(c) => format!("ran `{cmd_repr}` (exit {c})"),
None => format!("ran `{cmd_repr}` (killed by signal)"),
};
if let Err(e) = app.refresh(backend) {
app.set_error_toast(format!("{msg} but refresh failed: {e}"));
} else {
app.set_info_toast(msg);
}
}
}
}
DispatchOutcome::ViewValueRequested { id, name } => {
let result = panic::catch_unwind(AssertUnwindSafe(|| backend.get_value(id)));
match result {
Err(_) => {
app.set_error_toast("view value crashed: backend panicked");
}
Ok(Err(e)) => {
app.set_error_toast(format!("view value failed: {e}"));
}
Ok(Ok(None)) => {
app.set_error_toast(format!("value missing for `{name}`"));
}
Ok(Ok(Some(value))) => {
app.show_value_modal(name, value);
}
}
}
DispatchOutcome::DeleteRequested { id, name } => {
let delete_result =
panic::catch_unwind(AssertUnwindSafe(|| backend.delete(id)));
match delete_result {
Err(_) => {
app.show_error_modal(
"delete failed",
"backend panicked while deleting the variable",
Some(
"this is a bug in the backend; restart \
and report the issue if it persists."
.into(),
),
);
}
Ok(Err(e)) => {
let msg = e.to_string();
app.show_error_modal("delete failed", msg, None);
}
Ok(Ok(())) => {
if matches!(app.current_view(), View::Detail) {
app.return_to_dashboard();
}
match app.refresh(backend) {
Ok(()) => {
app.set_info_toast(format!("deleted `{name}`"));
}
Err(e) => {
app.splice_out_row(id);
app.set_error_toast(format!(
"deleted `{name}` but refresh failed: {e}"
));
}
}
}
}
}
},
Event::Resize(_, _) => {}
_ => {}
}
}
Ok(())
}
fn create_hint(msg: &str) -> Option<String> {
let lower = msg.to_ascii_lowercase();
if lower.contains("invalid character") || lower.contains("invalid name") {
return Some(
"Variable names have these rules:\n\
\u{2022} Start with a letter (A-Z or a-z) or an underscore (_)\n\
\u{2022} After the first character, use only letters, digits, \
or underscores\n\
\u{2022} Maximum 64 characters\n\
\u{2022} Not allowed: dashes, spaces, dots, accents, or other \
punctuation\n\
\n\
Try a name like API_KEY, DATABASE_URL, or my_token."
.to_owned(),
);
}
if lower.contains("duplicate") || lower.contains("already exists") {
return Some(
"A variable with that name already exists. Pick a different \
name, or press e on the existing row to update its value."
.to_owned(),
);
}
if lower.contains("empty") {
return Some("The value field cannot be empty.".to_owned());
}
if lower.contains("too long") {
return Some(
"Names are limited to 64 characters. Values typically cap \
around 1 MB depending on the storage backend."
.to_owned(),
);
}
None
}
fn update_hint(msg: &str) -> Option<String> {
let lower = msg.to_ascii_lowercase();
if lower.contains("empty") {
return Some("The new value cannot be empty.".to_owned());
}
if lower.contains("not found") || lower.contains("no variable") {
return Some(
"The variable was deleted by another process before the \
update could complete. Press r to refresh the dashboard."
.to_owned(),
);
}
None
}
fn run_hint(msg: &str) -> Option<String> {
let lower = msg.to_ascii_lowercase();
if lower.contains("manifest") || lower.contains("evault.toml") || lower.contains("no such file")
{
return Some(
"The project must have an evault.toml manifest before \
it can be run. Link a variable to the project first \
(press l on a row), or run `evault link` from the \
shell."
.to_owned(),
);
}
if lower.contains("program not found") || lower.contains("not found") {
return Some(
"The program was not found on PATH inside the project \
directory. Check the spelling, or use an absolute path \
(for example `./node_modules/.bin/jest` or \
`C:\\Program Files\\app\\app.exe`)."
.to_owned(),
);
}
if lower.contains("permission") {
return Some(
"The OS refused to spawn the program. On Unix, mark the \
file executable with `chmod +x`. On Windows, check that \
the file is not blocked by `Unblock-File`."
.to_owned(),
);
}
None
}
fn link_hint(msg: &str) -> Option<String> {
let lower = msg.to_ascii_lowercase();
if lower.contains("create project dir") || lower.contains("permission") {
return Some(
"Could not create the project directory. Check that the \
path is writable and try again with a different path."
.to_owned(),
);
}
if lower.contains("canonicalise") || lower.contains("canonicalize") {
return Some(
"Could not resolve the project path. Check that the path \
syntax is valid for your platform (use forward slashes on \
Linux/macOS, backslashes or forward slashes on Windows)."
.to_owned(),
);
}
if lower.contains("manifest") {
return Some(
"Could not read or write the project's evault.toml file. \
Check filesystem permissions on the project directory."
.to_owned(),
);
}
if lower.contains("materialize") {
return Some(
"Linking succeeded but writing the .env file failed. The \
binding is recorded; you can retry materialization later \
with evault gen --project PATH from the shell."
.to_owned(),
);
}
None
}