Skip to content
//! egui panels. Split from state so `app.rs` stays a state machine.

use twice_core::{AppSummary, ContainerState, Host, JobOutcome};

#[cfg(test)]
mod widget_tests;

use crate::app::{AutoUpdateChoice, InfrastructureEditor, JobPane, TwiceApp, UpdateDialog};
use crate::backend::UiCommand;
use crate::client::{infrastructure_spec, update_request};

/// A button press in the application table, applied after the row loop ends.
#[derive(Clone, Debug)]
pub(crate) enum RowAction {
    /// Start the application.
    Start(Host),
    /// Stop the application.
    Stop(Host),
    /// Open the image update dialog, pre-filled with the running image.
    OpenUpdate(Host, String),
}

/// Server picker and refresh controls.
pub(crate) fn toolbar(app: &mut TwiceApp, ui: &mut egui::Ui) {
    ui.horizontal(|ui| {
        ui.heading("twice");
        ui.separator();

        let selected = app
            .store
            .current()
            .map_or("no server", |p| p.name.as_str())
            .to_owned();
        egui::ComboBox::from_id_salt("server-picker")
            .selected_text(selected)
            .show_ui(ui, |ui| {
                for (index, profile) in app.store.profiles.iter().enumerate() {
                    let label = format!("{} — {}", profile.name, profile.base_url);
                    if ui
                        .selectable_label(app.store.selected == index, label)
                        .clicked()
                    {
                        app.pending_selection = Some(index);
                    }
                }
            });

        if ui.button("Refresh").clicked() {
            app.backend.send(UiCommand::RefreshApps);
        }
        ui.toggle_value(&mut app.editing_servers, "Servers…");
        ui.toggle_value(&mut app.editing_infrastructure, "Infrastructure…");

        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            let (colour, text) = app.status_line();
            ui.colored_label(colour, text);
        });
    });
}

/// Editor for the saved server profiles, including their API keys.
pub(crate) fn server_editor(app: &mut TwiceApp, ui: &mut egui::Ui) {
    ui.label("Server profiles are stored with owner-only permissions.");
    egui::Grid::new("server-editor")
        .num_columns(2)
        .show(ui, |ui| {
            ui.label("Name");
            ui.text_edit_singleline(&mut app.draft.name);
            ui.end_row();
            ui.label("Base URL");
            ui.text_edit_singleline(&mut app.draft.base_url);
            ui.end_row();
            ui.label("API key");
            ui.add(egui::TextEdit::singleline(&mut app.draft.api_key).password(true));
            ui.end_row();
        });
    ui.horizontal(|ui| {
        if ui.button("Save profile").clicked() {
            app.save_draft();
        }
        if ui.button("Forget selected").clicked() {
            app.forget_selected();
        }
    });
}

/// TOML infrastructure editor. Returns an apply command after local validation.
pub(crate) fn infrastructure_editor(
    editor: &mut InfrastructureEditor,
    ui: &mut egui::Ui,
) -> Option<UiCommand> {
    ui.strong("Infrastructure as code");
    ui.label("Declare one application by hostname, image source, and version.");
    ui.add(
        egui::TextEdit::multiline(&mut editor.document)
            .code_editor()
            .desired_rows(4)
            .desired_width(f32::INFINITY),
    );
    if let Some(err) = &editor.error {
        ui.colored_label(egui::Color32::from_rgb(220, 90, 90), err);
    }
    if ui.button("Apply infrastructure").clicked() {
        match infrastructure_spec(&editor.document) {
            Ok(spec) => {
                editor.error = None;
                return Some(UiCommand::ApplyInfrastructure(Box::new(spec)));
            }
            Err(err) => editor.error = Some(format!("{err:#}")),
        }
    }
    None
}

/// The application table. Returns the button the operator pressed, if any.
pub(crate) fn apps_table(apps: &[AppSummary], ui: &mut egui::Ui) -> Option<RowAction> {
    if apps.is_empty() {
        ui.label("No once-managed applications reported by this server.");
        return None;
    }

    let mut action = None;
    egui::Grid::new("apps")
        .num_columns(6)
        .striped(true)
        .show(ui, |ui| {
            for header in ["Host", "State", "Image", "Status", "Auto-update", ""] {
                ui.strong(header);
            }
            ui.end_row();

            for app in apps {
                ui.label(app.host.as_str());
                ui.colored_label(state_colour(app.state), app.state.label());
                ui.label(app.image.as_str());
                ui.label(&app.status);
                ui.label(match app.auto_update {
                    Some(true) => "on",
                    Some(false) => "off",
                    None => "—",
                });
                ui.horizontal(|ui| {
                    if ui.button("Start").clicked() {
                        action = Some(RowAction::Start(app.host.clone()));
                    }
                    if ui.button("Stop").clicked() {
                        action = Some(RowAction::Stop(app.host.clone()));
                    }
                    if ui.button("Update image…").clicked() {
                        action = Some(RowAction::OpenUpdate(
                            app.host.clone(),
                            app.image.as_str().to_owned(),
                        ));
                    }
                });
                ui.end_row();
            }
        });
    action
}

/// Modal for rolling one application onto a new image.
pub(crate) fn update_dialog(dialog: &mut UpdateDialog, ctx: &egui::Context) -> Option<UiCommand> {
    let mut command = None;
    let mut open = true;
    egui::Window::new(format!("Update {}", dialog.host))
        .open(&mut open)
        .collapsible(false)
        .resizable(false)
        .show(ctx, |ui| {
            ui.label("New image reference");
            ui.add(egui::TextEdit::singleline(&mut dialog.image).desired_width(420.0));
            ui.horizontal(|ui| {
                ui.label("Auto-update:");
                ui.selectable_value(&mut dialog.auto_update, AutoUpdateChoice::Inherit, "leave");
                ui.selectable_value(&mut dialog.auto_update, AutoUpdateChoice::Enable, "on");
                ui.selectable_value(&mut dialog.auto_update, AutoUpdateChoice::Disable, "off");
            });
            if let Some(err) = &dialog.error {
                ui.colored_label(egui::Color32::from_rgb(220, 90, 90), err);
            }
            ui.separator();
            if ui.button("Roll onto this image").clicked() {
                match update_request(&dialog.image, dialog.auto_update.as_flag()) {
                    Ok(request) => {
                        command = Some(UiCommand::UpdateImage {
                            host: dialog.host.clone(),
                            request: Box::new(request),
                        });
                    }
                    Err(err) => dialog.error = Some(format!("{err:#}")),
                }
            }
        });
    dialog.closed = !open;
    command
}

/// Live output of the most recent job.
pub(crate) fn job_pane(job: &JobPane, ui: &mut egui::Ui) {
    ui.horizontal(|ui| {
        ui.strong(&job.title);
        match &job.outcome {
            None | Some(JobOutcome::Running) => ui.spinner(),
            Some(JobOutcome::Succeeded) => {
                ui.colored_label(egui::Color32::from_rgb(90, 190, 110), "succeeded")
            }
            Some(JobOutcome::Failed { exit_code, message }) => ui.colored_label(
                egui::Color32::from_rgb(220, 90, 90),
                format!("failed ({}): {message}", exit_code.unwrap_or(-1)),
            ),
        };
    });
    egui::ScrollArea::vertical()
        .stick_to_bottom(true)
        .max_height(220.0)
        .show(ui, |ui| {
            for line in &job.lines {
                ui.monospace(line);
            }
        });
}

const fn state_colour(state: ContainerState) -> egui::Color32 {
    match state {
        ContainerState::Running => egui::Color32::from_rgb(90, 190, 110),
        ContainerState::Paused | ContainerState::Restarting | ContainerState::Created => {
            egui::Color32::from_rgb(220, 180, 80)
        }
        ContainerState::Exited | ContainerState::Removing => egui::Color32::from_rgb(170, 170, 170),
        ContainerState::Dead => egui::Color32::from_rgb(220, 90, 90),
        ContainerState::Unknown => egui::Color32::GRAY,
    }
}

/// Convenience used by the dialog to convert the tri-state control.
impl AutoUpdateChoice {
    pub(crate) const fn as_flag(self) -> Option<bool> {
        match self {
            Self::Inherit => None,
            Self::Enable => Some(true),
            Self::Disable => Some(false),
        }
    }
}