shell-cell 1.6.3

Shell-Cell. CLI app to spawn and manage containerized shell environments
mod confirm_remove;
mod error_window;
mod help_window;
mod inspect;
mod loading;
mod ls;
mod removing;
mod stopping;
mod ui;

use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};

use crate::{
    buildkit::{BuildKitD, container_info::SCellContainerInfo, image_info::SCellImageInfo},
    cli::{
        MIN_FPS,
        ls::app::{
            confirm_remove::ConfirmRemoveState,
            error_window::ErrorWindowState,
            help_window::HelpWindowState,
            inspect::{InspectState, ItemToInspect},
            loading::LoadingState,
            ls::LsState,
            removing::RemovingState,
            stopping::StoppingState,
        },
        terminal::Terminal,
    },
};

/// State machine for the `ls` interactive TUI.
///
/// Transitions:
/// - `Loading` → `Ls` (once container list is fetched)
/// - `Ls` → `Stopping` (user presses `s` on a selected container)
/// - `Ls` → `ConfirmRemove` (user presses `r` on a selected container)
/// - `Ls` → `Help` (user presses `h`)
/// - `Ls` → `ShowDefinition` (user presses `i` on a selected item)
/// - `ConfirmRemove` → `Removing` (user confirms with `y`)
/// - `ConfirmRemove` → `Ls` (user cancels with `n` or `Esc`)
/// - `Stopping` → `Loading` (once the item is stopped; triggers a list refresh)
/// - `Stopping` → `Error` (stop operation fails)
/// - `Removing` → `Loading` (once the item is removed; triggers a list refresh)
/// - `Removing` → `Error` (remove operation fails)
/// - `Error` → `Ls` (user presses `Esc`)
/// - `ShowDefinition` → `Ls` (user presses `i` or `Esc`)
/// - Any state → `Exit` (user presses `Ctrl-C` or `Ctrl-D`)
pub enum App {
    Containers(AppInner<SCellContainerInfo>),
    Images(AppInner<SCellImageInfo>),
}

pub trait AppItemSuperTrait: ItemToInspect {}
impl<T: ItemToInspect> AppItemSuperTrait for T {}

pub enum AppInner<Item: AppItemSuperTrait> {
    /// Fetching the item list from Docker in the background.
    Loading(LoadingState<Item>),
    /// Displaying the interactive item table.
    Ls(LsState<Item>),
    /// Displaying the help overlay over the item table.
    HelpWindow(HelpWindowState<Item>),
    /// Stopping a selected item and refreshing the list.
    Stopping(StoppingState<Item>),
    /// Confirming removal of a selected item.
    ConfirmRemove(ConfirmRemoveState<Item>),
    /// Removing a selected item and refreshing the list.
    Removing(RemovingState<Item>),
    /// Displaying an error that occurred during a background operation.
    ErrorWindow(ErrorWindowState<Item>),
    /// Displaying the definition overlay for the selected item.
    Inspect(InspectState<Item>),
    /// Terminal state — the event loop exits.
    Exit,
}

impl App {
    /// Runs the TUI event loop, polling for state transitions and key events.
    pub fn run(
        buildkit: &BuildKitD,
        terminal: &mut Terminal,
    ) -> color_eyre::Result<()> {
        // First step
        let mut app = Self::Containers(LoadingState::<SCellContainerInfo>::load(buildkit.clone()));

        loop {
            let new_app = match app {
                Self::Containers(app) => app.run_one_turn()?.map(Self::Containers),
                Self::Images(app) => app.run_one_turn()?.map(Self::Images),
            };

            let Some(new_app) = new_app else {
                // Exit
                return Ok(());
            };
            app = new_app;

            match &mut app {
                Self::Containers(app) => {
                    terminal.draw(|f| {
                        f.render_widget(app, f.area());
                    })?;
                },
                Self::Images(app) => {
                    terminal.draw(|f| {
                        f.render_widget(app, f.area());
                    })?;
                },
            }

            app = app.handle_key_event(buildkit)?;
        }
    }

    /// Handles a single key event, dispatching navigation and actions
    /// based on the current state.
    fn handle_key_event(
        mut self,
        buildkit: &BuildKitD,
    ) -> color_eyre::Result<Self> {
        if event::poll(MIN_FPS)?
            && let Event::Key(key) = event::read()?
            && key.kind == KeyEventKind::Press
        {
            match self {
                Self::Containers(app) => {
                    self = app.handle_key_event(key)?.map_or_else(
                        || Self::Images(LoadingState::<SCellImageInfo>::load(buildkit.clone())),
                        Self::Containers,
                    );
                },
                Self::Images(app) => {
                    self = app.handle_key_event(key)?.map_or_else(
                        || {
                            Self::Containers(LoadingState::<SCellContainerInfo>::load(
                                buildkit.clone(),
                            ))
                        },
                        Self::Images,
                    );
                },
            }
        }

        Ok(self)
    }
}

impl<Item: Clone + AppItemSuperTrait> AppInner<Item> {
    /// Runs only ONE TUI event loop, polling for state transitions and key events.
    /// Returns `None` if its `Exit` state.
    fn run_one_turn(mut self) -> color_eyre::Result<Option<Self>> {
        if let Self::Loading(state) = self {
            self = state.try_recv()?;
        }

        if let Self::Stopping(state) = self {
            self = state.try_recv()?;
        }

        if let Self::Removing(state) = self {
            self = state.try_recv()?;
        }

        if matches!(self, Self::Exit) {
            return Ok(None);
        }

        Ok(Some(self))
    }
}

impl AppInner<SCellContainerInfo> {
    /// Handles a single key event, dispatching navigation and actions
    /// based on the current state.
    fn handle_key_event(
        mut self,
        key: KeyEvent,
    ) -> color_eyre::Result<Option<Self>> {
        match key.code {
            KeyCode::Char('q') => {
                if let Self::Ls(_) = self {
                    // switching to images
                    return Ok(None);
                }
            },
            KeyCode::Char('c' | 'd') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
                self = Self::Exit;
            },
            KeyCode::Char('h') => {
                match self {
                    Self::Ls(ls_state) => self = Self::HelpWindow(HelpWindowState { ls_state }),
                    Self::HelpWindow(state) => self = Self::Ls(state.ls_state),
                    _ => {},
                }
            },
            KeyCode::Char('i') => {
                match self {
                    Self::Ls(ls_state) => {
                        self = ls_state.inspect()?;
                    },
                    Self::Inspect(state) => self = Self::Ls(state.ls_state),
                    _ => {},
                }
            },
            KeyCode::Down | KeyCode::Char('j') => {
                if let Self::Ls(ref mut ls_state) = self {
                    ls_state.next();
                }
                if let Self::Inspect(ref mut inspect_state) = self {
                    inspect_state.scroll_down();
                }
            },
            KeyCode::Up | KeyCode::Char('k') => {
                if let Self::Ls(ref mut ls_state) = self {
                    ls_state.previous();
                }
                if let Self::Inspect(ref mut inspect_state) = self {
                    inspect_state.scroll_up();
                }
            },
            KeyCode::Char('s') => {
                if let Self::Ls(ls_state) = self {
                    self = ls_state.stop_selected()?;
                }
            },
            KeyCode::Char('r') => {
                if let Self::Ls(ls_state) = self {
                    self = Self::ConfirmRemove(ls_state.confirm_remove()?);
                }
            },
            KeyCode::Char('y') => {
                if let Self::ConfirmRemove(confirm_state) = self {
                    self = confirm_state.confirm();
                }
            },
            KeyCode::Char('n') => {
                if let Self::ConfirmRemove(confirm_state) = self {
                    self = confirm_state.cancel();
                }
            },
            KeyCode::Esc => {
                match self {
                    Self::HelpWindow(state) => self = Self::Ls(state.ls_state),
                    Self::ErrorWindow(error_state) => self = Self::Ls(error_state.ls_state),
                    Self::Inspect(state) => self = Self::Ls(state.ls_state),
                    Self::ConfirmRemove(confirm_state) => {
                        self = confirm_state.cancel();
                    },
                    _ => {},
                }
            },
            _ => {},
        }

        Ok(Some(self))
    }
}

impl AppInner<SCellImageInfo> {
    /// Handles a single key event, dispatching navigation and actions
    /// based on the current state.
    fn handle_key_event(
        mut self,
        key: KeyEvent,
    ) -> color_eyre::Result<Option<Self>> {
        match key.code {
            KeyCode::Char('q') => {
                if let Self::Ls(_) = self {
                    // switching to containers
                    return Ok(None);
                }
            },
            KeyCode::Char('c' | 'd') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
                self = Self::Exit;
            },
            KeyCode::Char('h') => {
                match self {
                    Self::Ls(ls_state) => self = Self::HelpWindow(HelpWindowState { ls_state }),
                    Self::HelpWindow(state) => self = Self::Ls(state.ls_state),
                    _ => {},
                }
            },
            KeyCode::Char('i') => {
                match self {
                    Self::Ls(ls_state) => {
                        self = ls_state.inspect()?;
                    },
                    Self::Inspect(state) => self = Self::Ls(state.ls_state),
                    _ => {},
                }
            },
            KeyCode::Down | KeyCode::Char('j') => {
                if let Self::Ls(ref mut ls_state) = self {
                    ls_state.next();
                }
                if let Self::Inspect(ref mut inspect_state) = self {
                    inspect_state.scroll_down();
                }
            },
            KeyCode::Up | KeyCode::Char('k') => {
                if let Self::Ls(ref mut ls_state) = self {
                    ls_state.previous();
                }
                if let Self::Inspect(ref mut inspect_state) = self {
                    inspect_state.scroll_up();
                }
            },
            KeyCode::Char('r') => {
                if let Self::Ls(ls_state) = self {
                    self = Self::ConfirmRemove(ls_state.confirm_remove()?);
                }
            },
            KeyCode::Char('y') => {
                if let Self::ConfirmRemove(confirm_state) = self {
                    self = confirm_state.confirm();
                }
            },
            KeyCode::Char('n') => {
                if let Self::ConfirmRemove(confirm_state) = self {
                    self = confirm_state.cancel();
                }
            },
            KeyCode::Esc => {
                match self {
                    Self::HelpWindow(state) => self = Self::Ls(state.ls_state),
                    Self::ErrorWindow(error_state) => self = Self::Ls(error_state.ls_state),
                    Self::Inspect(state) => self = Self::Ls(state.ls_state),
                    Self::ConfirmRemove(confirm_state) => {
                        self = confirm_state.cancel();
                    },
                    _ => {},
                }
            },
            _ => {},
        }

        Ok(Some(self))
    }
}