use std::collections::HashSet;
use std::path::{Path, PathBuf};
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, StatefulWidget, Widget};
use gravityfile_core::{FileNode, GitStatus, NodeKind};
use crate::app::state::{ClipboardMode, ClipboardState};
use crate::preview::PreviewContent;
use crate::theme::Theme;
use crate::ui::{CompactSizeBar, format_size, truncate_to_width};
#[derive(Debug, Clone, Default)]
pub struct MillerState {
pub selected: usize,
pub offset: usize,
}
impl MillerState {
pub fn new() -> Self {
Self::default()
}
pub fn move_up(&mut self, count: usize) {
self.selected = self.selected.saturating_sub(count);
}
pub fn move_down(&mut self, count: usize, max: usize) {
self.selected = (self.selected + count).min(max.saturating_sub(1));
}
pub fn jump_to_top(&mut self) {
self.selected = 0;
self.offset = 0;
}
pub fn jump_to_bottom(&mut self, max: usize) {
self.selected = max.saturating_sub(1);
}
pub fn ensure_visible(&mut self, viewport_height: usize) {
if self.selected < self.offset {
self.offset = self.selected;
} else if self.selected >= self.offset + viewport_height {
self.offset = self.selected - viewport_height + 1;
}
}
pub fn reset(&mut self) {
self.selected = 0;
self.offset = 0;
}
}
#[derive(Debug, Clone)]
pub struct ColumnEntry {
pub name: String,
pub size: u64,
pub is_dir: bool,
pub is_executable: bool,
pub is_symlink: bool,
pub is_broken_symlink: bool,
pub git_status: Option<GitStatus>,
}
impl ColumnEntry {
pub fn from_node(node: &FileNode) -> Self {
let (is_executable, is_symlink, is_broken_symlink) = match &node.kind {
NodeKind::File { executable } => (*executable, false, false),
NodeKind::Symlink { broken, .. } => (false, true, *broken),
_ => (false, false, false),
};
Self {
name: node.name.to_string(),
size: node.size,
is_dir: node.is_dir(),
is_executable,
is_symlink,
is_broken_symlink,
git_status: node.git_status,
}
}
}
pub struct MillerColumns<'a> {
current: &'a FileNode,
parent: Option<&'a FileNode>,
current_name: &'a str,
view_root: &'a PathBuf,
marked: &'a HashSet<PathBuf>,
clipboard: &'a ClipboardState,
theme: &'a Theme,
block: Option<Block<'a>>,
file_preview: Option<&'a PreviewContent>,
preview_mode: crate::preview::PreviewMode,
}
impl<'a> MillerColumns<'a> {
pub fn new(
current: &'a FileNode,
parent: Option<&'a FileNode>,
current_name: &'a str,
view_root: &'a PathBuf,
marked: &'a HashSet<PathBuf>,
clipboard: &'a ClipboardState,
theme: &'a Theme,
) -> Self {
Self {
current,
parent,
current_name,
view_root,
marked,
clipboard,
theme,
block: None,
file_preview: None,
preview_mode: crate::preview::PreviewMode::Auto,
}
}
#[allow(dead_code)]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn file_preview(mut self, preview: &'a PreviewContent) -> Self {
self.file_preview = Some(preview);
self
}
pub fn preview_mode(mut self, mode: crate::preview::PreviewMode) -> Self {
self.preview_mode = mode;
self
}
fn get_entries(node: &FileNode) -> Vec<ColumnEntry> {
node.children.iter().map(ColumnEntry::from_node).collect()
}
#[allow(clippy::too_many_arguments)]
fn render_column(
&self,
area: Rect,
buf: &mut Buffer,
entries: &[ColumnEntry],
selected: Option<usize>,
highlight_name: Option<&str>,
offset: usize,
title: Option<&str>,
base_path: Option<&Path>,
total_size: u64,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(self.theme.border);
let block = if let Some(title) = title {
block
.title(format!(" {} ", title))
.title_style(self.theme.title)
} else {
block
};
let inner = block.inner(area);
block.render(area, buf);
if inner.height == 0 || inner.width == 0 {
return;
}
let size_bar_width: u16 = 8;
let viewport_height = inner.height as usize;
let end = (offset + viewport_height).min(entries.len());
for (row_idx, entry_idx) in (offset..end).enumerate() {
let entry = &entries[entry_idx];
let y = inner.y + row_idx as u16;
let is_selected = selected == Some(entry_idx);
let is_highlighted = highlight_name.is_some_and(|name| name == entry.name);
let entry_path = base_path.map(|bp| bp.join(&entry.name));
let is_marked = entry_path
.as_ref()
.map(|p| self.marked.contains(p))
.unwrap_or(false);
let in_clipboard = entry_path
.as_ref()
.map(|p| self.clipboard.paths.contains(p))
.unwrap_or(false);
let clipboard_mode = if in_clipboard {
self.clipboard.mode
} else {
ClipboardMode::Empty
};
let (checkbox, checkbox_style) = if is_marked {
("● ", Style::default().fg(self.theme.info))
} else if clipboard_mode == ClipboardMode::Cut {
("✂ ", Style::default().fg(self.theme.warning))
} else if clipboard_mode == ClipboardMode::Copy {
("⎘ ", Style::default().fg(self.theme.success))
} else {
(" ", Style::default().fg(self.theme.muted))
};
let checkbox_width: u16 = 2;
let (icon, base_style) = if entry.is_dir {
(" ", self.theme.directory)
} else if entry.is_symlink {
if entry.is_broken_symlink {
(" ", self.theme.symlink.add_modifier(Modifier::DIM))
} else {
(" ", self.theme.symlink)
}
} else if entry.is_executable {
("* ", self.theme.executable)
} else {
(" ", self.theme.file)
};
let base_style = if clipboard_mode == ClipboardMode::Cut {
base_style.add_modifier(Modifier::DIM)
} else if clipboard_mode == ClipboardMode::Copy {
base_style.add_modifier(Modifier::ITALIC)
} else {
base_style
};
let icon_width = 2;
let size_width = 7; let available_for_name = inner
.width
.saturating_sub(checkbox_width + icon_width + size_width + size_bar_width + 2)
as usize;
let (git_indicator, git_style) = match entry.git_status {
Some(status) if status.is_displayable() => {
let color = self.theme.git_status_color(status);
(
format!(" {}", status.indicator()),
Style::default().fg(color),
)
}
_ => (String::new(), Style::default()),
};
let git_len = git_indicator.len();
let name_max_len = available_for_name.saturating_sub(git_len);
let name = {
use unicode_width::UnicodeWidthStr;
if entry.name.width() > name_max_len {
let truncated = truncate_to_width(&entry.name, name_max_len.saturating_sub(1));
format!("{}…", truncated)
} else {
entry.name.clone()
}
};
let name_display_width = {
use unicode_width::UnicodeWidthStr;
name.width()
};
let name_padding = " ".repeat(
available_for_name
.saturating_sub(name_display_width)
.saturating_sub(git_len),
);
let size_text = if entry.is_dir && entry.size == 0 {
format!("{:>7}", "...")
} else {
format!("{:>7}", format_size(entry.size))
};
let checkbox_span = Span::styled(checkbox, checkbox_style);
let git_span = Span::styled(&git_indicator, git_style);
let spans = vec![
checkbox_span,
Span::styled(icon, base_style),
Span::styled(&name, base_style),
git_span,
Span::raw(&name_padding),
Span::styled(&size_text, Style::default().fg(self.theme.muted)),
];
let line = Line::from(spans);
let line = if is_selected {
line.style(self.theme.selected)
} else if is_highlighted {
line.style(self.theme.hover)
} else {
line
};
let line_area = Rect::new(
inner.x,
y,
inner.width.saturating_sub(size_bar_width + 1),
1,
);
Widget::render(line, line_area, buf);
let ratio = if total_size > 0 {
entry.size as f64 / total_size as f64
} else {
0.0
};
let bar_area = Rect::new(inner.x + inner.width - size_bar_width, y, size_bar_width, 1);
let bar = CompactSizeBar::new(ratio).style(self.theme.size_bar_style(ratio));
Widget::render(bar, bar_area, buf);
}
if entries.is_empty() {
let msg = Line::styled("(empty)", Style::default().fg(self.theme.muted));
let msg_area = Rect::new(inner.x, inner.y, inner.width, 1);
Widget::render(msg, msg_area, buf);
}
}
fn render_file_preview(&self, area: Rect, buf: &mut Buffer, entry: &ColumnEntry) {
let title = format!(" Preview [{}] (P) ", self.preview_mode.name());
let block = Block::default()
.borders(Borders::ALL)
.border_style(self.theme.border)
.title(title)
.title_style(self.theme.title);
let inner = block.inner(area);
block.render(area, buf);
if let Some(preview) = self.file_preview {
match preview {
PreviewContent::Text {
lines, highlighted, ..
} => {
let header = Line::from(vec![
Span::styled(&entry.name, self.theme.title.add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(
if *highlighted {
"[highlighted]"
} else {
"[plain]"
},
Style::default().fg(self.theme.muted),
),
]);
let mut display_lines = vec![header, Line::raw("")];
let max_lines = inner.height.saturating_sub(2) as usize;
for line in lines.iter().take(max_lines) {
display_lines.push(line.clone());
}
Paragraph::new(display_lines).render(inner, buf);
}
PreviewContent::Hex { lines, total_bytes } => {
let header = Line::from(vec![
Span::styled(&entry.name, self.theme.title.add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(
format!("[binary: {} bytes]", total_bytes),
Style::default().fg(self.theme.warning),
),
]);
let mut display_lines = vec![header, Line::raw("")];
let max_lines = inner.height.saturating_sub(2) as usize;
for line in lines.iter().take(max_lines) {
display_lines.push(line.clone());
}
Paragraph::new(display_lines).render(inner, buf);
}
PreviewContent::Directory { entries } => {
let header = Line::from(vec![
Span::styled(&entry.name, self.theme.title.add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(
format!("[{} items]", entries.len()),
Style::default().fg(self.theme.muted),
),
]);
let mut display_lines = vec![header, Line::raw("")];
let max_lines = inner.height.saturating_sub(2) as usize;
for (name, is_dir) in entries.iter().take(max_lines) {
let icon = if *is_dir { " " } else { " " };
let style = if *is_dir {
self.theme.directory
} else {
self.theme.file
};
display_lines.push(Line::styled(format!("{}{}", icon, name), style));
}
Paragraph::new(display_lines).render(inner, buf);
}
PreviewContent::Metadata {
size,
modified,
created,
accessed,
file_type,
permissions,
} => {
let mut lines = vec![
Line::styled(&entry.name, self.theme.title.add_modifier(Modifier::BOLD)),
Line::raw(""),
Line::from(vec![
Span::styled("Type: ", self.theme.help_desc),
Span::raw(file_type.clone()),
]),
Line::from(vec![
Span::styled("Size: ", self.theme.help_desc),
Span::raw(crate::ui::format_size(*size)),
Span::styled(
format!(" ({} bytes)", size),
Style::default().fg(self.theme.muted),
),
]),
];
if let Some(perms) = permissions {
lines.push(Line::from(vec![
Span::styled("Permissions: ", self.theme.help_desc),
Span::raw(perms.clone()),
]));
}
if let Some(mtime) = modified
&& let Ok(duration) = mtime.duration_since(std::time::UNIX_EPOCH)
{
let secs = duration.as_secs();
lines.push(Line::from(vec![
Span::styled("Modified: ", self.theme.help_desc),
Span::raw(format_timestamp(secs)),
]));
}
if let Some(ctime) = created
&& let Ok(duration) = ctime.duration_since(std::time::UNIX_EPOCH)
{
let secs = duration.as_secs();
lines.push(Line::from(vec![
Span::styled("Created: ", self.theme.help_desc),
Span::raw(format_timestamp(secs)),
]));
}
if let Some(atime) = accessed
&& let Ok(duration) = atime.duration_since(std::time::UNIX_EPOCH)
{
let secs = duration.as_secs();
lines.push(Line::from(vec![
Span::styled("Accessed: ", self.theme.help_desc),
Span::raw(format_timestamp(secs)),
]));
}
Paragraph::new(lines).render(inner, buf);
}
PreviewContent::Archive {
format,
entry_count,
total_size,
entries,
} => {
let header = Line::from(vec![
Span::styled(&entry.name, self.theme.title.add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(
format!("[{} archive]", format),
Style::default().fg(self.theme.warning),
),
]);
let summary = Line::from(vec![Span::styled(
format!(
"{} entries, {} uncompressed",
entry_count,
crate::ui::format_size(*total_size)
),
Style::default().fg(self.theme.muted),
)]);
let mut display_lines = vec![header, summary, Line::raw("")];
let max_lines = inner.height.saturating_sub(3) as usize;
for archive_entry in entries.iter().take(max_lines) {
let icon = if archive_entry.is_symlink {
"@ "
} else if archive_entry.is_dir {
" "
} else {
" "
};
let size_str = if archive_entry.is_dir || archive_entry.is_symlink {
String::new()
} else {
format!(" ({})", crate::ui::format_size(archive_entry.size))
};
let ratio_str = archive_entry
.compression_ratio
.map(|r| format!(" [{:.0}%]", r * 100.0))
.unwrap_or_default();
let target_str = archive_entry
.link_target
.as_ref()
.map(|t| format!(" -> {}", t))
.unwrap_or_default();
let style = if archive_entry.is_symlink {
self.theme.symlink
} else if archive_entry.is_dir {
self.theme.directory
} else {
self.theme.file
};
display_lines.push(Line::from(vec![
Span::styled(format!("{}{}", icon, archive_entry.path), style),
Span::styled(target_str, self.theme.symlink),
Span::styled(size_str, Style::default().fg(self.theme.muted)),
Span::styled(ratio_str, Style::default().fg(self.theme.success)),
]));
}
if entries.len() > max_lines {
display_lines.push(Line::styled(
format!("... and {} more entries", entry_count - max_lines),
Style::default().fg(self.theme.muted),
));
}
Paragraph::new(display_lines).render(inner, buf);
}
PreviewContent::Error(msg) => {
let lines = vec![
Line::styled(&entry.name, self.theme.title.add_modifier(Modifier::BOLD)),
Line::raw(""),
Line::styled(msg.clone(), Style::default().fg(self.theme.error)),
];
Paragraph::new(lines).render(inner, buf);
}
PreviewContent::Empty => {
self.render_basic_info(inner, buf, entry);
}
}
} else {
self.render_basic_info(inner, buf, entry);
}
}
fn render_directory_placeholder(&self, area: Rect, buf: &mut Buffer, entry: &ColumnEntry) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(self.theme.border)
.title(" Preview ")
.title_style(self.theme.title);
let inner = block.inner(area);
block.render(area, buf);
let lines = vec![
Line::styled(&entry.name, self.theme.title.add_modifier(Modifier::BOLD)),
Line::raw(""),
Line::from(vec![
Span::styled("Type: ", self.theme.help_desc),
Span::raw("Directory"),
]),
Line::raw(""),
Line::styled(
"Press Enter to navigate",
Style::default().fg(self.theme.muted),
),
];
Paragraph::new(lines).render(inner, buf);
}
fn render_basic_info(&self, area: Rect, buf: &mut Buffer, entry: &ColumnEntry) {
let size_str = if entry.is_dir && entry.size == 0 {
"calculating...".to_string()
} else {
format_size(entry.size)
};
let lines = vec![
Line::styled(&entry.name, self.theme.title.add_modifier(Modifier::BOLD)),
Line::raw(""),
Line::from(vec![
Span::styled("Size: ", self.theme.help_desc),
Span::raw(size_str),
]),
Line::from(vec![
Span::styled("Type: ", self.theme.help_desc),
Span::raw(if entry.is_dir {
"Directory"
} else if entry.is_symlink {
"Symbolic Link"
} else if entry.is_executable {
"Executable"
} else {
"File"
}),
]),
];
Paragraph::new(lines).render(area, buf);
}
}
impl StatefulWidget for MillerColumns<'_> {
type State = MillerState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let inner_area = if let Some(block) = &self.block {
let inner = block.inner(area);
block.clone().render(area, buf);
inner
} else {
area
};
if inner_area.height == 0 || inner_area.width == 0 {
return;
}
let [parent_area, current_area, preview_area] = Layout::horizontal([
Constraint::Percentage(25),
Constraint::Percentage(35),
Constraint::Percentage(40),
])
.areas(inner_area);
let current_entries = Self::get_entries(self.current);
let parent_entries = self.parent.map(Self::get_entries).unwrap_or_default();
if state.selected >= current_entries.len() && !current_entries.is_empty() {
state.selected = current_entries.len() - 1;
}
let viewport_height = current_area.height.saturating_sub(2) as usize;
state.ensure_visible(viewport_height);
let parent_base_path = self.view_root.parent().map(|p| p.to_path_buf());
let parent_total_size = self.parent.map(|p| p.size).unwrap_or(0);
let current_total_size = self.current.size;
self.render_column(
parent_area,
buf,
&parent_entries,
None,
Some(self.current_name),
0,
Some("Parent"),
parent_base_path.as_deref(),
parent_total_size,
);
self.render_column(
current_area,
buf,
¤t_entries,
Some(state.selected),
None,
state.offset,
Some("Current"),
Some(self.view_root),
current_total_size,
);
if let Some(selected_entry) = current_entries.get(state.selected) {
if selected_entry.is_dir {
if let Some(child_node) = self
.current
.children
.iter()
.find(|c| c.name.as_str() == selected_entry.name)
{
let preview_entries = Self::get_entries(child_node);
let preview_base_path = self.view_root.join(&selected_entry.name);
let preview_total_size = child_node.size;
self.render_column(
preview_area,
buf,
&preview_entries,
None,
None,
0,
Some("Preview"),
Some(&preview_base_path),
preview_total_size,
);
} else {
self.render_directory_placeholder(preview_area, buf, selected_entry);
}
} else {
self.render_file_preview(preview_area, buf, selected_entry);
}
} else {
let block = Block::default()
.borders(Borders::ALL)
.border_style(self.theme.border)
.title(" Preview ")
.title_style(self.theme.title);
block.render(preview_area, buf);
}
}
}
fn format_timestamp(secs: u64) -> String {
let days_since_epoch = secs / 86400;
let remaining_secs = secs % 86400;
let hours = remaining_secs / 3600;
let minutes = (remaining_secs % 3600) / 60;
let seconds = remaining_secs % 60;
let mut days = days_since_epoch as i64;
let mut year = 1970i32;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if days < days_in_year {
break;
}
days -= days_in_year;
year += 1;
}
let days_in_months: [i64; 12] = if is_leap_year(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u32;
for &dim in &days_in_months {
if days < dim {
break;
}
days -= dim;
month += 1;
}
let day = days + 1;
format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
year, month, day, hours, minutes, seconds
)
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
#[allow(dead_code)]
pub fn get_selected_entry<'a>(state: &MillerState, node: &'a FileNode) -> Option<&'a FileNode> {
node.children.get(state.selected)
}