use crate::events::{ActionOutcome, SessionEvent, SessionState, SessionTail};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
const MAX_ROWS: usize = 2_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Tab {
Live,
Sessions,
Store,
}
impl Tab {
pub(crate) const ALL: [Tab; 3] = [Tab::Live, Tab::Sessions, Tab::Store];
pub(crate) fn title(self) -> &'static str {
match self {
Self::Live => "Live",
Self::Sessions => "Sessions",
Self::Store => "Store",
}
}
fn next(self) -> Self {
match self {
Self::Live => Self::Sessions,
Self::Sessions => Self::Store,
Self::Store => Self::Live,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct Row {
pub outcome: ActionOutcome,
pub crate_name: Option<String>,
pub duration_ns: u64,
}
#[derive(Debug, Default)]
pub(crate) struct Session {
pub id: String,
pub workspace_root: Option<PathBuf>,
pub command: Vec<String>,
pub state: SessionState,
pub started_ms: u64,
pub finished_ms: Option<u64>,
pub truncated: bool,
pub counts: BTreeMap<String, u64>,
pub avoided_compiler_ns: u64,
pub restored_bytes: u64,
pub rows: Vec<Row>,
pub totals: Option<serde_json::Value>,
}
impl Session {
fn apply(&mut self, event: SessionEvent) {
match event {
SessionEvent::SessionStarted {
ts_ms,
workspace_root,
command,
..
} => {
self.started_ms = ts_ms;
self.workspace_root = Some(workspace_root);
self.command = command;
}
SessionEvent::Action {
outcome,
crate_name,
duration_ns,
detail,
..
} => {
*self.counts.entry(outcome.label().to_string()).or_insert(0) += 1;
self.avoided_compiler_ns = self
.avoided_compiler_ns
.saturating_add(detail.avoided_compiler_ns);
self.restored_bytes = self.restored_bytes.saturating_add(detail.output_bytes);
if self.rows.len() == MAX_ROWS {
self.rows.remove(0);
}
self.rows.push(Row {
outcome,
crate_name,
duration_ns,
});
}
SessionEvent::Truncated { .. } => self.truncated = true,
SessionEvent::SessionFinished { ts_ms, stats, .. } => {
self.finished_ms = Some(ts_ms);
self.totals = Some(stats);
}
}
}
pub(crate) fn title(&self) -> String {
if self.command.is_empty() {
return self.id.clone();
}
format!("mbx {}", self.command.join(" "))
}
pub(crate) fn workspace_name(&self) -> Option<&str> {
self.workspace_root
.as_deref()
.and_then(Path::file_name)
.and_then(std::ffi::OsStr::to_str)
}
pub(crate) fn count(&self, label: &str) -> u64 {
self.counts.get(label).copied().unwrap_or(0)
}
fn consulted(&self) -> u64 {
self.count("hit").saturating_add(self.count("miss"))
}
pub(crate) fn hit_rate(&self) -> Option<f64> {
let consulted = self.consulted();
(consulted > 0).then(|| self.count("hit") as f64 * 100.0 / consulted as f64)
}
pub(crate) fn bypasses(&self) -> Vec<(&str, u64)> {
let mut bypasses: Vec<(&str, u64)> = self
.counts
.iter()
.filter(|(label, _)| {
!matches!(
label.as_str(),
"hit" | "miss" | "unconsulted" | "verification"
)
})
.map(|(label, count)| (label.as_str(), *count))
.collect();
bypasses.sort_by_key(|(label, count)| (std::cmp::Reverse(*count), *label));
bypasses
}
}
pub(crate) struct App {
store: PathBuf,
tails: Vec<(SessionTail, Session)>,
pub tab: Tab,
pub selected: usize,
pub scroll: usize,
pub paused: bool,
pub store_stats: Option<crate::store::StoreStats>,
pub savings: crate::savings::Tally,
}
impl App {
pub(crate) fn new(store: &Path, limit: usize) -> Self {
let mut app = Self {
store: store.to_path_buf(),
tails: Vec::new(),
tab: Tab::Live,
selected: 0,
scroll: 0,
paused: false,
store_stats: None,
savings: crate::savings::Tally::default(),
};
app.discover(limit);
app.refresh_store();
app
}
pub(crate) fn tick(&mut self, limit: usize) {
if self.paused {
return;
}
self.discover(limit);
for (tail, session) in &mut self.tails {
for event in tail.read() {
session.apply(event);
}
session.state = tail.state();
}
self.tails.sort_by_key(|(tail, session)| {
(
session.state != SessionState::Live,
std::cmp::Reverse(tail.id().to_string()),
)
});
self.selected = self.selected.min(self.tails.len().saturating_sub(1));
}
fn discover(&mut self, limit: usize) {
for tail in crate::events::open_tails(&self.store, limit) {
if self.tails.iter().any(|(known, _)| known.id() == tail.id()) {
continue;
}
let session = Session {
id: tail.id().to_string(),
..Session::default()
};
self.tails.push((tail, session));
}
}
pub(crate) fn refresh_store(&mut self) {
self.store_stats = crate::store::stats(&self.store).ok();
self.savings = crate::savings::read_tally(&self.store);
}
pub(crate) fn sessions(&self) -> impl Iterator<Item = &Session> {
self.tails.iter().map(|(_, session)| session)
}
pub(crate) fn is_empty(&self) -> bool {
self.tails.is_empty()
}
pub(crate) fn selected_session(&self) -> Option<&Session> {
self.tails.get(self.selected).map(|(_, session)| session)
}
pub(crate) fn store_dir(&self) -> &Path {
&self.store
}
pub(crate) fn next_tab(&mut self) {
self.tab = self.tab.next();
self.scroll = 0;
}
pub(crate) fn select_tab(&mut self, tab: Tab) {
self.tab = tab;
self.scroll = 0;
}
pub(crate) fn select_next(&mut self) {
if self.tab == Tab::Live {
self.selected = (self.selected + 1).min(self.tails.len().saturating_sub(1));
self.scroll = 0;
} else {
self.scroll = self.scroll.saturating_add(1);
}
}
pub(crate) fn select_previous(&mut self) {
if self.tab == Tab::Live {
self.selected = self.selected.saturating_sub(1);
self.scroll = 0;
} else {
self.scroll = self.scroll.saturating_sub(1);
}
}
pub(crate) fn toggle_pause(&mut self) {
self.paused = !self.paused;
}
}
#[cfg(test)]
#[path = "app_tests.rs"]
mod tests;