use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, StatefulWidget, Widget, Wrap},
};
use crate::color::ColorTheme;
use crate::void_backend::{
AuditResult, CommitAudit, ManifestAudit, MetadataAudit, ObjectInfo, ObjectType,
RepoManifestAudit, ShardAudit,
};
#[derive(Debug, Default)]
pub struct AuditDetailState {
offset: usize,
viewport_height: usize,
content_height: usize,
}
impl AuditDetailState {
pub fn new() -> Self {
Self::default()
}
pub fn reset(&mut self) {
self.offset = 0;
}
pub fn scroll_up(&mut self) {
self.offset = self.offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
let max_offset = self.content_height.saturating_sub(self.viewport_height);
if self.offset < max_offset {
self.offset += 1;
}
}
pub fn scroll_up_half(&mut self) {
let half = self.viewport_height / 2;
for _ in 0..half {
self.scroll_up();
}
}
pub fn scroll_down_half(&mut self) {
let half = self.viewport_height / 2;
for _ in 0..half {
self.scroll_down();
}
}
pub fn scroll_up_page(&mut self) {
for _ in 0..self.viewport_height {
self.scroll_up();
}
}
pub fn scroll_down_page(&mut self) {
for _ in 0..self.viewport_height {
self.scroll_down();
}
}
}
pub struct AuditDetail<'a> {
object: &'a ObjectInfo,
audit: &'a AuditResult,
theme: &'a ColorTheme,
}
impl<'a> AuditDetail<'a> {
pub fn new(object: &'a ObjectInfo, audit: &'a AuditResult, theme: &'a ColorTheme) -> Self {
Self {
object,
audit,
theme,
}
}
}
impl<'a> StatefulWidget for AuditDetail<'a> {
type State = AuditDetailState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let title = match self.object.object_type {
ObjectType::Commit => format!(" Commit: {}... ", self.object.short_cid()),
ObjectType::Metadata => format!(" Metadata: {}... ", self.object.short_cid()),
ObjectType::Manifest => format!(" Manifest: {}... ", self.object.short_cid()),
ObjectType::RepoManifest => format!(" Repo Manifest: {}... ", self.object.short_cid()),
ObjectType::Shard => format!(" Shard: {}... ", self.object.short_cid()),
ObjectType::Unknown => format!(" Object: {}... ", self.object.short_cid()),
};
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(area);
block.render(area, buf);
let content = self.build_content(inner.width as usize);
state.viewport_height = inner.height as usize;
state.content_height = content.len();
let visible_content: Vec<Line> = content
.into_iter()
.skip(state.offset)
.take(state.viewport_height)
.collect();
let paragraph = Paragraph::new(visible_content).wrap(Wrap { trim: false });
paragraph.render(inner, buf);
}
}
impl<'a> AuditDetail<'a> {
fn build_content(&self, _width: usize) -> Vec<Line<'a>> {
let mut lines = Vec::new();
lines.push(self.label_value("CID", &self.object.cid));
lines.push(self.label_value("Format", self.object.format.as_str()));
lines.push(self.label_value(
"Encrypted Size",
&format_size(self.object.encrypted_size),
));
lines.push(Line::from(""));
match self.audit {
AuditResult::Commit(commit) => {
self.build_commit_content(&mut lines, commit);
}
AuditResult::Metadata(metadata) => {
self.build_metadata_content(&mut lines, metadata);
}
AuditResult::Manifest(manifest) => {
self.build_manifest_content(&mut lines, manifest);
}
AuditResult::RepoManifest(rm) => {
self.build_repo_manifest_content(&mut lines, rm);
}
AuditResult::Shard(shard) => {
self.build_shard_content(&mut lines, shard);
}
AuditResult::Error(err) => {
lines.push(Line::from(vec![
Span::styled(
"Error: ",
Style::default()
.fg(self.theme.status_error_fg)
.add_modifier(Modifier::BOLD),
),
Span::raw(err.clone()),
]));
}
}
lines
}
fn build_commit_content(&self, lines: &mut Vec<Line<'a>>, commit: &CommitAudit) {
lines.push(Line::from(vec![Span::styled(
"── Commit ──",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(self.label_value("Message", &commit.message));
lines.push(self.label_value("Date", &format_timestamp(commit.timestamp)));
if let Some(ref parent) = commit.parent_cid {
lines.push(self.label_value("Parent", parent));
} else {
lines.push(self.label_value("Parent", "(root commit)"));
}
lines.push(self.label_value("Metadata", &commit.metadata_cid));
let signed_str = if commit.is_signed {
"Yes"
} else {
"No"
};
lines.push(self.label_value("Signed", signed_str));
if let Some(ref author) = commit.author {
lines.push(self.label_value("Author", &format!("{}...", &author[..16.min(author.len())])));
}
}
fn build_metadata_content(&self, lines: &mut Vec<Line<'a>>, metadata: &MetadataAudit) {
lines.push(Line::from(vec![Span::styled(
"── Metadata Bundle ──",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(self.label_value("Version", &metadata.version.to_string()));
lines.push(self.label_value("Range Count", &metadata.range_count.to_string()));
lines.push(self.label_value("Shard Count", &metadata.shards.len().to_string()));
if let Some(ref parent) = metadata.parent_commit {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"── Referenced By ──",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(self.label_value("Commit", &parent.cid));
lines.push(self.label_value("Message", &parent.message));
lines.push(self.label_value("Date", &format_timestamp(parent.timestamp)));
}
if !metadata.shards.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"── Shards ──",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
for shard in &metadata.shards {
lines.push(Line::from(format!(
" {}: {}",
shard.shard_id,
&shard.cid[..shard.cid.len().min(20)]
)));
}
}
}
fn build_shard_content(&self, lines: &mut Vec<Line<'a>>, shard: &ShardAudit) {
lines.push(Line::from(vec![Span::styled(
"── Shard ──",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(self.label_value("Version", &shard.version.to_string()));
lines.push(self.label_value("Files", &shard.entry_count.to_string()));
lines.push(self.label_value("Directories", &shard.dir_count.to_string()));
lines.push(self.label_value(
"Compressed",
&format_size(shard.body_compressed as usize),
));
lines.push(self.label_value(
"Decompressed",
&format_size(shard.body_decompressed as usize),
));
let savings = if shard.body_decompressed > 0 {
((1.0 - shard.body_compressed as f64 / shard.body_decompressed as f64) * 100.0) as u32
} else {
0
};
lines.push(self.label_value("Compression", &format!("{}%", savings)));
if let Some(ref parent) = shard.parent_commit {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"── First Referenced By ──",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(self.label_value("Commit", &parent.cid));
lines.push(self.label_value("Message", &parent.message));
lines.push(self.label_value("Date", &format_timestamp(parent.timestamp)));
}
if !shard.entries.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
format!("── Files ({} entries) ──", shard.entries.len()),
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
let max_entries = 50;
for entry in shard.entries.iter().take(max_entries) {
let path_display = if entry.path.len() > 40 {
format!("...{}", &entry.path[entry.path.len() - 37..])
} else {
entry.path.clone()
};
let size_str = format_size(entry.size as usize);
lines.push(Line::from(format!(" {} ({})", path_display, size_str)));
}
if shard.entries.len() > max_entries {
lines.push(Line::from(format!(
" ... and {} more files",
shard.entries.len() - max_entries
)));
}
}
}
fn build_repo_manifest_content(&self, lines: &mut Vec<Line<'a>>, rm: &RepoManifestAudit) {
lines.push(Line::from(vec![Span::styled(
"── Repo Manifest (contributors.json) ──",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(self.label_value("Encrypted Size", &format_size(rm.encrypted_size)));
if let Some(ref parent) = rm.parent_commit {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"── Referenced By ──",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(self.label_value("Commit", &parent.cid));
lines.push(self.label_value("Message", &parent.message));
lines.push(self.label_value("Date", &format_timestamp(parent.timestamp)));
}
}
fn build_manifest_content(&self, lines: &mut Vec<Line<'a>>, manifest: &ManifestAudit) {
lines.push(Line::from(vec![Span::styled(
"── Tree Manifest ──",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(self.label_value("Files", &manifest.file_count.to_string()));
lines.push(self.label_value("Total Size", &format_size(manifest.total_bytes as usize)));
lines.push(self.label_value("Shards", &manifest.shard_count.to_string()));
if let Some(ref parent) = manifest.parent_commit {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"── Referenced By ──",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(self.label_value("Commit", &parent.cid));
lines.push(self.label_value("Message", &parent.message));
lines.push(self.label_value("Date", &format_timestamp(parent.timestamp)));
}
if !manifest.shards.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
format!("── Shards ({}) ──", manifest.shards.len()),
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
for (i, shard) in manifest.shards.iter().enumerate() {
let ratio = if shard.size_decompressed > 0 {
format!(
"{}%",
((1.0 - shard.size_compressed as f64 / shard.size_decompressed as f64)
* 100.0) as u32
)
} else {
"N/A".to_string()
};
lines.push(Line::from(format!(
" [{}] {} files, {} -> {} ({})",
i,
shard.file_count,
format_size(shard.size_decompressed as usize),
format_size(shard.size_compressed as usize),
ratio,
)));
lines.push(Line::from(format!(
" {}",
&shard.cid[..shard.cid.len().min(24)]
)));
}
}
if !manifest.files.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
format!("── Files ({} entries) ──", manifest.files.len()),
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
let max_entries = 100;
for entry in manifest.files.iter().take(max_entries) {
let path_display = if entry.path.len() > 40 {
format!("...{}", &entry.path[entry.path.len() - 37..])
} else {
entry.path.clone()
};
let line_info = if entry.lines > 0 {
format!(", {}L", entry.lines)
} else {
String::new()
};
lines.push(Line::from(format!(
" {} ({}{}) [shard {}]",
path_display,
format_size(entry.size as usize),
line_info,
entry.shard_index,
)));
}
if manifest.files.len() > max_entries {
lines.push(Line::from(format!(
" ... and {} more files",
manifest.files.len() - max_entries
)));
}
}
}
fn label_value(&self, label: &str, value: &str) -> Line<'a> {
Line::from(vec![
Span::styled(
format!("{:>12}: ", label),
Style::default()
.fg(self.theme.detail_label_fg)
.add_modifier(Modifier::BOLD),
),
Span::styled(value.to_string(), Style::default().fg(self.theme.detail_value_fg)),
])
}
}
fn format_size(bytes: usize) -> String {
if bytes < 1024 {
format!("{}B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1}KB", bytes as f64 / 1024.0)
} else {
format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
}
}
fn format_timestamp(ts_ms: u64) -> String {
use std::time::{Duration, UNIX_EPOCH};
let d = UNIX_EPOCH + Duration::from_millis(ts_ms);
let datetime: chrono::DateTime<chrono::Utc> = d.into();
datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
pub struct AuditLoading<'a> {
object: &'a ObjectInfo,
theme: &'a ColorTheme,
}
impl<'a> AuditLoading<'a> {
pub fn new(object: &'a ObjectInfo, theme: &'a ColorTheme) -> Self {
Self { object, theme }
}
}
impl<'a> Widget for AuditLoading<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let title = format!(" Object: {}... ", self.object.short_cid());
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(area);
block.render(area, buf);
let msg = "Loading audit data...";
let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2;
let y = inner.y + inner.height / 2;
buf.set_string(x, y, msg, Style::default().fg(self.theme.status_info_fg));
}
}