use std::collections::{HashMap, VecDeque};
use std::time::Instant;
use ratatui::layout::Rect;
use super::LogEntry;
const MAX_LOG_LINES: usize = 2000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LogSource {
Daemon,
UiServer,
Build,
App,
System,
}
impl LogSource {
pub fn all() -> &'static [LogSource] {
&[
LogSource::System,
LogSource::Daemon,
LogSource::UiServer,
LogSource::Build,
LogSource::App,
]
}
pub fn tab_label(self) -> &'static str {
match self {
LogSource::System => "1:system",
LogSource::Daemon => "2:daemon",
LogSource::UiServer => "3:ui",
LogSource::Build => "4:build",
LogSource::App => "5:app",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ServiceStatus {
Pending,
Building,
Starting,
Ready,
Watching,
Changed,
Loaded { reloads: usize },
Failed(String),
Disabled,
}
impl ServiceStatus {
pub fn indicator(&self) -> &'static str {
match self {
ServiceStatus::Ready | ServiceStatus::Loaded { .. } | ServiceStatus::Watching => "●",
ServiceStatus::Building | ServiceStatus::Starting | ServiceStatus::Changed => "◐",
ServiceStatus::Failed(_) => "✗",
ServiceStatus::Pending => "◌",
ServiceStatus::Disabled => "○",
}
}
pub fn color(&self) -> ratatui::style::Color {
use ratatui::style::Color;
match self {
ServiceStatus::Ready | ServiceStatus::Loaded { .. } | ServiceStatus::Watching => {
Color::Green
}
ServiceStatus::Building | ServiceStatus::Starting | ServiceStatus::Changed => {
Color::Yellow
}
ServiceStatus::Failed(_) => Color::Red,
ServiceStatus::Pending | ServiceStatus::Disabled => Color::DarkGray,
}
}
pub fn summary(&self) -> String {
match self {
ServiceStatus::Pending => "pending".into(),
ServiceStatus::Building => "building…".into(),
ServiceStatus::Starting => "starting…".into(),
ServiceStatus::Ready => "ready".into(),
ServiceStatus::Watching => "watching".into(),
ServiceStatus::Changed => "changed!".into(),
ServiceStatus::Loaded { reloads } => format!("loaded (×{reloads})"),
ServiceStatus::Failed(msg) => {
format!("FAILED: {}", msg.chars().take(18).collect::<String>())
}
ServiceStatus::Disabled => "disabled".into(),
}
}
}
pub struct ServiceState {
pub label: &'static str,
pub status: ServiceStatus,
pub detail: String,
}
pub struct Timings {
pub build_started_at: Option<Instant>,
pub last_build_ms: Option<u64>,
pub build_count: usize,
pub daemon_started_at: Option<Instant>,
pub last_reload_ms: Option<u64>,
}
impl Timings {
fn new() -> Self {
Self {
build_started_at: None,
last_build_ms: None,
build_count: 0,
daemon_started_at: None,
last_reload_ms: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownPhase {
Running,
ShuttingDown,
Done,
}
#[derive(Clone, Copy, Default)]
pub struct TuiLayout {
pub service_list: Rect,
pub tab_bar: Rect,
pub log_scroll: Rect,
pub tab_edges: [u16; 5],
}
#[derive(Clone, Copy, Debug)]
pub struct Selection {
pub anchor: usize,
pub head: usize,
}
impl Selection {
pub fn range(self) -> (usize, usize) {
(self.anchor.min(self.head), self.anchor.max(self.head))
}
pub fn contains(self, idx: usize) -> bool {
let (s, e) = self.range();
idx >= s && idx <= e
}
}
pub struct AppState {
pub app_name: String,
pub app_version: String,
pub mode: String,
services: Vec<(LogSource, ServiceState)>,
logs: HashMap<LogSource, VecDeque<String>>,
pub active_pane: LogSource,
pub auto_scroll: bool,
pub scroll_pos: usize,
pub search_query: String,
pub search_input_active: bool,
pub last_render_height: usize,
pub shutdown_phase: ShutdownPhase,
pub timings: Timings,
pub layout: TuiLayout,
pub log_scroll_start: usize,
pub selection: Option<Selection>,
pub copy_flash: Option<std::time::Instant>,
}
impl AppState {
pub fn new(app_name: String, app_version: String, mode: String) -> Self {
let services = vec![
(
LogSource::Daemon,
ServiceState {
label: "daemon",
status: ServiceStatus::Pending,
detail: String::new(),
},
),
(
LogSource::UiServer,
ServiceState {
label: "ui-server",
status: ServiceStatus::Pending,
detail: String::new(),
},
),
(
LogSource::Build,
ServiceState {
label: "build",
status: ServiceStatus::Pending,
detail: String::new(),
},
),
(
LogSource::App,
ServiceState {
label: "app",
status: ServiceStatus::Pending,
detail: String::new(),
},
),
(
LogSource::System,
ServiceState {
label: "system",
status: ServiceStatus::Ready,
detail: String::new(),
},
),
];
Self {
app_name,
app_version,
mode,
services,
logs: HashMap::new(),
active_pane: LogSource::System,
auto_scroll: true,
scroll_pos: 0,
search_query: String::new(),
search_input_active: false,
last_render_height: 24,
shutdown_phase: ShutdownPhase::Running,
timings: Timings::new(),
layout: TuiLayout::default(),
log_scroll_start: 0,
selection: None,
copy_flash: None,
}
}
pub fn push_log(&mut self, entry: LogEntry) {
let buf = self.logs.entry(entry.source).or_default();
let line = strip_ansi(&entry.line);
if line.is_empty() {
return;
}
buf.push_back(line);
if buf.len() > MAX_LOG_LINES {
buf.pop_front();
if !self.auto_scroll && entry.source == self.active_pane {
self.scroll_pos = self.scroll_pos.saturating_sub(1);
}
}
if self.auto_scroll && entry.source == self.active_pane && self.search_query.is_empty() {
self.scroll_pos = buf.len();
}
}
pub fn update_service(
&mut self,
source: LogSource,
status: ServiceStatus,
detail: Option<String>,
) {
match (&source, &status) {
(LogSource::Build, ServiceStatus::Building) => {
self.timings.build_started_at = Some(Instant::now());
}
(LogSource::Build, ServiceStatus::Ready) => {
if let Some(started) = self.timings.build_started_at.take() {
self.timings.last_build_ms = Some(started.elapsed().as_millis() as u64);
self.timings.build_count += 1;
}
}
(LogSource::Build, ServiceStatus::Failed(_)) => {
if let Some(started) = self.timings.build_started_at.take() {
self.timings.last_build_ms = Some(started.elapsed().as_millis() as u64);
self.timings.build_count += 1;
}
}
(LogSource::Daemon, ServiceStatus::Starting) => {
self.timings.daemon_started_at = Some(Instant::now());
}
(LogSource::App, ServiceStatus::Loaded { .. }) => {
if let Some(d) = &detail {
if let Some(rest) = d.strip_prefix("last reload: ") {
if let Some(ms_str) = rest.strip_suffix("ms") {
if let Ok(ms) = ms_str.parse::<u64>() {
self.timings.last_reload_ms = Some(ms);
}
}
}
}
}
_ => {}
}
if let Some((_, svc)) = self.services.iter_mut().find(|(s, _)| *s == source) {
svc.status = status;
if let Some(d) = detail {
svc.detail = d;
}
}
}
pub fn services(&self) -> &[(LogSource, ServiceState)] {
&self.services
}
pub fn log_lines(&self, source: LogSource) -> &VecDeque<String> {
static EMPTY: std::sync::OnceLock<VecDeque<String>> = std::sync::OnceLock::new();
self.logs
.get(&source)
.unwrap_or_else(|| EMPTY.get_or_init(VecDeque::new))
}
pub fn visible_start_for(&self, total: usize, height: usize) -> usize {
if self.auto_scroll {
total.saturating_sub(height)
} else {
self.scroll_pos.min(total.saturating_sub(height))
}
}
pub fn cycle_pane(&mut self, delta: i32) {
let sources = LogSource::all();
let pos = sources
.iter()
.position(|s| *s == self.active_pane)
.unwrap_or(0);
let next = ((pos as i32 + delta).rem_euclid(sources.len() as i32)) as usize;
self.active_pane = sources[next];
self.auto_scroll = true;
self.clear_search();
}
pub fn scroll_up(&mut self) {
if self.auto_scroll {
let total = self.log_lines(self.active_pane).len();
self.scroll_pos = total;
self.auto_scroll = false;
}
self.scroll_pos = self.scroll_pos.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
if !self.auto_scroll {
let total = self.log_lines(self.active_pane).len();
self.scroll_pos = (self.scroll_pos + 1).min(total);
}
}
pub fn page_up(&mut self) {
let step = (self.last_render_height / 2).max(1);
if self.auto_scroll {
let total = self.log_lines(self.active_pane).len();
self.scroll_pos = total;
self.auto_scroll = false;
}
self.scroll_pos = self.scroll_pos.saturating_sub(step);
}
pub fn page_down(&mut self) {
if !self.auto_scroll {
let step = (self.last_render_height / 2).max(1);
let total = self.log_lines(self.active_pane).len();
self.scroll_pos = (self.scroll_pos + step).min(total);
}
}
pub fn scroll_top(&mut self) {
self.auto_scroll = false;
self.scroll_pos = 0;
}
pub fn scroll_bottom(&mut self) {
self.auto_scroll = true;
}
pub fn clear_active_pane(&mut self) {
self.logs.remove(&self.active_pane);
self.scroll_pos = 0;
self.auto_scroll = true;
self.clear_search();
}
pub fn enter_search(&mut self) {
self.search_input_active = true;
}
pub fn exit_search_input(&mut self) {
self.search_input_active = false;
}
pub fn clear_search(&mut self) {
self.search_query.clear();
self.search_input_active = false;
self.auto_scroll = true;
}
pub fn search_push(&mut self, c: char) {
self.search_query.push(c);
self.scroll_pos = 0;
self.auto_scroll = false;
}
pub fn search_backspace(&mut self) {
self.search_query.pop();
self.scroll_pos = 0;
if self.search_query.is_empty() {
self.auto_scroll = true;
}
}
pub fn matches(&self, line: &str) -> bool {
fuzzy_match(line, &self.search_query)
}
pub fn match_positions(&self, line: &str) -> Option<Vec<usize>> {
if self.search_query.is_empty() {
return None;
}
fuzzy_positions(line, &self.search_query)
}
pub fn get_selected_text(&self) -> String {
let sel = match self.selection {
Some(s) => s,
None => return String::new(),
};
let (start, end) = sel.range();
self.log_lines(self.active_pane)
.iter()
.enumerate()
.skip(start)
.take_while(|(i, _)| *i <= end)
.map(|(_, l)| l.as_str())
.collect::<Vec<_>>()
.join("\n")
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut it = s.chars().peekable();
while let Some(c) = it.next() {
match c {
'\x1b' => match it.peek() {
Some('[') => {
it.next();
for ch in it.by_ref() {
if ch.is_ascii() && (0x40..=0x7e).contains(&(ch as u8)) {
break;
}
}
}
Some(']') => {
it.next();
loop {
match it.next() {
None | Some('\x07') => break,
Some('\x1b') => {
if it.peek() == Some(&'\\') {
it.next();
}
break;
}
_ => {}
}
}
}
_ => {}
},
'\r' => out.clear(),
_ => out.push(c),
}
}
out
}
pub fn fuzzy_match(line: &str, query: &str) -> bool {
if query.is_empty() {
return true;
}
let mut line_chars = line.chars().flat_map(char::to_lowercase);
query
.chars()
.flat_map(char::to_lowercase)
.all(|q| line_chars.any(|c| c == q))
}
pub fn fuzzy_positions(line: &str, query: &str) -> Option<Vec<usize>> {
if query.is_empty() {
return Some(vec![]);
}
let line_chars: Vec<char> = line.chars().collect();
let query_lower: Vec<char> = query.chars().flat_map(char::to_lowercase).collect();
let mut positions = Vec::with_capacity(query_lower.len());
let mut li = 0usize;
for qc in &query_lower {
let found = line_chars[li..].iter().enumerate().find(|(_, lc)| {
lc.to_lowercase().next() == Some(*qc)
});
match found {
Some((offset, _)) => {
positions.push(li + offset);
li += offset + 1;
}
None => return None,
}
}
Some(positions)
}