use std::sync::OnceLock;
use ratatui::Frame;
use ratatui::layout::Margin;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Borders, Cell, Clear, HighlightSpacing, Padding, Paragraph, Row, Scrollbar,
ScrollbarOrientation, ScrollbarState, Table, Tabs, Wrap,
};
use crate::config::Config;
use crate::removal::{DryRunResult, RemovalMethod};
use crate::scanner::calculate_expiration;
use super::TuiContext;
use super::{
App, FocusPanel, PendingAuditExport, PendingQuotaTarget, QuotaTargetFocus, SortMode, View,
};
mod palette {
use ratatui::style::Color;
pub const GREEN: Color = Color::Rgb(0, 166, 0);
pub const YELLOW: Color = Color::Rgb(220, 200, 0);
pub const RED: Color = Color::Rgb(220, 80, 80);
pub const CYAN: Color = Color::Rgb(0, 200, 200);
pub const MODAL_BG: Color = Color::Reset;
pub const MODAL_FG: Color = Color::Reset;
pub const MODAL_MUTED: Color = Color::DarkGray;
}
static LIGHT_THEME: OnceLock<bool> = OnceLock::new();
pub(crate) fn detect_terminal_theme() {
let is_light = std::time::Duration::from_millis(100);
let light = matches!(termbg::theme(is_light), Ok(termbg::Theme::Light));
let _ = LIGHT_THEME.set(light);
}
fn is_light_theme() -> bool {
LIGHT_THEME.get().copied().unwrap_or(false)
}
struct FadeGradient {
text: [Color; 101],
green: [Color; 101],
yellow: [Color; 101],
red: [Color; 101],
gray: [Color; 101],
}
impl FadeGradient {
fn new() -> Self {
let fade_end = if is_light_theme() {
(200, 200, 200)
} else {
(130, 130, 130)
};
let text_start = if is_light_theme() {
(40, 40, 40)
} else {
(220, 220, 220)
};
let green_rgb = (0, 166, 0);
let yellow_rgb = (220, 200, 0);
let red_rgb = (220, 80, 80);
Self {
text: Self::generate(text_start, fade_end),
green: Self::generate(green_rgb, fade_end),
yellow: Self::generate(yellow_rgb, fade_end),
red: Self::generate(red_rgb, fade_end),
gray: Self::generate((128, 128, 128), fade_end),
}
}
fn generate(start: (u8, u8, u8), end: (u8, u8, u8)) -> [Color; 101] {
let mut gradient = [Color::Reset; 101];
for (i, slot) in gradient.iter_mut().enumerate() {
let r = Self::lerp_channel(start.0, end.0, i);
let g = Self::lerp_channel(start.1, end.1, i);
let b = Self::lerp_channel(start.2, end.2, i);
*slot = Color::Rgb(r, g, b);
}
gradient
}
#[allow(clippy::cast_possible_truncation)]
fn lerp_channel(start: u8, end: u8, t: usize) -> u8 {
let start_16 = u16::from(start);
let end_16 = u16::from(end);
let t_16 = t.min(100) as u16;
let result = if end_16 >= start_16 {
start_16 + (end_16 - start_16) * t_16 / 100
} else {
start_16 - (start_16 - end_16) * t_16 / 100
};
result as u8
}
fn fade_percent(row_idx: usize, cursor_idx: usize, visible_rows: usize) -> usize {
if visible_rows == 0 {
return 0;
}
let distance = row_idx.abs_diff(cursor_idx);
(distance * 100 / visible_rows.max(1)).min(100)
}
}
fn fade_gradient() -> &'static FadeGradient {
static GRADIENT: OnceLock<FadeGradient> = OnceLock::new();
GRADIENT.get_or_init(FadeGradient::new)
}
pub(crate) fn render(app: &mut App, ctx: &TuiContext, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5), Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
.split(frame.area());
render_header(app, frame, chunks[0]);
render_view_tabs(app, frame, chunks[1]);
match app.view() {
View::FileList => {
let config = ctx.config(app);
render_file_list_view(app, config, frame, chunks[2]);
}
View::AuditLog => render_audit_log(app, frame, chunks[2]),
View::Help => render_help(app, frame, chunks[2]),
}
render_footer(app, frame, chunks[3]);
if let Some(deletion) = app.pending_entry_delete() {
let count = deletion.entries.len();
if count == 1 {
render_entry_delete_modal(
frame,
&deletion.entries[0].path.to_string_lossy(),
deletion.entries[0].is_dir,
deletion.method,
);
} else {
render_entry_delete_modal_multi(frame, count, deletion.method);
}
}
if let Some(deferral) = app.pending_entry_deferral() {
let count = deferral.entries.len();
render_deferral_modal(
frame,
&deferral.entries[0].path.to_string_lossy(),
&deferral.input,
deferral.default_days,
count,
);
}
if let Some(entries) = app.pending_entry_ignore() {
let count = entries.len();
if count == 1 {
render_ignore_modal(frame, &entries[0].path.to_string_lossy());
} else {
render_ignore_modal_multi(frame, count);
}
}
if let Some(entries) = app.pending_entry_approval() {
let count = entries.len();
if count == 1 {
render_confirmation_modal(frame, &entries[0].path.to_string_lossy());
} else {
render_confirmation_modal_multi(frame, count);
}
}
if let Some(input) = app.pending_add_path() {
render_add_path_modal(frame, input);
}
if let Some(path) = app.pending_remove_path() {
render_remove_path_modal(frame, &path.display().to_string());
}
if let Some(target) = app.pending_quota_target() {
render_quota_target_modal(frame, target);
}
if let Some(export) = app.pending_audit_export() {
render_audit_export_modal(frame, export);
}
if app.pending_full_rescan_confirmation() {
render_full_rescan_modal(frame);
}
if let Some(result) = app.pending_dry_run() {
render_dry_run_modal(frame, result);
}
}
const LOGO: &str = "\
┏━┓╺┳╸┏━┓┏━╸┏━╸┏━╸┏━┓┏━╸╻ ╻
┗━┓ ┃ ┣━┫┃╺┓┣╸ ┃ ┣┳┛┣╸ ┃╻┃
┗━┛ ╹ ╹ ╹┗━┛┗━╸┗━╸╹┗╸┗━╸┗┻┛";
fn render_header(app: &App, frame: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.padding(Padding::symmetric(1, 0));
let inner = block.inner(area);
frame.render_widget(block, area);
let now = jiff::Timestamp::now().as_second();
let has_countdown = app.nearest_expiration.is_some();
let is_debug_build = cfg!(debug_assertions);
let logo_style = if is_debug_build {
Style::default().fg(palette::YELLOW)
} else {
Style::default()
};
if has_countdown {
let dial_width: u16 = 16;
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(30), Constraint::Length(2), Constraint::Min(10), Constraint::Length(1), Constraint::Length(dial_width), ])
.split(inner);
let logo = Paragraph::new(LOGO).style(logo_style);
frame.render_widget(logo, h_chunks[0]);
let expiration_ts = app.nearest_expiration.unwrap_or(now);
let remaining = expiration_ts - now;
let (context_lines, time_top, time_bottom, color) =
build_countdown_display(remaining, &app.cached_stats, is_debug_build);
let context = Paragraph::new(context_lines).alignment(Alignment::Right);
frame.render_widget(context, h_chunks[2]);
render_timer_dial(frame, h_chunks[4], &time_top, &time_bottom, color);
} else {
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(30), Constraint::Length(2), Constraint::Min(10), ])
.split(inner);
let logo = Paragraph::new(LOGO).style(logo_style);
frame.render_widget(logo, h_chunks[0]);
let status_lines = build_idle_display(&app.cached_stats, is_debug_build);
let status = Paragraph::new(status_lines).alignment(Alignment::Right);
frame.render_widget(status, h_chunks[2]);
}
}
fn build_countdown_display(
remaining_seconds: i64,
stats: &crate::db::Stats,
is_debug_build: bool,
) -> (Vec<Line<'static>>, String, String, Color) {
let (label, time_top, time_bottom, color) = if remaining_seconds <= 0 {
let overdue = remaining_seconds.unsigned_abs();
let days = overdue / 86400;
let hours = (overdue % 86400) / 3600;
let mins = (overdue % 3600) / 60;
let secs = overdue % 60;
let label = if days > 0 {
format!(
"overdue by {days} day{}, {hours} hour{}",
if days == 1 { "" } else { "s" },
if hours == 1 { "" } else { "s" }
)
} else {
format!(
"overdue by {hours} hour{}, {mins} min{}",
if hours == 1 { "" } else { "s" },
if mins == 1 { "" } else { "s" }
)
};
(
label,
format!("-{days}d {hours:02}h"),
format!("{mins:02}:{secs:02}"),
palette::RED,
)
} else {
let r = remaining_seconds.unsigned_abs();
let days = r / 86400;
let hours = (r % 86400) / 3600;
let mins = (r % 3600) / 60;
let secs = r % 60;
let color = if days == 0 && hours < 6 {
palette::RED
} else if remaining_seconds <= 14 * 86400 {
palette::YELLOW
} else {
palette::GREEN
};
let label = if days > 0 {
format!(
"next removal in {days} day{}, {hours} hour{}",
if days == 1 { "" } else { "s" },
if hours == 1 { "" } else { "s" }
)
} else {
format!(
"next removal in {hours} hour{}, {mins} min{}",
if hours == 1 { "" } else { "s" },
if mins == 1 { "" } else { "s" }
)
};
(
label,
format!("{days}d {hours:02}h"),
format!("{mins:02}:{secs:02}"),
color,
)
};
let is_overdue = remaining_seconds <= 0;
let context = build_context_lines(&label, stats, is_overdue, is_debug_build);
(context, time_top, time_bottom, color)
}
fn build_context_lines(
label: &str,
stats: &crate::db::Stats,
is_overdue: bool,
is_debug_build: bool,
) -> Vec<Line<'static>> {
let mut lines = vec![if is_debug_build {
Line::from(Span::styled(
"DEBUG BUILD — reduced performance",
Style::default().fg(palette::YELLOW),
))
} else {
Line::from(Span::styled(
label.to_string(),
Style::default().fg(Color::DarkGray),
))
}];
let overdue = stats.files_overdue;
let warning = stats.files_within_warning;
let mut summary_spans = Vec::new();
if overdue > 0 || is_overdue {
let count = overdue.max(1);
summary_spans.push(Span::styled(
format!("{count} file{} overdue", if count == 1 { "" } else { "s" }),
Style::default().fg(palette::RED),
));
}
if warning > 0 {
if !summary_spans.is_empty() {
summary_spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
}
summary_spans.push(Span::styled(
format!(
"{warning} file{} due soon",
if warning == 1 { "" } else { "s" }
),
Style::default().fg(palette::YELLOW),
));
}
if summary_spans.is_empty() {
summary_spans.push(Span::styled(
"all clear",
Style::default().fg(palette::GREEN),
));
}
lines.push(Line::from(summary_spans));
#[allow(clippy::cast_sign_loss)]
let total_bytes = stats.total_size_bytes.max(0) as u64;
lines.push(Line::from(Span::styled(
format!(
"tracking {} unique files ({})",
stats.total_files,
format_bytes(total_bytes)
),
Style::default().fg(Color::DarkGray),
)));
lines
}
fn build_idle_display(stats: &crate::db::Stats, is_debug_build: bool) -> Vec<Line<'static>> {
if stats.total_files == 0 {
vec![
if is_debug_build {
Line::from(Span::styled(
"DEBUG BUILD — reduced performance",
Style::default().fg(palette::YELLOW),
))
} else {
Line::from("")
},
Line::from(Span::styled(
"no directories tracked",
Style::default().fg(Color::DarkGray),
)),
]
} else {
#[allow(clippy::cast_sign_loss)]
let total_bytes = stats.total_size_bytes.max(0) as u64;
vec![
if is_debug_build {
Line::from(Span::styled(
"DEBUG BUILD — reduced performance",
Style::default().fg(palette::YELLOW),
))
} else {
Line::from("")
},
Line::from(Span::styled(
"all clear",
Style::default().fg(palette::GREEN),
)),
Line::from(Span::styled(
format!(
"tracking {} unique files ({})",
stats.total_files,
format_bytes(total_bytes)
),
Style::default().fg(Color::DarkGray),
)),
]
}
}
fn render_timer_dial(
frame: &mut Frame,
area: Rect,
time_top: &str,
time_bottom: &str,
color: Color,
) {
if area.height < 3 || area.width < 12 {
return;
}
let border_style = Style::default().fg(color);
let time_style = Style::default().fg(color).add_modifier(Modifier::BOLD);
let combined = format!("{time_top} {time_bottom}");
let width = area
.width
.min(u16::try_from(combined.len() + 4).unwrap_or(20));
let x = area.right().saturating_sub(width);
let y = area.top();
let buffer = frame.buffer_mut();
buffer[(x, y)].set_symbol("╭").set_style(border_style);
for dx in 1..width.saturating_sub(1) {
buffer[(x + dx, y)].set_symbol("─").set_style(border_style);
}
buffer[(x + width - 1, y)]
.set_symbol("╮")
.set_style(border_style);
buffer[(x, y + 1)].set_symbol("│").set_style(border_style);
buffer[(x + width - 1, y + 1)]
.set_symbol("│")
.set_style(border_style);
let padded = format!(
"{combined:^width$}",
width = usize::from(width).saturating_sub(2)
);
write_dial_text(buffer, x, y + 1, width, &padded, time_style);
buffer[(x, y + 2)].set_symbol("╰").set_style(border_style);
for dx in 1..width.saturating_sub(1) {
buffer[(x + dx, y + 2)]
.set_symbol("─")
.set_style(border_style);
}
buffer[(x + width - 1, y + 2)]
.set_symbol("╯")
.set_style(border_style);
}
fn write_dial_text(
buffer: &mut ratatui::buffer::Buffer,
x: u16,
y: u16,
width: u16,
text: &str,
style: Style,
) {
for (i, ch) in text.chars().enumerate() {
if let Ok(dx) = u16::try_from(i + 1)
&& dx < width - 1
{
buffer[(x + dx, y)]
.set_symbol(&ch.to_string())
.set_style(style);
}
}
}
fn render_view_tabs(app: &App, frame: &mut Frame, area: Rect) {
let selected = match app.view() {
View::FileList => Some(0),
View::AuditLog => Some(1),
View::Help => Some(2),
};
let tab_block = Block::default().borders(Borders::ALL);
let tabs_inner = tab_block.inner(area);
frame.render_widget(tab_block, area);
let Some(selected) = selected else {
frame.render_widget(Paragraph::new(""), tabs_inner);
return;
};
let titles = vec![
Line::from(vec![
Span::styled("MAIN DASHBOARD", Style::default()),
Span::raw(" [1]"),
]),
Line::from(vec![
Span::styled("AUDIT LOG", Style::default()),
Span::raw(" [2]"),
]),
Line::from(vec![
Span::styled("HELP MENU", Style::default()),
Span::raw(" [3]"),
]),
];
let tabs = Tabs::new(titles)
.select(selected)
.style(Style::default().fg(Color::DarkGray))
.highlight_style(Style::default().fg(Color::Reset))
.divider(Span::styled("│", Style::default().fg(Color::DarkGray)));
frame.render_widget(tabs, tabs_inner);
}
#[allow(clippy::too_many_lines)]
fn render_file_list_view(
app: &mut App,
config: &Config,
frame: &mut Frame,
area: ratatui::layout::Rect,
) {
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7), Constraint::Min(0), ])
.split(area);
let top_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(v_chunks[0]);
render_lifecycle_widget(app, config, frame, top_chunks[0]);
render_expiration_timeline(app, config, frame, top_chunks[1]);
let content_area = v_chunks[1];
if app.sidebar_visible() {
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20), Constraint::Percentage(80), ])
.split(content_area);
render_sidebar(app, config, frame, h_chunks[0]);
render_main_entry_panel(app, config, frame, h_chunks[1]);
} else {
render_main_entry_panel(app, config, frame, content_area);
}
}
struct LifecycleTally {
files: u64,
bytes: u64,
}
struct LifecycleView {
total_files: u64,
total_bytes: u64,
healthy: LifecycleTally,
warning: LifecycleTally,
overdue: LifecycleTally,
ignored: LifecycleTally,
}
impl From<&crate::db::Stats> for LifecycleView {
fn from(stats: &crate::db::Stats) -> Self {
#[allow(clippy::cast_sign_loss)]
let clamp = |v: i64| -> u64 { v.max(0) as u64 };
Self {
total_files: clamp(stats.total_files),
total_bytes: clamp(stats.total_size_bytes),
healthy: LifecycleTally {
files: clamp(stats.files_healthy),
bytes: clamp(stats.bytes_healthy),
},
warning: LifecycleTally {
files: clamp(stats.files_within_warning),
bytes: clamp(stats.bytes_within_warning),
},
overdue: LifecycleTally {
files: clamp(stats.files_overdue) + clamp(stats.files_pending_approval),
bytes: clamp(stats.bytes_overdue) + clamp(stats.bytes_pending_approval),
},
ignored: LifecycleTally {
files: clamp(stats.files_ignored),
bytes: clamp(stats.bytes_ignored),
},
}
}
}
impl LifecycleView {
fn from_entries(entries: &[crate::db::Entry], config: &Config) -> Self {
let mut healthy = LifecycleTally { files: 0, bytes: 0 };
let mut warning = LifecycleTally { files: 0, bytes: 0 };
let mut overdue = LifecycleTally { files: 0, bytes: 0 };
let mut ignored = LifecycleTally { files: 0, bytes: 0 };
for entry in entries
.iter()
.filter(|e| !e.is_dir && e.status != "removed")
{
#[allow(clippy::cast_sign_loss)]
let size = entry.size_bytes.max(0) as u64;
if entry.status == "ignored" {
ignored.files += 1;
ignored.bytes += size;
continue;
}
let days_remaining = if entry.status == "deferred" {
entry.deferred_until.map(|until| {
let now = jiff::Timestamp::now().as_second();
(until - now) / 86400
})
} else {
entry
.countdown_start
.map(|cs| calculate_expiration(cs, config.expiration_days))
.or_else(|| {
if entry.status == "pending" || entry.status == "approved" {
Some(0)
} else {
None
}
})
};
match days_remaining {
Some(d) if d <= 0 => {
overdue.files += 1;
overdue.bytes += size;
}
Some(d) if d <= i64::from(config.warning_days) => {
warning.files += 1;
warning.bytes += size;
}
_ => {
healthy.files += 1;
healthy.bytes += size;
}
}
}
Self {
total_files: healthy.files + warning.files + overdue.files + ignored.files,
total_bytes: healthy.bytes + warning.bytes + overdue.bytes + ignored.bytes,
healthy,
warning,
overdue,
ignored,
}
}
}
fn render_lifecycle_widget(
app: &App,
config: &Config,
frame: &mut Frame,
area: ratatui::layout::Rect,
) {
let title = if app.loading.root_entries {
"LIFECYCLE ..."
} else {
"LIFECYCLE"
};
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
if app.current_root_id.is_none() {
let msg = Paragraph::new("Select a root from the sidebar")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}
let view = LifecycleView::from_entries(&app.root_entries, config);
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Length(3)])
.split(inner);
let summary_area = v_chunks[0];
let bars_area = v_chunks[1];
let summary_line = build_summary_line(&view, app.status_message.as_deref());
let full_divider = Line::from(Span::styled(
"─".repeat(usize::from(summary_area.width)),
Style::default().fg(Color::DarkGray),
));
let summary_widget = Paragraph::new(vec![summary_line, full_divider]);
frame.render_widget(summary_widget, summary_area);
let prefix_width: u16 = 6;
let bar_width = bars_area.width.saturating_sub(prefix_width);
let files_segments = [
BarSegment {
value: view.healthy.files,
label: view.healthy.files.to_string(),
color: palette::GREEN,
},
BarSegment {
value: view.warning.files,
label: view.warning.files.to_string(),
color: palette::YELLOW,
},
BarSegment {
value: view.overdue.files,
label: view.overdue.files.to_string(),
color: palette::RED,
},
];
let bytes_segments = [
BarSegment {
value: view.healthy.bytes,
label: format_bytes(view.healthy.bytes),
color: palette::GREEN,
},
BarSegment {
value: view.warning.bytes,
label: format_bytes(view.warning.bytes),
color: palette::YELLOW,
},
BarSegment {
value: view.overdue.bytes,
label: format_bytes(view.overdue.bytes),
color: palette::RED,
},
];
let files_bar = build_lifecycle_bar(&files_segments, bar_width);
let bytes_bar = build_lifecycle_bar(&bytes_segments, bar_width);
let mut files_spans = vec![Span::styled(
"Files ",
Style::default().add_modifier(Modifier::DIM),
)];
files_spans.extend(files_bar);
let files_line = Line::from(files_spans);
let bar_divider = Line::from(Span::styled(
"─".repeat(usize::from(bars_area.width)),
Style::default().fg(Color::DarkGray),
));
let mut bytes_spans = vec![Span::styled(
"Bytes ",
Style::default().add_modifier(Modifier::DIM),
)];
bytes_spans.extend(bytes_bar);
let bytes_line = Line::from(bytes_spans);
let bars_widget = Paragraph::new(vec![files_line, bar_divider, bytes_line]);
frame.render_widget(bars_widget, bars_area);
}
#[allow(clippy::too_many_lines)]
fn render_expiration_timeline(
app: &App,
config: &Config,
frame: &mut Frame,
area: ratatui::layout::Rect,
) {
const TIMELINE_DAYS: usize = 30;
let title = if app.loading.root_entries {
"REMOVAL TIMELINE ..."
} else {
"REMOVAL TIMELINE"
};
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
let content = inner.inner(Margin {
horizontal: 1,
vertical: 0,
});
if app.current_root_id.is_none() {
let msg = Paragraph::new("Select a root from the sidebar")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, content);
return;
}
let mut future_buckets: [u64; TIMELINE_DAYS + 1] = [0; TIMELINE_DAYS + 1];
let mut overdue_count: u64 = 0;
let mut overdue_bytes: i64 = 0;
let mut future_count: u64 = 0;
let mut future_bytes: i64 = 0;
for entry in app
.root_entries
.iter()
.filter(|e| !e.is_dir && e.status != "removed" && e.status != "ignored")
{
let days_remaining = if entry.status == "deferred" {
entry.deferred_until.map(|until| {
let now = jiff::Timestamp::now().as_second();
(until - now) / 86400
})
} else {
entry
.countdown_start
.map(|cs| calculate_expiration(cs, config.expiration_days))
.or_else(|| {
if entry.status == "pending" || entry.status == "approved" {
Some(0)
} else {
None
}
})
};
if let Some(days) = days_remaining {
if days < 0 {
overdue_count += 1;
overdue_bytes += entry.size_bytes;
} else if days <= i64::try_from(TIMELINE_DAYS).unwrap_or(i64::MAX) {
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let bucket_idx = days as usize;
future_buckets[bucket_idx] += 1;
future_count += 1;
future_bytes += entry.size_bytes;
}
}
}
if tracing::enabled!(tracing::Level::DEBUG) {
let non_zero: Vec<_> = future_buckets
.iter()
.enumerate()
.filter(|(_, count)| **count > 0)
.map(|(day, count)| format!("d{day}={count}"))
.collect();
if !non_zero.is_empty() {
tracing::debug!(
target: "stagecrew::timeline",
buckets = %non_zero.join(" "),
overdue_count,
future_count,
"Timeline bucket distribution"
);
}
}
if overdue_count == 0 && future_count == 0 {
let msg = Paragraph::new("No files expiring in the next 30 days")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, content);
return;
}
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(content);
let labels_area = v_chunks[0];
let chart_area = v_chunks[1];
let metrics_area = v_chunks[2];
let summary_area = v_chunks[3];
let overdue_width = 22;
let y_label_width: u16 = 6; let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(overdue_width),
Constraint::Length(2),
Constraint::Min(10),
])
.split(chart_area);
let overdue_area = h_chunks[0];
let future_area = h_chunks[2];
let label_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(overdue_width),
Constraint::Length(2),
Constraint::Length(y_label_width),
Constraint::Min(5),
])
.split(labels_area);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"overdue backlog",
Style::default().fg(Color::DarkGray),
))),
label_chunks[0],
);
frame.render_widget(
Paragraph::new(build_future_axis_labels(label_chunks[3].width))
.style(Style::default().fg(Color::DarkGray)),
label_chunks[3],
);
render_overdue_block(
overdue_area,
frame,
overdue_count,
overdue_bytes,
overdue_count + future_count,
);
let future_h = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(y_label_width), Constraint::Min(5)])
.split(future_area);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("files", Style::default().fg(Color::DarkGray)),
Span::styled("│", Style::default().fg(Color::DarkGray)),
]))
.alignment(Alignment::Right),
future_h[0],
);
let sparkline = build_future_sparkline(&future_buckets, future_h[1].width, config.warning_days);
frame.render_widget(Paragraph::new(sparkline), future_h[1]);
let metric_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(overdue_width),
Constraint::Length(2),
Constraint::Length(y_label_width),
Constraint::Min(5),
])
.split(metrics_area);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"└",
Style::default().fg(Color::DarkGray),
)))
.alignment(Alignment::Right),
metric_chunks[2],
);
let baseline_width = usize::from(metric_chunks[3].width);
let baseline: String = "─".repeat(baseline_width);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
baseline,
Style::default().fg(Color::DarkGray),
))),
metric_chunks[3],
);
let overdue_metric = if overdue_count == 0 {
String::new()
} else {
format!(
"{} overdue",
format_bytes(overdue_bytes.max(0).cast_unsigned())
)
};
let future_metric = if future_count == 0 {
"nothing due soon".to_string()
} else {
format!(
"{} due soon",
format_bytes(future_bytes.max(0).cast_unsigned())
)
};
let combined_metric = if overdue_metric.is_empty() {
future_metric
} else if future_metric.is_empty() || future_metric == "nothing due soon" {
overdue_metric
} else {
format!("{overdue_metric} · {future_metric}")
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
combined_metric,
Style::default().fg(Color::DarkGray),
))),
metric_chunks[0],
);
let summary_text = match (overdue_count, future_count) {
(0, upcoming) => format!(
"{} due in next 30 days ({})",
pluralize_files(upcoming),
format_bytes(future_bytes.max(0).cast_unsigned())
),
(overdue, 0) => format!(
"{} overdue ({})",
pluralize_files(overdue),
format_bytes(overdue_bytes.max(0).cast_unsigned())
),
(overdue, upcoming) => format!(
"{} overdue | {} due in next 30 days",
pluralize_files(overdue),
pluralize_files(upcoming)
),
};
let summary_paragraph = Paragraph::new(Line::from(Span::styled(
summary_text,
Style::default().add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Center);
let summary_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(summary_area);
frame.render_widget(summary_paragraph, summary_rows[1]);
}
fn build_future_axis_labels(width: u16) -> Line<'static> {
let axis_width = usize::from(width);
let mut axis_label = String::with_capacity(axis_width);
axis_label.push_str("today");
let mid_label = "+15d";
let end_label = "+30d";
let mid_pos = axis_width / 2;
let end_pos = axis_width.saturating_sub(end_label.len());
let padding_to_mid = mid_pos
.saturating_sub(axis_label.len())
.saturating_sub(mid_label.len() / 2);
for _ in 0..padding_to_mid {
axis_label.push(' ');
}
axis_label.push_str(mid_label);
let padding_to_end = end_pos.saturating_sub(axis_label.len());
for _ in 0..padding_to_end {
axis_label.push(' ');
}
axis_label.push_str(end_label);
Line::from(axis_label)
}
fn render_overdue_block(
area: Rect,
frame: &mut Frame,
overdue_count: u64,
_overdue_bytes: i64,
total_count: u64,
) {
let inner_width = area.width.saturating_sub(1);
let blocks = u16::try_from(
overdue_count
.saturating_mul(u64::from(inner_width))
.saturating_add(total_count.saturating_sub(1))
/ total_count.max(1),
)
.unwrap_or(inner_width)
.max(if overdue_count > 0 { 4 } else { 0 })
.min(inner_width);
let bar = "█".repeat(usize::from(blocks));
if overdue_count == 0 {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"✓ all clear",
Style::default().fg(palette::GREEN),
))),
area,
);
return;
}
let count_text = pluralize_files(overdue_count);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(bar, Style::default().fg(palette::RED)),
Span::raw(" "),
Span::styled(count_text, Style::default().add_modifier(Modifier::BOLD)),
])),
area,
);
}
fn build_future_sparkline(buckets: &[u64], width: u16, warning_days: u32) -> Line<'static> {
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
if width == 0 || buckets.is_empty() {
return Line::from(String::new());
}
let samples = sample_future_buckets(buckets, usize::from(width));
let max_value = samples.iter().map(|(count, _)| *count).max().unwrap_or(0);
if max_value == 0 {
return Line::from(Span::styled(
"·".repeat(usize::from(width)),
Style::default().fg(Color::DarkGray),
));
}
let spans: Vec<Span<'static>> = samples
.into_iter()
.map(|(count, day)| {
let idx = if count == 0 {
0
} else {
usize::try_from(
count
.saturating_mul(u64::try_from(BLOCKS.len()).unwrap_or(8))
.saturating_sub(1)
/ max_value,
)
.unwrap_or(0)
.min(BLOCKS.len().saturating_sub(1))
.max(1)
};
if count == 0 {
return Span::styled(" ", Style::default());
}
let color = if day == 0 {
palette::RED
} else if day <= usize::try_from(warning_days).unwrap_or(0) {
palette::YELLOW
} else {
palette::GREEN
};
Span::styled(BLOCKS[idx].to_string(), Style::default().fg(color))
})
.collect();
Line::from(spans)
}
fn sample_future_buckets(buckets: &[u64], width: usize) -> Vec<(u64, usize)> {
(0..width)
.map(|column| {
let start = column.saturating_mul(buckets.len()) / width;
let end = ((column + 1).saturating_mul(buckets.len()) / width).max(start + 1);
let slice = &buckets[start..end.min(buckets.len())];
let count = slice.iter().copied().sum::<u64>();
let day = slice
.iter()
.enumerate()
.find(|(_, count)| **count > 0)
.map_or(start, |(offset, _)| start + offset);
(count, day.min(buckets.len().saturating_sub(1)))
})
.collect()
}
fn pluralize_files(count: u64) -> String {
format!("{count} file{}", if count == 1 { "" } else { "s" })
}
#[allow(clippy::too_many_lines)]
fn render_quota_widget(app: &App, config: &Config, frame: &mut Frame, area: ratatui::layout::Rect) {
let title = if app.loading.root_entries {
"QUOTA ..."
} else {
"QUOTA"
};
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(root_id) = app.current_root_id else {
let msg = Paragraph::new("Select a root")
.alignment(ratatui::layout::Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
};
let Some(root) = app.roots.iter().find(|r| r.id == root_id) else {
let msg = Paragraph::new("Not found")
.alignment(ratatui::layout::Alignment::Center)
.style(Style::default().fg(palette::RED));
frame.render_widget(msg, inner);
return;
};
let Some(target_bytes) = root.target_bytes else {
let msg = Paragraph::new("Press t to set")
.alignment(ratatui::layout::Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
};
if app.loading.root_entries {
let msg = Paragraph::new("Loading...")
.alignment(ratatui::layout::Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}
let view = LifecycleView::from_entries(&app.root_entries, config);
let clamp_i64 = |v: u64| i64::try_from(v).unwrap_or(i64::MAX);
let (healthy_bytes, warning_bytes, overdue_bytes) = (
clamp_i64(view.healthy.bytes),
clamp_i64(view.warning.bytes),
clamp_i64(view.overdue.bytes),
);
let ignored_bytes = clamp_i64(view.ignored.bytes);
let used_bytes = healthy_bytes + warning_bytes + overdue_bytes + ignored_bytes;
#[allow(clippy::cast_precision_loss)]
let target_f64 = target_bytes.max(1) as f64;
#[allow(clippy::cast_precision_loss)]
let used_f64 = used_bytes.max(0) as f64;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(inner);
let text_area = chunks[0];
let chart_area = chunks[1];
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let pct_display = (used_f64 / target_f64 * 100.0).round() as u32;
#[allow(clippy::cast_sign_loss)]
let used_display = crate::format_bytes(used_bytes.max(0) as u64);
#[allow(clippy::cast_sign_loss)]
let target_display = crate::format_bytes(target_bytes.max(0) as u64);
let text_color = if used_bytes > target_bytes {
palette::RED
} else {
Color::Reset
};
let text_content = if used_bytes > target_bytes {
#[allow(clippy::cast_sign_loss)]
let overage = crate::format_bytes((used_bytes - target_bytes).max(0) as u64);
vec![
Line::from(Span::styled(
format!("{pct_display}% ({overage} over)"),
Style::default().fg(text_color),
)),
Line::from(format!("{used_display} / {target_display}")),
]
} else {
vec![
Line::from(Span::styled(
format!("{pct_display}%"),
Style::default().fg(text_color),
)),
Line::from(format!("{used_display} / {target_display}")),
]
};
let text_widget = Paragraph::new(text_content)
.alignment(ratatui::layout::Alignment::Center)
.style(Style::default().fg(text_color));
frame.render_widget(text_widget, text_area);
let chart_height = chart_area.height;
let ideal_width = chart_height.saturating_mul(2);
let actual_width = chart_area.width.min(ideal_width);
let x_offset = (chart_area.width.saturating_sub(actual_width)) / 2;
let square_chart_area = ratatui::layout::Rect {
x: chart_area.x + x_offset,
y: chart_area.y,
width: actual_width,
height: chart_height,
};
let slices = if used_bytes > target_bytes {
vec![
tui_piechart::PieSlice::new("", 99.99, palette::RED),
tui_piechart::PieSlice::new("", 0.01, palette::RED),
]
} else {
let remaining_bytes = (target_bytes - used_bytes).max(0);
#[allow(clippy::cast_precision_loss)]
let healthy_pct = healthy_bytes.max(0) as f64 / target_f64 * 100.0;
#[allow(clippy::cast_precision_loss)]
let warning_pct = warning_bytes.max(0) as f64 / target_f64 * 100.0;
#[allow(clippy::cast_precision_loss)]
let overdue_pct = overdue_bytes.max(0) as f64 / target_f64 * 100.0;
#[allow(clippy::cast_precision_loss)]
let ignored_pct = ignored_bytes.max(0) as f64 / target_f64 * 100.0;
#[allow(clippy::cast_precision_loss)]
let remaining_pct = remaining_bytes as f64 / target_f64 * 100.0;
let mut slices = Vec::new();
if healthy_pct > 0.0 {
slices.push(tui_piechart::PieSlice::new("", healthy_pct, palette::GREEN));
}
if warning_pct > 0.0 {
slices.push(tui_piechart::PieSlice::new(
"",
warning_pct,
palette::YELLOW,
));
}
if overdue_pct > 0.0 {
slices.push(tui_piechart::PieSlice::new("", overdue_pct, palette::RED));
}
if ignored_pct > 0.0 {
slices.push(tui_piechart::PieSlice::new("", ignored_pct, Color::Gray));
}
if remaining_pct > 0.0 {
slices.push(tui_piechart::PieSlice::new(
"",
remaining_pct,
Color::DarkGray,
));
}
if slices.is_empty() {
slices.push(tui_piechart::PieSlice::new("", 100.0, Color::DarkGray));
}
slices
};
let chart = tui_piechart::PieChart::new(slices)
.show_legend(false)
.high_resolution(true);
frame.render_widget(chart, square_chart_area);
}
fn build_summary_line<'a>(view: &LifecycleView, status_message: Option<&str>) -> Line<'a> {
let mut spans = vec![Span::styled(
format!(
"Total: {} files, {}",
view.total_files,
format_bytes(view.total_bytes)
),
Style::default().add_modifier(Modifier::BOLD),
)];
if let Some(status) = status_message {
spans.push(Span::raw(" "));
spans.push(Span::styled(
status.to_owned(),
Style::default().add_modifier(Modifier::DIM),
));
}
if view.ignored.files > 0 || view.ignored.bytes > 0 {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!(
"ignored: {} files, {}",
view.ignored.files,
format_bytes(view.ignored.bytes)
),
Style::default().fg(Color::DarkGray),
));
}
Line::from(spans)
}
struct BarSegment {
value: u64,
label: String,
color: Color,
}
fn build_lifecycle_bar(segments: &[BarSegment], width: u16) -> Vec<Span<'static>> {
let w = usize::from(width);
let total: u64 = segments.iter().map(|s| s.value).sum();
if total == 0 || w == 0 {
return vec![Span::styled(
" ".repeat(w),
Style::default().bg(Color::DarkGray),
)];
}
let values: Vec<u64> = segments.iter().map(|s| s.value).collect();
let widths = proportional_widths(&values, total, w);
let mut spans = Vec::new();
for (seg, &seg_width) in segments.iter().zip(&widths) {
if seg_width > 0 {
spans.push(build_bar_segment(seg_width, seg.color, &seg.label));
}
}
spans
}
fn build_bar_segment(width: usize, color: Color, label: &str) -> Span<'static> {
let label_len = label.len();
let content = if width >= label_len {
let total_padding = width - label_len;
let left_pad = total_padding / 2;
let right_pad = total_padding - left_pad;
format!("{}{}{}", " ".repeat(left_pad), label, " ".repeat(right_pad),)
} else {
" ".repeat(width)
};
Span::styled(content, Style::default().bg(color).fg(Color::Black))
}
fn proportional_widths(values: &[u64], total: u64, width: usize) -> Vec<usize> {
let total_128 = u128::from(total);
let width_128 = width as u128;
let mut widths: Vec<usize> = values
.iter()
.map(|&v| {
if total_128 == 0 {
0
} else {
#[allow(clippy::cast_possible_truncation)]
let w = (u128::from(v) * width_128 + total_128 / 2) / total_128;
w as usize
}
})
.collect();
for (i, &v) in values.iter().enumerate() {
if v > 0 && widths[i] == 0 {
widths[i] = 1;
}
}
let sum: usize = widths.iter().sum();
if sum != width
&& let Some(max_idx) = widths
.iter()
.enumerate()
.max_by_key(|&(_, &w)| w)
.map(|(i, _)| i)
{
if sum > width {
widths[max_idx] = widths[max_idx].saturating_sub(sum - width);
} else {
widths[max_idx] += width - sum;
}
}
widths
}
fn render_sidebar(app: &mut App, config: &Config, frame: &mut Frame, area: ratatui::layout::Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(14)])
.split(area);
let roots_area = chunks[0];
let quota_area = chunks[1];
render_roots_list(app, frame, roots_area);
render_quota_widget(app, config, frame, quota_area);
}
fn render_roots_list(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) {
let roots = &app.roots;
let selected_idx = if roots.is_empty() {
0
} else {
app.sidebar_selected_index().min(roots.len() - 1)
};
let rows: Vec<Row> = roots
.iter()
.enumerate()
.map(|(idx, root)| {
let path_str = root.path.to_string_lossy();
let dir_name = root
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&path_str);
let cell = Cell::from(dir_name.to_owned());
let style = if idx == selected_idx {
if app.focus_panel() == FocusPanel::Sidebar {
Style::default()
.add_modifier(Modifier::REVERSED)
.fg(palette::CYAN)
} else {
Style::default().add_modifier(Modifier::REVERSED)
}
} else {
Style::default()
};
Row::new(vec![cell]).style(style)
})
.collect();
if rows.is_empty() {
let msg = if app.loading.roots {
"Loading..."
} else {
"No tracked paths.\n\nRun 'stagecrew add PATH'"
};
let empty_text = Paragraph::new(msg)
.block(
Block::default()
.borders(Borders::ALL)
.title("TRACKED DIRECTORIES"),
)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(empty_text, area);
return;
}
let table = Table::new(rows, [Constraint::Percentage(100)]).block(
Block::default()
.title("TRACKED DIRECTORIES")
.borders(Borders::ALL)
.border_style(if app.focus_panel() == FocusPanel::Sidebar {
Style::default().fg(palette::CYAN)
} else {
Style::default()
}),
);
frame.render_widget(table, area);
}
#[allow(clippy::too_many_lines)]
fn render_main_entry_panel(
app: &mut App,
config: &Config,
frame: &mut Frame,
area: ratatui::layout::Rect,
) {
let current_path = app.current_path();
if current_path.as_os_str().is_empty() {
let msg = if app.loading.roots {
"Loading..."
} else {
"Select a root from the sidebar\n\n(Use j/k to navigate, Tab to switch panels)"
};
let message = Paragraph::new(msg)
.block(Block::default().borders(Borders::ALL).title("ENTRIES"))
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(message, area);
return;
}
if app.dir_entries.is_empty() {
let msg = if app.loading.dir_entries {
"Loading..."
} else {
"No entries in this directory"
};
let empty_text = Paragraph::new(msg)
.block(Block::default().borders(Borders::ALL).title("ENTRIES"))
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(empty_text, area);
return;
}
app.entry_list_len = app.dir_entries.len();
let selected_idx = if app.dir_entries.is_empty() {
0
} else {
app.entry_selected_index().min(app.dir_entries.len() - 1)
};
let search_match_set: std::collections::HashSet<usize> = app
.search_query
.as_ref()
.map(|q| {
super::input::find_search_matches(&app.dir_entries, q)
.into_iter()
.collect()
})
.unwrap_or_default();
let entry_rows = &app.dir_entries;
let viewport_height = area.height.saturating_sub(4) as usize; let gradient = fade_gradient();
let rows: Vec<Row> = entry_rows
.iter()
.enumerate()
.map(|(idx, (entry, days_remaining))| {
let fade_pct = FadeGradient::fade_percent(idx, selected_idx, viewport_height);
let (indicator_symbol, indicator_color) = expiration_indicator_entry(
&entry.status,
*days_remaining,
config.warning_days,
entry,
);
let (workflow_symbol, workflow_color) = workflow_indicator(&entry.status);
let indicator_cell = Cell::from(Line::from(vec![
Span::styled(
indicator_symbol.to_string(),
Style::default().fg(indicator_color),
),
Span::styled(
workflow_symbol.to_string(),
Style::default().fg(workflow_color),
),
]));
let path_str = entry.path.to_string_lossy();
let filename = entry
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&path_str);
let display_name = if entry.is_dir {
format!("{filename}/")
} else {
filename.to_string()
};
#[allow(clippy::cast_sign_loss)]
let size_str = format_bytes(entry.size_bytes as u64);
let effective_days = if entry.status == "deferred" {
if let Some(deferred_until) = entry.deferred_until {
let now = jiff::Timestamp::now().as_second();
(deferred_until - now) / 86400
} else {
*days_remaining
}
} else {
*days_remaining
};
let due_str = if entry.status == "ignored" {
"—".to_string()
} else if effective_days >= 0 {
format!("{effective_days} days")
} else {
format!("{} days ago", -effective_days)
};
let is_selected = app.selected_entries().contains(&entry.id);
let is_search_match = search_match_set.contains(&idx);
let is_cursor = idx == selected_idx && app.focus_panel() == FocusPanel::MainPanel;
let uses_reversed = is_cursor;
let should_fade = !is_cursor && !is_selected;
let filename_cell = if uses_reversed {
Cell::from(display_name)
} else if entry.status == "ignored" {
let color = if should_fade {
gradient.gray[fade_pct]
} else {
Color::DarkGray
};
Cell::from(display_name).style(Style::default().fg(color))
} else {
let color = if should_fade {
gradient.text[fade_pct]
} else {
Color::Reset };
Cell::from(display_name).style(Style::default().fg(color))
};
let size_cell = if uses_reversed {
Cell::from(size_str)
} else {
let color = if should_fade {
gradient.gray[fade_pct]
} else {
Color::DarkGray
};
Cell::from(size_str).style(Style::default().fg(color))
};
let due_color = if uses_reversed {
Color::Reset
} else {
let base_gradient = match entry.status.as_str() {
"ignored" => &gradient.gray,
_ => {
if effective_days <= 0 {
&gradient.red } else if effective_days <= i64::from(config.warning_days) {
&gradient.yellow } else {
&gradient.green }
}
};
if should_fade {
base_gradient[fade_pct]
} else {
base_gradient[0] }
};
let due_cell = Cell::from(due_str).style(Style::default().fg(due_color));
let mut row_style = if is_selected && is_cursor {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else if is_selected {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else if is_cursor {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
if is_search_match {
row_style = row_style.add_modifier(Modifier::UNDERLINED);
}
Row::new(vec![indicator_cell, filename_cell, size_cell, due_cell]).style(row_style)
})
.collect();
let widths = [
Constraint::Length(3), Constraint::Percentage(54), Constraint::Percentage(15), Constraint::Percentage(28), ];
let sort_indicator = match app.sort_mode() {
SortMode::Expiration => " (by due)",
SortMode::Size => " (by size)",
SortMode::Name => " (by name)",
SortMode::Modified => " (by modified)",
};
let selection_info = if app.selected_entries().is_empty() {
String::new()
} else {
format!(" | {} selected", app.selected_entries().len())
};
let search_info = if let Some(query) = &app.search_query {
if query.is_empty() {
String::new()
} else {
let match_count = search_match_set.len();
format!(
" | /{query} ({match_count} match{plural})",
plural = if match_count == 1 { "" } else { "es" }
)
}
} else {
String::new()
};
let table = Table::new(rows, widths)
.block(
Block::default()
.title(format!(
"ENTRIES{sort_indicator}{selection_info}{search_info}"
))
.borders(Borders::ALL)
.border_style(if app.focus_panel() == FocusPanel::MainPanel {
Style::default().fg(palette::CYAN)
} else {
Style::default()
}),
)
.header(
Row::new(entry_table_header_cells(app.sort_mode()))
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(1),
)
.highlight_spacing(HighlightSpacing::Never);
if app.ensure_cursor_visible {
let viewport_height = area.height.saturating_sub(4) as usize; let current_offset = app.entry_table_state.offset();
if selected_idx < current_offset {
*app.entry_table_state.offset_mut() = selected_idx;
} else if selected_idx >= current_offset + viewport_height && viewport_height > 0 {
*app.entry_table_state.offset_mut() = selected_idx.saturating_sub(viewport_height) + 1;
}
app.ensure_cursor_visible = false;
}
app.entry_table_area = area;
frame.render_stateful_widget(table, area, &mut app.entry_table_state);
let viewport_height = area.height.saturating_sub(4) as usize; if entry_rows.len() > viewport_height {
let max_offset = entry_rows.len().saturating_sub(viewport_height);
app.entry_scrollbar_state =
ScrollbarState::new(max_offset + 1).position(app.entry_table_state.offset());
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"))
.track_symbol(Some("│"));
frame.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut app.entry_scrollbar_state,
);
}
}
pub(super) fn sort_entry_rows(rows: &mut [(crate::db::Entry, i64)], sort_mode: SortMode) {
match sort_mode {
SortMode::Expiration => {
rows.sort_by(|a, b| {
let key_a = expiration_sort_key_entry(&a.0, a.1);
let key_b = expiration_sort_key_entry(&b.0, b.1);
key_a.cmp(&key_b)
});
}
SortMode::Size => {
rows.sort_by(|a, b| {
b.0.size_bytes
.cmp(&a.0.size_bytes)
.then_with(|| a.0.path.cmp(&b.0.path))
});
}
SortMode::Name => {
rows.sort_by(|a, b| {
match (a.0.is_dir, b.0.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => {
let str_a = a.0.path.to_string_lossy();
let name_a =
a.0.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&str_a);
let str_b = b.0.path.to_string_lossy();
let name_b =
b.0.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&str_b);
name_a.cmp(name_b)
}
}
});
}
SortMode::Modified => {
rows.sort_by(|a, b| {
match (a.0.mtime, b.0.mtime) {
(Some(mtime_a), Some(mtime_b)) => mtime_b.cmp(&mtime_a),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
}
}
}
fn expiration_sort_key_entry(entry: &crate::db::Entry, days_remaining: i64) -> i64 {
match entry.status.as_str() {
"ignored" => i64::MAX, "deferred" => {
if let Some(deferred_until) = entry.deferred_until {
let now = jiff::Timestamp::now().as_second();
let seconds_remaining = deferred_until - now;
seconds_remaining / 86400 } else {
days_remaining
}
}
_ => days_remaining,
}
}
fn expiration_indicator_entry(
status: &str,
days_remaining: i64,
warning_days: u32,
entry: &crate::db::Entry,
) -> (&'static str, Color) {
if status == "ignored" {
return ("—", Color::DarkGray);
}
let effective_days = if status == "deferred" {
if let Some(deferred_until) = entry.deferred_until {
let now = jiff::Timestamp::now().as_second();
(deferred_until - now) / 86400
} else {
days_remaining
}
} else {
days_remaining
};
if effective_days <= 0 {
("●", palette::RED) } else if effective_days <= i64::from(warning_days) {
("⚠", palette::YELLOW) } else {
(" ", Color::Reset) }
}
fn workflow_indicator(status: &str) -> (&'static str, Color) {
match status {
"pending" => ("!", palette::YELLOW),
"approved" => ("✓", Color::Reset),
_ => (" ", Color::Reset),
}
}
fn entry_table_header_cells(sort_mode: SortMode) -> Vec<Cell<'static>> {
let indicator_asc = " ▲";
let indicator_desc = " ▼";
let filename_header = match sort_mode {
SortMode::Name => format!("Filename{indicator_asc}"),
_ => "Filename".to_string(),
};
let size_header = match sort_mode {
SortMode::Size => format!("Size{indicator_desc}"),
_ => "Size".to_string(),
};
let due_header = match sort_mode {
SortMode::Expiration => format!("Due{indicator_asc}"),
SortMode::Modified => format!("Due (mtime){indicator_desc}"),
_ => "Due".to_string(),
};
vec![
Cell::from(""), Cell::from(filename_header),
Cell::from(size_header),
Cell::from(due_header),
]
}
fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
const THRESHOLD: f64 = 1000.0;
if bytes == 0 {
return "0 B".to_string();
}
#[allow(clippy::cast_precision_loss)]
let bytes_f = bytes as f64;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let unit_idx = (bytes_f.log10() / THRESHOLD.log10()).floor() as usize;
let unit_idx = unit_idx.min(UNITS.len() - 1);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let value = bytes_f / THRESHOLD.powi(unit_idx as i32);
if unit_idx == 0 {
format!("{bytes} B")
} else {
format!("{value:.1} {}", UNITS[unit_idx])
}
}
#[allow(clippy::too_many_lines)]
fn render_audit_log(app: &mut App, frame: &mut Frame, area: ratatui::layout::Rect) {
app.sidebar_len = app.audit_entries.len();
let selected_idx = if app.audit_entries.is_empty() {
0
} else {
app.sidebar_selected_index()
.min(app.audit_entries.len() - 1)
};
if app.audit_entries.is_empty() {
let msg = if app.loading.audit_entries {
"Loading..."
} else {
"No audit entries found.\n\nPress 'q' or Esc to go back"
};
let empty_text = Paragraph::new(msg)
.block(Block::default().borders(Borders::ALL).title("AUDIT LOG"))
.style(Style::default());
frame.render_widget(empty_text, area);
return;
}
let entries = &app.audit_entries;
let gradient = fade_gradient();
let max_dist = entries.len().saturating_sub(1).max(1);
let rows: Vec<Row> = entries
.iter()
.enumerate()
.map(|(idx, entry)| {
let is_selected = idx == selected_idx;
let distance = idx.abs_diff(selected_idx);
let fade_pct = ((distance * 100) / max_dist).min(100);
let should_fade = !is_selected;
let timestamp_str = format_timestamp(entry.timestamp);
let timestamp_cell = if is_selected {
Cell::from(timestamp_str)
} else {
let color = if should_fade {
gradient.text[fade_pct]
} else {
Color::DarkGray
};
Cell::from(timestamp_str).style(Style::default().fg(color))
};
let user_cell = if is_selected {
Cell::from(entry.user.as_str())
} else {
let color = if should_fade {
gradient.text[fade_pct]
} else {
Color::Gray
};
Cell::from(entry.user.as_str()).style(Style::default().fg(color))
};
let action_style = if is_selected {
Style::default().add_modifier(Modifier::BOLD)
} else {
let base = match entry.action.as_str() {
"remove" => &gradient.red,
"defer" => &gradient.green,
"ignore" => &gradient.yellow,
"approve" | "unapprove" | "unignore" | "undo" => &gradient.text,
_ => &gradient.gray,
};
let color = if should_fade { base[fade_pct] } else { base[0] };
Style::default().fg(color).add_modifier(Modifier::BOLD)
};
let action_cell = Cell::from(entry.action.as_str()).style(action_style);
let path_str = entry.target_path.as_deref().unwrap_or("<system-wide>");
let path_cell = if is_selected {
Cell::from(path_str)
} else {
let color = if should_fade {
gradient.text[fade_pct]
} else {
Color::Gray
};
Cell::from(path_str).style(Style::default().fg(color))
};
let style = if is_selected {
Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default()
};
Row::new(vec![timestamp_cell, user_cell, action_cell, path_cell]).style(style)
})
.collect();
let widths = [
Constraint::Percentage(20), Constraint::Percentage(15), Constraint::Percentage(15), Constraint::Percentage(50), ];
let table = Table::new(rows, widths)
.block(
Block::default()
.title(format!(
"AUDIT LOG (Most Recent First | {} entries | E export)",
entries.len()
))
.borders(Borders::ALL),
)
.header(
Row::new(vec![
Cell::from("Timestamp"),
Cell::from("User"),
Cell::from("Action"),
Cell::from("Path"),
])
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(1),
)
.highlight_spacing(HighlightSpacing::Never);
if app.ensure_cursor_visible {
let viewport_height = area.height.saturating_sub(4) as usize; let current_offset = app.audit_table_state.offset();
if selected_idx < current_offset {
*app.audit_table_state.offset_mut() = selected_idx;
} else if selected_idx >= current_offset + viewport_height && viewport_height > 0 {
*app.audit_table_state.offset_mut() = selected_idx.saturating_sub(viewport_height) + 1;
}
if app.view() == super::View::AuditLog {
app.ensure_cursor_visible = false;
}
}
app.audit_table_area = area;
frame.render_stateful_widget(table, area, &mut app.audit_table_state);
let viewport_height = area.height.saturating_sub(4) as usize; if entries.len() > viewport_height {
let max_offset = entries.len().saturating_sub(viewport_height);
app.audit_scrollbar_state =
ScrollbarState::new(max_offset + 1).position(app.audit_table_state.offset());
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"))
.track_symbol(Some("│"));
frame.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut app.audit_scrollbar_state,
);
}
}
fn format_timestamp(timestamp: i64) -> String {
let ts = jiff::Timestamp::from_second(timestamp).unwrap_or(jiff::Timestamp::UNIX_EPOCH);
ts.to_zoned(jiff::tz::TimeZone::system())
.strftime("%Y-%m-%d %H:%M:%S")
.to_string()
}
const HELP_LEFT_TEXT: &str = r"File-Centric Workflow:
The main panel shows files from the currently selected directory.
The left sidebar shows tracked directories for filtering.
Navigate the sidebar with j/k to change which directory's files are shown.
Navigation:
j / ↓ Move selection down in focused panel
k / ↑ Move selection up in focused panel
g Jump to top of focused panel
G Jump to bottom of focused panel
Tab Switch focus between sidebar and main panel
h Switch focus to sidebar (shows sidebar if hidden)
l Switch focus to main panel / enter directory
B Toggle sidebar visibility
/ Search entries (Enter to confirm, Esc to cancel)
n / N Jump to next / previous search match
Selection (main panel only):
Space Toggle selection on current file and advance cursor
v Enter/exit visual mode (range select from anchor)
a Select all entries in current directory
Esc Exit visual mode / clear search / clear selection
Actions (on focused file or all selected files):
d Delete file(s) with confirmation
r Defer file(s) expiration (reset clock, prompt for days)
i Permanently ignore file(s)
x Approve file(s) for daemon removal
I Unignore file(s) (restore from ignored)
u Undo last reversible action
e Open in $VISUAL/$EDITOR (suspends TUI)
o Open with system viewer (fire-and-forget)";
const HELP_RIGHT_TEXT: &str = r"Root Management:
A Add a new tracked path
X Remove selected root (sidebar only)
t Set quota target for current root
Views:
1 Main dashboard (file list)
2 Audit log
3 / ? Show this help screen
Sorting:
s Cycle sort mode (Due → Size → Name → Modified)
Other:
E Export audit log (from Audit Log view)
F Execute approved removals for current root
R Refresh UI
Ctrl+r Run full filesystem rescan
T Reset countdown timer for current root
Y Dry run: check if approved entries can be removed
q Quit application (or return from audit log)
Ctrl+C Quit application";
fn styled_help_lines(text: &str) -> Vec<Line<'static>> {
text.lines()
.map(|line| {
let is_header = line.ends_with(':') && !line.starts_with(" ");
if is_header {
Line::from(vec![Span::styled(
line.to_string(),
Style::default()
.fg(palette::CYAN)
.add_modifier(Modifier::BOLD),
)])
} else {
Line::from(line.to_string())
}
})
.collect()
}
fn help_legend_lines() -> Vec<Line<'static>> {
vec![
Line::from(""),
Line::from(vec![Span::styled(
"Legend:",
Style::default()
.fg(palette::CYAN)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled(" ■", Style::default().fg(palette::GREEN)),
Span::raw(" Healthy (beyond warning window)"),
]),
Line::from(vec![
Span::styled(" ■", Style::default().fg(palette::YELLOW)),
Span::raw(" Warning (within warning window)"),
]),
Line::from(vec![
Span::styled(" ■", Style::default().fg(palette::RED)),
Span::raw(" Overdue (due now or in the past)"),
]),
Line::from(vec![
Span::styled(" ⚠ / ●", Style::default().fg(palette::YELLOW)),
Span::raw(" Countdown glyphs (warning / overdue)"),
]),
Line::from(vec![
Span::styled(" !", Style::default().fg(palette::YELLOW)),
Span::raw(" Pending workflow state"),
]),
Line::from(vec![
Span::styled(" ✓", Style::default().fg(Color::Reset)),
Span::raw(" Approved workflow state"),
]),
]
}
fn render_help(_app: &App, frame: &mut Frame, area: ratatui::layout::Rect) {
let block = Block::default()
.title("KEYBIND REFERENCE")
.borders(Borders::ALL)
.style(Style::default());
let left_lines = styled_help_lines(HELP_LEFT_TEXT);
let mut right_lines = styled_help_lines(HELP_RIGHT_TEXT);
right_lines.extend(help_legend_lines());
right_lines.push(Line::from(""));
right_lines.push(Line::from("Press any key to close this help screen"));
frame.render_widget(block, area);
let inner = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(2),
Constraint::Fill(1),
])
.split(area.inner(Margin {
horizontal: 1,
vertical: 1,
}));
let left = Paragraph::new(left_lines).wrap(Wrap { trim: true });
let right = Paragraph::new(right_lines).wrap(Wrap { trim: true });
frame.render_widget(left, inner[0]);
frame.render_widget(right, inner[2]);
}
fn build_hint_line(left: &[(&str, &str)], right: &[(&str, &str)], max_width: u16) -> Line<'static> {
let style = Style::default().add_modifier(Modifier::REVERSED);
let width = usize::from(max_width);
let fmt = |key: &str, label: &str| -> String { format!("[{key}] {label}") };
let right_parts: Vec<String> = right.iter().map(|(k, l)| fmt(k, l)).collect();
let right_total: usize =
right_parts.iter().map(String::len).sum::<usize>() + right_parts.len().saturating_sub(1);
if right_total >= width {
let joined = right_parts.join(" ");
return Line::from(Span::styled(
joined[..width.min(joined.len())].to_string(),
style,
));
}
let min_gap: usize = 2;
let left_budget = width.saturating_sub(right_total).saturating_sub(min_gap);
let mut left_rendered: Vec<String> = Vec::new();
let mut left_used: usize = 0;
for (key, label) in left {
let part = fmt(key, label);
let needed = if left_rendered.is_empty() {
part.len()
} else {
part.len() + 1 };
if left_used + needed > left_budget {
break;
}
left_used += needed;
left_rendered.push(part);
}
let left_str = left_rendered.join(" ");
let gap = width
.saturating_sub(left_str.len())
.saturating_sub(right_total);
let right_str = right_parts.join(" ");
Line::from(Span::styled(
format!("{left_str}{:>gap$}{right_str}", "", gap = gap),
style,
))
}
type Hint = (&'static str, &'static str);
fn context_hints(app: &App) -> (Vec<Hint>, Vec<Hint>) {
let right = vec![("?", "Help"), ("q", "Quit")];
let left = match app.view() {
View::FileList => {
let selection_count = app.selected_entries().len();
if app.search_query.is_some() {
vec![
("n", "Next match"),
("N", "Prev match"),
("/", "New search"),
("Esc", "Clear search"),
]
} else if app.is_visual_mode() {
vec![
("j/k", "Extend"),
("d/r/i/x", "Act on selection"),
("Esc", "Keep & exit"),
("g/G", "Top/Bottom"),
]
} else if selection_count > 0 {
vec![
("d", "Delete"),
("r", "Defer"),
("i", "Ignore"),
("x", "Approve"),
("Esc", "Clear"),
]
} else {
match app.focus_panel() {
FocusPanel::Sidebar => vec![
("j/k", "Navigate"),
("h/l", "Switch panel"),
("d/r/i/x", "Actions"),
("Space", "Select"),
("g/G", "Top/Bottom"),
("s", "Sort"),
],
FocusPanel::MainPanel => vec![
("j/k", "Navigate"),
("h/l", "Switch panel"),
("d", "Delete"),
("r", "Defer"),
("i", "Ignore"),
("x", "Approve"),
("I", "Unignore"),
("u", "Undo"),
("F", "Remove approved"),
("T", "Reset timer"),
("Y", "Dry run"),
("Space", "Select"),
("v", "Visual"),
("s", "Sort"),
("a", "All"),
],
}
}
}
View::AuditLog => vec![
("j/k", "Navigate"),
("g/G", "Top/Bottom"),
("E", "Export"),
("Esc", "Back"),
],
View::Help => {
return (vec![("Any key", "Close")], vec![]);
}
};
(left, right)
}
fn render_footer(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) {
let (mode_label, mode_style) = if app.search_input_active || app.search_query.is_some() {
(
" SEARCH ",
Style::default()
.fg(Color::Black)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
)
} else if app.is_visual_mode() {
(
" VISUAL ",
Style::default()
.fg(Color::Black)
.bg(palette::GREEN)
.add_modifier(Modifier::BOLD),
)
} else {
(
" NORMAL ",
Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED),
)
};
let badge_width: u16 = 9; let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(badge_width), Constraint::Min(0)])
.split(area);
let badge = Paragraph::new(Span::styled(mode_label, mode_style));
frame.render_widget(badge, chunks[0]);
if app.search_input_active {
let query = app.search_query.as_deref().unwrap_or("");
let search_bar = Paragraph::new(Line::from(vec![Span::styled(
format!("/{query}█"),
Style::default().add_modifier(Modifier::REVERSED),
)]));
frame.render_widget(search_bar, chunks[1]);
return;
}
let modal_open = app.pending_entry_delete().is_some()
|| app.pending_entry_deferral().is_some()
|| app.pending_entry_ignore().is_some()
|| app.pending_entry_approval().is_some()
|| app.pending_add_path().is_some()
|| app.pending_remove_path().is_some()
|| app.pending_audit_export().is_some()
|| app.pending_dry_run().is_some();
if modal_open {
let hints = if app.pending_dry_run().is_some() {
"[Any key] Close"
} else if app.pending_entry_deferral().is_some() {
"[0-9] Enter days [Backspace] Delete [Enter] Confirm [Esc] Cancel"
} else if app.pending_add_path().is_some() {
"[Type path] (supports ~) [Backspace] Delete [Enter] Add [Esc] Cancel"
} else if app.pending_audit_export().is_some() {
"[Type path] [Tab] Format [Backspace] Delete [Enter] Export [Esc] Cancel"
} else {
"[y] Yes [n] No [Esc] Cancel"
};
let footer = Paragraph::new(hints).style(Style::default().add_modifier(Modifier::REVERSED));
frame.render_widget(footer, chunks[1]);
return;
}
let (left, right) = context_hints(app);
let line = build_hint_line(&left, &right, chunks[1].width);
let footer = Paragraph::new(line);
frame.render_widget(footer, chunks[1]);
}
fn render_modal_shell(
frame: &mut Frame,
title: &str,
border_color: Color,
desired_width: u16,
desired_height: u16,
) -> Rect {
let area = frame.area();
let buffer = frame.buffer_mut();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
let cell = &mut buffer[(x, y)];
cell.set_style(cell.style().add_modifier(Modifier::DIM));
}
}
let width = desired_width.clamp(44, area.width.saturating_sub(4));
let height = desired_height.clamp(8, area.height.saturating_sub(2));
let modal_area = Rect {
x: area.left() + area.width.saturating_sub(width) / 2,
y: area.top() + area.height.saturating_sub(height) / 2,
width,
height,
};
let block = Block::default()
.title(title)
.title_style(
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
)
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.padding(Padding::symmetric(2, 1))
.style(Style::default().bg(palette::MODAL_BG).fg(palette::MODAL_FG));
let inner = block.inner(modal_area);
frame.render_widget(Clear, modal_area);
frame.render_widget(block, modal_area);
inner
}
fn render_modal_body(frame: &mut Frame, area: Rect, lines: Vec<Line<'_>>) {
let body = Paragraph::new(lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.style(Style::default().fg(palette::MODAL_FG).bg(palette::MODAL_BG));
frame.render_widget(body, area);
}
const MODAL_WIDTH_CONFIRM: u16 = 64;
const MODAL_WIDTH_INPUT: u16 = 74;
const MODAL_WIDTH_FORM: u16 = 82;
fn render_confirmation_modal(frame: &mut Frame, path: &str) {
let inner = render_modal_shell(
frame,
"Approve Removal",
palette::YELLOW,
MODAL_WIDTH_CONFIRM,
10,
);
render_modal_body(
frame,
inner,
vec![
Line::from(vec![Span::styled(
"Action",
Style::default().fg(palette::MODAL_MUTED),
)]),
Line::from("Approve removal for:"),
Line::from(path.to_string()),
Line::from(""),
Line::from(vec![Span::styled(
"[N] cancel [y] confirm",
Style::default().fg(palette::MODAL_MUTED),
)]),
],
);
}
fn render_deferral_modal(
frame: &mut Frame,
path: &str,
input: &str,
default_days: u32,
count: usize,
) {
let title = if count > 1 {
format!("Defer Expiration ({count} files)")
} else {
"Defer Expiration".to_string()
};
let display_input = if input.is_empty() {
format!("[{default_days}]")
} else {
input.to_string()
};
let path_display = if count > 1 {
format!("{count} selected files")
} else {
path.to_string()
};
let inner = render_modal_shell(frame, &title, palette::CYAN, MODAL_WIDTH_INPUT, 12);
render_modal_body(
frame,
inner,
vec![
Line::from(vec![Span::styled(
"Target",
Style::default().fg(palette::MODAL_MUTED),
)]),
Line::from(path_display),
Line::from(""),
Line::from(vec![
Span::styled("Days to defer: ", Style::default().fg(palette::MODAL_MUTED)),
Span::raw(display_input),
]),
Line::from(""),
Line::from(vec![Span::styled(
"[Enter] confirm [Esc] cancel",
Style::default().fg(palette::MODAL_MUTED),
)]),
],
);
}
fn render_entry_delete_modal(frame: &mut Frame, path: &str, is_dir: bool, method: RemovalMethod) {
let type_name = if is_dir { "directory" } else { "file" };
let (title, action_verb, border_color) = match method {
RemovalMethod::Trash => (
if is_dir {
"Move Directory to Trash"
} else {
"Move File to Trash"
},
"Move to trash",
palette::YELLOW,
),
RemovalMethod::PermanentDelete => (
if is_dir {
"Permanently Delete Directory"
} else {
"Permanently Delete File"
},
"Permanently delete",
palette::RED,
),
};
let inner = render_modal_shell(frame, title, border_color, MODAL_WIDTH_CONFIRM, 10);
render_modal_body(
frame,
inner,
vec![
Line::from(vec![Span::styled(
"Action",
Style::default().fg(palette::MODAL_MUTED),
)]),
Line::from(format!("{action_verb} {type_name}:")),
Line::from(path.to_string()),
Line::from(""),
Line::from(vec![Span::styled(
"[N] cancel [y] confirm",
Style::default().fg(palette::MODAL_MUTED),
)]),
],
);
}
fn render_entry_delete_modal_multi(frame: &mut Frame, count: usize, method: RemovalMethod) {
let (title, message, border_color) = match method {
RemovalMethod::Trash => (
format!("Move {count} Entries to Trash"),
format!("Move {count} entries to trash?"),
palette::YELLOW,
),
RemovalMethod::PermanentDelete => (
format!("Permanently Delete {count} Entries"),
format!("Permanently delete {count} entries?"),
palette::RED,
),
};
let inner = render_modal_shell(frame, &title, border_color, 58, 9);
render_modal_body(
frame,
inner,
vec![
Line::from(message),
Line::from(""),
Line::from(vec![Span::styled(
"[N] cancel [y] confirm",
Style::default().fg(palette::MODAL_MUTED),
)]),
],
);
}
fn render_ignore_modal(frame: &mut Frame, path: &str) {
let inner = render_modal_shell(
frame,
"Ignore Path Permanently",
palette::CYAN,
MODAL_WIDTH_CONFIRM,
10,
);
render_modal_body(
frame,
inner,
vec![
Line::from("Permanently ignore:"),
Line::from(path.to_string()),
Line::from(""),
Line::from(vec![Span::styled(
"[Y] confirm [n] cancel",
Style::default().fg(palette::MODAL_MUTED),
)]),
],
);
}
fn render_ignore_modal_multi(frame: &mut Frame, count: usize) {
let title = format!("Ignore {count} Files Permanently");
let inner = render_modal_shell(frame, &title, palette::CYAN, 58, 9);
render_modal_body(
frame,
inner,
vec![
Line::from(format!("Permanently ignore {count} files?")),
Line::from(""),
Line::from(vec![Span::styled(
"[Y] confirm [n] cancel",
Style::default().fg(palette::MODAL_MUTED),
)]),
],
);
}
fn render_confirmation_modal_multi(frame: &mut Frame, count: usize) {
let title = format!("Approve {count} Files for Removal");
let inner = render_modal_shell(frame, &title, palette::YELLOW, 60, 9);
render_modal_body(
frame,
inner,
vec![
Line::from(format!("Approve {count} files for removal?")),
Line::from(""),
Line::from(vec![Span::styled(
"[Y] confirm [n] cancel",
Style::default().fg(palette::MODAL_MUTED),
)]),
],
);
}
fn render_add_path_modal(frame: &mut Frame, input: &str) {
let display_input = if input.is_empty() { "_" } else { input };
let inner = render_modal_shell(
frame,
"Add Tracked Path",
palette::GREEN,
MODAL_WIDTH_INPUT,
11,
);
render_modal_body(
frame,
inner,
vec![
Line::from(vec![Span::styled(
"Path",
Style::default().fg(palette::MODAL_MUTED),
)]),
Line::from(display_input.to_string()),
Line::from(""),
Line::from(vec![Span::styled(
"Supports ~ expansion",
Style::default().fg(palette::MODAL_MUTED),
)]),
Line::from(vec![Span::styled(
"[Enter] add [Esc] cancel",
Style::default().fg(palette::MODAL_MUTED),
)]),
],
);
}
fn render_remove_path_modal(frame: &mut Frame, path: &str) {
let inner = render_modal_shell(
frame,
"Remove Tracked Path",
palette::RED,
MODAL_WIDTH_CONFIRM,
10,
);
render_modal_body(
frame,
inner,
vec![
Line::from("Remove tracked path:"),
Line::from(path.to_string()),
Line::from(""),
Line::from(vec![Span::styled(
"[N] cancel [y] confirm",
Style::default().fg(palette::MODAL_MUTED),
)]),
],
);
}
fn render_full_rescan_modal(frame: &mut Frame) {
let inner = render_modal_shell(frame, "Full Filesystem Rescan", palette::YELLOW, 68, 11);
render_modal_body(
frame,
inner,
vec![
Line::from("Run full filesystem rescan?"),
Line::from(""),
Line::from(Span::styled(
"This may take a long time depending on the size and depth of tracked directories.",
Style::default().fg(palette::MODAL_MUTED),
)),
Line::from(""),
Line::from(Span::styled(
"[N] cancel [y] run rescan",
Style::default().fg(palette::MODAL_MUTED),
)),
],
);
}
fn render_audit_export_modal(frame: &mut Frame, export: &PendingAuditExport) {
let inner = render_modal_shell(frame, "Export Audit Log", palette::CYAN, 86, 12);
let display_input = if export.path_input.is_empty() {
"_".to_string()
} else {
export.path_input.clone()
};
render_modal_body(
frame,
inner,
vec![
Line::from(vec![Span::styled(
"Output path",
Style::default().fg(palette::MODAL_MUTED),
)]),
Line::from(display_input),
Line::from(""),
Line::from(vec![
Span::styled("Format: ", Style::default().fg(palette::MODAL_MUTED)),
Span::styled(
export.format.label(),
Style::default()
.fg(palette::CYAN)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" (Tab to switch)",
Style::default().fg(palette::MODAL_MUTED),
),
]),
Line::from(""),
Line::from(vec![Span::styled(
"[Enter] export [Esc] cancel",
Style::default().fg(palette::MODAL_MUTED),
)]),
],
);
}
fn render_quota_target_modal(frame: &mut Frame, target: &PendingQuotaTarget) {
let inner = render_modal_shell(
frame,
"Set Quota Target",
palette::CYAN,
MODAL_WIDTH_FORM,
13,
);
let current_display = match target.current_target {
#[allow(clippy::cast_sign_loss)]
Some(bytes) if bytes >= 0 => crate::format_bytes(bytes as u64),
Some(_) => "invalid".to_string(),
None => "not set".to_string(),
};
let size_display = if target.input.is_empty() {
"_".to_string()
} else {
target.input.clone()
};
let unit_display = format!(
"{} {} {}",
if target.unit == super::ByteUnit::MB {
"[MB]"
} else {
" MB "
},
if target.unit == super::ByteUnit::GB {
"[GB]"
} else {
" GB "
},
if target.unit == super::ByteUnit::TB {
"[TB]"
} else {
" TB "
},
);
let (size_style, unit_style) = match target.focus {
QuotaTargetFocus::Size => (
Style::default()
.fg(palette::CYAN)
.add_modifier(Modifier::BOLD),
Style::default().fg(palette::MODAL_FG),
),
QuotaTargetFocus::Unit => (
Style::default().fg(palette::MODAL_FG),
Style::default()
.fg(palette::CYAN)
.add_modifier(Modifier::BOLD),
),
};
let path_display = target.root_path.display().to_string();
let content = vec![
Line::from(vec![
Span::styled("Root: ", Style::default().fg(palette::MODAL_MUTED)),
Span::raw(path_display),
]),
Line::from(vec![
Span::styled("Current: ", Style::default().fg(palette::MODAL_MUTED)),
Span::raw(current_display),
]),
Line::from(""),
Line::from(vec![
Span::styled("Size: ", Style::default().fg(palette::MODAL_MUTED)),
Span::styled(size_display, size_style),
Span::styled(" Unit: ", Style::default().fg(palette::MODAL_MUTED)),
Span::styled(unit_display, unit_style),
]),
Line::from(""),
Line::from(vec![Span::styled(
"[Tab] switch fields [Enter] confirm",
Style::default().fg(palette::MODAL_MUTED),
)]),
Line::from(vec![Span::styled(
"Empty or 0 clears target [Esc] cancel",
Style::default().fg(palette::MODAL_MUTED),
)]),
];
render_modal_body(frame, inner, content);
}
fn render_dry_run_modal(frame: &mut Frame, result: &DryRunResult) {
let title = format!(
"Dry Run: {} of {} removable",
result.removable_count, result.total_count
);
let failure_count = result.failures.len();
let modal_height = u16::try_from(failure_count)
.unwrap_or(u16::MAX)
.saturating_add(6)
.min(24);
let inner = render_modal_shell(frame, &title, palette::YELLOW, 78, modal_height);
let mut lines = vec![
Line::from(vec![Span::styled(
format!(
"{} entr{} would fail removal:",
failure_count,
if failure_count == 1 { "y" } else { "ies" }
),
Style::default().fg(palette::RED),
)]),
Line::from(""),
];
for failure in &result.failures {
let path_display = failure.path.to_string_lossy();
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
path_display.into_owned(),
Style::default().fg(palette::MODAL_FG),
),
]));
lines.push(Line::from(vec![Span::styled(
format!(" {}", failure.reason),
Style::default().fg(palette::MODAL_MUTED),
)]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"[Any key] close",
Style::default().fg(palette::MODAL_MUTED),
)]));
render_modal_body(frame, inner, lines);
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn test_entry(path: &str, size_bytes: i64, mtime: Option<i64>) -> crate::db::Entry {
let now = jiff::Timestamp::now().as_second();
crate::db::Entry {
id: 0,
root_id: 1,
path: PathBuf::from(path),
parent_path: PathBuf::from("/"),
is_dir: false,
size_bytes,
mtime,
tracked_since: Some(now),
countdown_start: Some(now),
status: "tracked".to_string(),
deferred_until: None,
created_at: now,
updated_at: now,
}
}
fn test_entry_dir(path: &str) -> crate::db::Entry {
let now = jiff::Timestamp::now().as_second();
crate::db::Entry {
id: 0,
root_id: 1,
path: PathBuf::from(path),
parent_path: PathBuf::from("/"),
is_dir: true,
size_bytes: 0,
mtime: None,
tracked_since: Some(now),
countdown_start: None,
status: "tracked".to_string(),
deferred_until: None,
created_at: now,
updated_at: now,
}
}
#[test]
fn sort_entries_by_expiration_most_urgent_first() {
let mut rows = vec![
(test_entry("/c.txt", 100, Some(1000)), 30), (test_entry("/a.txt", 200, Some(5000)), 5), (test_entry("/b.txt", 150, Some(3000)), 15), ];
sort_entry_rows(&mut rows, SortMode::Expiration);
assert_eq!(
rows[0].0.path,
PathBuf::from("/a.txt"),
"Most urgent (5 days) should be first"
);
assert_eq!(
rows[1].0.path,
PathBuf::from("/b.txt"),
"Middle urgency (15 days) should be second"
);
assert_eq!(
rows[2].0.path,
PathBuf::from("/c.txt"),
"Least urgent (30 days) should be last"
);
}
#[test]
fn sort_entries_directories_sort_by_expiration_like_files() {
let mut rows = vec![
(test_entry("/a.txt", 100, Some(1000)), 10),
(test_entry_dir("/subdir"), 3),
(test_entry("/b.txt", 100, Some(1000)), 20),
];
sort_entry_rows(&mut rows, SortMode::Expiration);
assert_eq!(
rows[0].0.path,
PathBuf::from("/subdir"),
"Most urgent (dir with 3 days) first"
);
assert_eq!(
rows[1].0.path,
PathBuf::from("/a.txt"),
"File with 10 days second"
);
assert_eq!(
rows[2].0.path,
PathBuf::from("/b.txt"),
"Least urgent (20 days) last"
);
}
#[test]
fn sort_entries_by_size_largest_first() {
let mut rows = vec![
(test_entry("/a.txt", 100, Some(1000)), 10),
(test_entry("/b.txt", 500, Some(1000)), 10),
(test_entry("/c.txt", 250, Some(1000)), 10),
];
sort_entry_rows(&mut rows, SortMode::Size);
assert_eq!(
rows[0].0.path,
PathBuf::from("/b.txt"),
"Largest (500) should be first"
);
assert_eq!(
rows[1].0.path,
PathBuf::from("/c.txt"),
"Middle (250) should be second"
);
assert_eq!(
rows[2].0.path,
PathBuf::from("/a.txt"),
"Smallest (100) should be last"
);
}
#[test]
fn sort_entries_by_name_alphabetical_dirs_first() {
let mut rows = vec![
(test_entry("/zebra.txt", 100, Some(1000)), 10),
(test_entry_dir("/alpha_dir"), 15),
(test_entry("/mango.txt", 100, Some(1000)), 10),
];
sort_entry_rows(&mut rows, SortMode::Name);
assert_eq!(
rows[0].0.path,
PathBuf::from("/alpha_dir"),
"Directory should come first"
);
assert_eq!(
rows[1].0.path,
PathBuf::from("/mango.txt"),
"Mango should be second"
);
assert_eq!(
rows[2].0.path,
PathBuf::from("/zebra.txt"),
"Zebra should be last"
);
}
#[test]
fn sort_entries_empty_list_does_not_panic() {
let mut rows: Vec<(crate::db::Entry, i64)> = vec![];
sort_entry_rows(&mut rows, SortMode::Expiration);
sort_entry_rows(&mut rows, SortMode::Size);
sort_entry_rows(&mut rows, SortMode::Name);
sort_entry_rows(&mut rows, SortMode::Modified);
assert_eq!(rows.len(), 0, "Empty list should remain empty");
}
#[test]
fn sort_entries_by_modified_most_recent_first() {
let mut rows = vec![
(test_entry("/a.txt", 100, Some(1000)), 10), (test_entry("/b.txt", 100, Some(5000)), 10), (test_entry("/c.txt", 100, Some(3000)), 10), ];
sort_entry_rows(&mut rows, SortMode::Modified);
assert_eq!(
rows[0].0.path,
PathBuf::from("/b.txt"),
"Most recent (5000) should be first"
);
assert_eq!(
rows[1].0.path,
PathBuf::from("/c.txt"),
"Middle (3000) should be second"
);
assert_eq!(
rows[2].0.path,
PathBuf::from("/a.txt"),
"Oldest (1000) should be last"
);
}
#[test]
fn lifecycle_view_includes_ignored_in_totals_only() {
let now = jiff::Timestamp::now().as_second();
let config = Config::default();
let mut healthy = test_entry("/healthy.txt", 100, Some(now));
healthy.countdown_start = Some(now);
healthy.status = "tracked".to_string();
let mut ignored = test_entry("/ignored.txt", 200, Some(now));
ignored.status = "ignored".to_string();
let view = LifecycleView::from_entries(&[healthy, ignored], &config);
assert_eq!(view.healthy.files, 1);
assert_eq!(view.healthy.bytes, 100);
assert_eq!(view.warning.files, 0);
assert_eq!(view.overdue.files, 0);
assert_eq!(view.ignored.files, 1);
assert_eq!(view.ignored.bytes, 200);
assert_eq!(
view.total_files, 2,
"ignored files should contribute to total"
);
assert_eq!(
view.total_bytes, 300,
"ignored bytes should contribute to total"
);
}
#[test]
fn build_summary_line_shows_ignored_callout_when_present() {
let view = LifecycleView {
total_files: 10,
total_bytes: 1_000,
healthy: LifecycleTally {
files: 4,
bytes: 400,
},
warning: LifecycleTally {
files: 3,
bytes: 300,
},
overdue: LifecycleTally {
files: 1,
bytes: 100,
},
ignored: LifecycleTally {
files: 2,
bytes: 200,
},
};
let line = build_summary_line(&view, None);
let rendered: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(rendered.contains("Total: 10 files, 1.0 KB"));
assert!(rendered.contains("ignored: 2 files, 200 B"));
}
#[test]
fn build_summary_line_omits_ignored_callout_when_zero() {
let view = LifecycleView {
total_files: 8,
total_bytes: 800,
healthy: LifecycleTally {
files: 4,
bytes: 400,
},
warning: LifecycleTally {
files: 3,
bytes: 300,
},
overdue: LifecycleTally {
files: 1,
bytes: 100,
},
ignored: LifecycleTally { files: 0, bytes: 0 },
};
let line = build_summary_line(&view, None);
let rendered: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(rendered.contains("Total: 8 files, 800 B"));
assert!(!rendered.contains("ignored:"));
}
#[test]
fn format_timestamp_formats_correctly() {
let ts = 1_705_329_165;
let result = format_timestamp(ts);
assert!(
result.contains("2024"),
"Should contain year 2024, got: {result}"
);
assert!(
result.contains("01") || result.contains("1-"),
"Should contain month 01, got: {result}"
);
assert!(
result.contains("15") || result.contains("14") || result.contains("16"),
"Should contain day 14-16 (timezone variance), got: {result}"
);
}
#[test]
fn format_timestamp_handles_unix_epoch() {
let result = format_timestamp(0);
assert!(
result.contains("1970") || result.contains("1969"),
"Should contain year 1970 or 1969, got: {result}"
);
}
#[test]
fn format_timestamp_handles_invalid_timestamp_gracefully() {
let result = format_timestamp(i64::MIN);
assert!(
!result.is_empty(),
"Should return non-empty string even for invalid timestamp"
);
}
#[test]
fn format_timestamp_includes_time_components() {
let ts = 1_718_443_845;
let result = format_timestamp(ts);
assert!(
result.contains(':'),
"Should include time separator ':', got: {result}"
);
assert_eq!(
result.matches(':').count(),
2,
"Should have exactly 2 colons for HH:MM:SS format, got: {result}"
);
}
fn make_test_entry(status: &str, deferred_until: Option<i64>) -> crate::db::Entry {
crate::db::Entry {
id: 1,
root_id: 1,
path: PathBuf::from("/test/file.txt"),
parent_path: PathBuf::from("/test"),
is_dir: false,
size_bytes: 100,
mtime: Some(0),
tracked_since: None,
countdown_start: Some(0),
status: status.to_string(),
deferred_until,
created_at: 0,
updated_at: 0,
}
}
#[test]
fn expiration_indicator_entry_overdue_is_red_circle() {
let entry = make_test_entry("tracked", None);
let (symbol, color) = expiration_indicator_entry("tracked", 0, 14, &entry);
assert_eq!(symbol, "●", "Overdue (0 days) should show filled circle");
assert_eq!(color, palette::RED, "Overdue should be red");
let (symbol, color) = expiration_indicator_entry("tracked", -5, 14, &entry);
assert_eq!(symbol, "●", "Negative days should show filled circle");
assert_eq!(color, palette::RED, "Overdue should be red");
}
#[test]
fn expiration_indicator_entry_within_warning_is_yellow_triangle() {
let entry = make_test_entry("tracked", None);
let (symbol, color) = expiration_indicator_entry("tracked", 1, 14, &entry);
assert_eq!(symbol, "⚠", "1 day remaining should show warning triangle");
assert_eq!(color, palette::YELLOW, "Warning should be yellow");
let (symbol, color) = expiration_indicator_entry("tracked", 14, 14, &entry);
assert_eq!(symbol, "⚠", "At warning threshold should show warning");
assert_eq!(color, palette::YELLOW, "Warning should be yellow");
}
#[test]
fn expiration_indicator_entry_safe_is_empty() {
let entry = make_test_entry("tracked", None);
let (symbol, color) = expiration_indicator_entry("tracked", 15, 14, &entry);
assert_eq!(symbol, " ", "Safe entries should show no indicator");
assert_eq!(color, Color::Reset, "Safe should use reset color");
let (symbol, _) = expiration_indicator_entry("tracked", 90, 14, &entry);
assert_eq!(symbol, " ", "Very safe entries should show no indicator");
}
#[test]
fn expiration_indicator_entry_ignored_is_gray_dash() {
let entry = make_test_entry("ignored", None);
let (symbol, color) = expiration_indicator_entry("ignored", -30, 14, &entry);
assert_eq!(symbol, "—", "Ignored entries should show dash");
assert_eq!(color, Color::DarkGray, "Ignored should be gray");
let (symbol, _) = expiration_indicator_entry("ignored", 0, 14, &entry);
assert_eq!(
symbol, "—",
"Ignored overdue entries should still show dash"
);
}
#[test]
fn expiration_indicator_entry_deferred_uses_deferred_until() {
let future = jiff::Timestamp::now().as_second() + (30 * 86400); let entry = make_test_entry("deferred", Some(future));
let (symbol, _) = expiration_indicator_entry("deferred", -100, 14, &entry);
assert_eq!(
symbol, " ",
"Deferred with time remaining should show no indicator"
);
let soon = jiff::Timestamp::now().as_second() + (5 * 86400); let entry = make_test_entry("deferred", Some(soon));
let (symbol, color) = expiration_indicator_entry("deferred", -100, 14, &entry);
assert_eq!(symbol, "⚠", "Deferred expiring soon should show warning");
assert_eq!(color, palette::YELLOW);
}
#[test]
fn workflow_indicator_pending_and_approved_have_markers() {
let (pending_symbol, pending_color) = workflow_indicator("pending");
assert_eq!(pending_symbol, "!");
assert_eq!(pending_color, palette::YELLOW);
let (approved_symbol, approved_color) = workflow_indicator("approved");
assert_eq!(approved_symbol, "✓");
assert_eq!(approved_color, Color::Reset);
}
#[test]
fn workflow_indicator_other_states_are_blank() {
let (tracked_symbol, tracked_color) = workflow_indicator("tracked");
assert_eq!(tracked_symbol, " ");
assert_eq!(tracked_color, Color::Reset);
let (deferred_symbol, deferred_color) = workflow_indicator("deferred");
assert_eq!(deferred_symbol, " ");
assert_eq!(deferred_color, Color::Reset);
}
}