tuimux 0.2.0

A fast Rust TUI for everything tmux, with full CRUD support.
Documentation
use std::time::{SystemTime, UNIX_EPOCH};

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Padding, Paragraph};

use crate::app::state::State;
use crate::ui::theme;

fn format_relative_time(timestamp: u64) -> String {
    let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);

    let diff = now.saturating_sub(timestamp);

    if diff < 60 {
        format!("{diff}s ago")
    } else if diff < 3600 {
        format!("{}m ago", diff / 60)
    } else if diff < 86400 {
        format!("{}h ago", diff / 3600)
    } else {
        format!("{}d ago", diff / 86400)
    }
}

pub fn render(frame: &mut Frame, area: Rect, state: &State) {
    let mut lines = Vec::new();
    let query = state.filter_query.trim().to_lowercase();
    let filter_active = !query.is_empty();

    if state.filter_mode {
        lines.push(Line::from(format!("Search: {}", state.filter_query)));
        lines.push(Line::from(""));
    }

    if state.sessions.is_empty() {
        lines.push(Line::from("No active tmux sessions"));
    } else {
        for (session_index, session) in state.sessions.iter().enumerate() {
            let session_match = if filter_active {
                let session_name_match = session.name.to_lowercase().contains(&query);
                let window_match = session.windows.iter().any(|window| {
                    window.name.to_lowercase().contains(&query) || window.index.to_lowercase().contains(&query)
                });
                session_name_match || window_match
            } else {
                true
            };

            if !session_match {
                continue;
            }

            let expanded = filter_active || state.expanded_sessions.contains(&session.name);
            let fold = if expanded { "[-]" } else { "[+]" };
            let is_selected_session = state.selected_session == Some(session_index) && state.selected_window.is_none();
            let marker = if is_selected_session { ">" } else { " " };
            let time_str = format_relative_time(session.activity);
            let mut line = Line::from(format!("{marker} {fold} {} ({time_str})", session.name));
            if is_selected_session {
                line = line.style(theme::selected_row());
            }
            lines.push(line);

            if !expanded {
                continue;
            }

            for (window_index, window) in session.windows.iter().enumerate() {
                if filter_active
                    && !window.name.to_lowercase().contains(&query)
                    && !window.index.to_lowercase().contains(&query)
                {
                    continue;
                }

                let is_selected_window =
                    state.selected_session == Some(session_index) && state.selected_window == Some(window_index);
                let marker = if is_selected_window { "*" } else { "-" };
                let time_str = format_relative_time(window.activity);
                let mut line = Line::from(format!("  {marker} [{}] {} ({time_str})", window.index, window.name));
                if is_selected_window {
                    line = line.style(theme::selected_row());
                }
                lines.push(line);
            }
        }
    }

    let focused = state.focus_label() == "tree";

    let tree = Paragraph::new(lines).block(
        Block::default()
            .borders(Borders::ALL)
            .border_style(theme::panel_border(focused))
            .title("Sessions")
            .title_style(theme::panel_title(focused))
            .padding(Padding::new(1, 1, 0, 0)),
    );
    frame.render_widget(tree, area);
}