use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use std::collections::HashSet;
use std::io;
use syntect::easy::HighlightLines;
use syntect::util::LinesWithEndings;
use syntect_assets::assets::HighlightingAssets;
use ratatui::text::Line;
fn highlight_content(content: &str, path: &str) -> Vec<Line<'static>> {
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
let assets = HighlightingAssets::from_binary();
let ss = assets.get_syntax_set().unwrap();
let theme = assets.get_theme("base16-ocean.dark");
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let syntax = ss
.find_syntax_by_extension(ext)
.unwrap_or_else(|| ss.find_syntax_plain_text());
let mut highlighter = HighlightLines::new(syntax, theme);
fn brighten(c: u8) -> u8 {
((c as u16 + 40).min(255)) as u8
}
let mut lines = Vec::new();
for (i, line) in LinesWithEndings::from(content).enumerate() {
let ranges = highlighter.highlight_line(line, ss).unwrap_or_default();
let mut spans = vec![Span::styled(
format!("{:>4} ", i + 1),
Style::default().fg(Color::DarkGray),
)];
for (style, text) in &ranges {
let fg = style.foreground;
spans.push(Span::styled(
text.trim_end_matches('\n').to_string(),
Style::default().fg(Color::Rgb(brighten(fg.r), brighten(fg.g), brighten(fg.b))),
));
}
lines.push(Line::from(spans));
}
lines
}
#[derive(Clone)]
pub struct TreeItem {
pub path: String,
pub name: String,
pub depth: usize,
pub is_dir: bool,
}
pub enum ViewerMode {
List,
File {
path: String,
scroll: usize,
},
}
pub struct Viewer {
pub files: Vec<(String, String)>,
pub tree: Vec<TreeItem>,
pub collapsed: HashSet<String>,
pub selected: usize,
pub mode: ViewerMode,
pub filename: String,
pub codec: String,
}
impl Viewer {
#[must_use]
pub fn new(files: Vec<(String, String)>, filename: String, codec: String) -> Self {
let mut viewer = Self {
files,
tree: Vec::new(),
collapsed: HashSet::new(),
selected: 0,
mode: ViewerMode::List,
filename,
codec,
};
for (path, _) in &viewer.files {
let parts: Vec<&str> = path.split('/').collect();
for i in 1..parts.len() {
viewer.collapsed.insert(parts[..i].join("/"));
}
}
viewer.rebuild_tree();
viewer
}
pub fn rebuild_tree(&mut self) {
self.tree.clear();
let mut dirs: Vec<String> = Vec::new();
for (path, _) in &self.files {
let parts: Vec<&str> = path.split('/').collect();
for i in 1..parts.len() {
let dir = parts[..i].join("/");
if !dirs.contains(&dir) {
dirs.push(dir);
}
}
}
let mut seen: HashSet<String> = HashSet::new();
let mut entries: Vec<TreeItem> = Vec::new();
self.add_entries("", 0, &mut seen, &mut entries);
self.tree = entries;
}
fn add_entries(
&self,
prefix: &str,
depth: usize,
seen: &mut HashSet<String>,
entries: &mut Vec<TreeItem>,
) {
let mut dirs_at_level: Vec<String> = Vec::new();
let mut files_at_level: Vec<String> = Vec::new();
for (path, _) in &self.files {
let relative = if prefix.is_empty() {
path.as_str()
} else if path.starts_with(&format!("{prefix}/")) {
&path[prefix.len() + 1..]
} else {
continue;
};
let parts: Vec<&str> = relative.splitn(2, '/').collect();
if parts.len() == 1 {
let full = if prefix.is_empty() {
path.clone()
} else {
format!("{}/{}", prefix, parts[0])
};
if !seen.contains(&full) {
seen.insert(full.clone());
files_at_level.push(full);
}
} else {
let dir = if prefix.is_empty() {
parts[0].to_string()
} else {
format!("{}/{}", prefix, parts[0])
};
if !seen.contains(&dir) {
seen.insert(dir.clone());
dirs_at_level.push(dir);
}
}
}
for dir in &dirs_at_level {
let name = dir.split('/').next_back().unwrap_or(dir).to_string();
let _collapsed = self.collapsed.contains(dir);
entries.push(TreeItem {
path: dir.clone(),
name: format!("{name}/"),
depth,
is_dir: true,
});
if !self.collapsed.contains(dir) {
self.add_entries(dir, depth + 1, seen, entries);
}
}
for file in &files_at_level {
let name = file.split('/').next_back().unwrap_or(file).to_string();
entries.push(TreeItem {
path: file.clone(),
name,
depth,
is_dir: false,
});
}
}
pub fn run(mut self) -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = self.event_loop(&mut terminal);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
result
}
fn event_loop(
&mut self,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> io::Result<()> {
loop {
terminal.draw(|f| self.draw(f))?;
if let Event::Key(key) = event::read()? {
match &self.mode {
ViewerMode::List => match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Esc => return Ok(()),
KeyCode::Up | KeyCode::Char('k') => {
if self.selected > 0 {
self.selected -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected < self.tree.len() - 1 {
self.selected += 1;
}
}
KeyCode::Enter => {
let item = self.tree[self.selected].clone();
if item.is_dir {
if self.collapsed.contains(&item.path) {
self.collapsed.remove(&item.path);
} else {
self.collapsed.insert(item.path.clone());
}
self.rebuild_tree();
if self.selected >= self.tree.len() {
self.selected = self.tree.len() - 1;
}
} else {
self.mode = ViewerMode::File {
path: item.path.clone(),
scroll: 0,
};
}
}
_ => {}
},
ViewerMode::File { path: _, scroll } => {
let scroll = *scroll;
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
self.mode = ViewerMode::List;
}
KeyCode::Up | KeyCode::Char('k') => {
if scroll > 0 {
let path = match &self.mode {
ViewerMode::File { path, .. } => path.clone(),
_ => unreachable!(),
};
self.mode = ViewerMode::File {
path,
scroll: scroll - 1,
};
}
}
KeyCode::Down | KeyCode::Char('j') => {
let path = match &self.mode {
ViewerMode::File { path, .. } => path.clone(),
_ => unreachable!(),
};
self.mode = ViewerMode::File {
path,
scroll: scroll + 1,
};
}
_ => {}
}
}
}
}
}
}
fn draw(&self, f: &mut ratatui::Frame) {
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
};
let area = f.area();
match &self.mode {
ViewerMode::List => {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
let header_text = format!(
" {} · {} · {} files",
self.filename,
self.codec,
self.files.len()
);
let header = Paragraph::new(header_text)
.style(
Style::default()
.fg(Color::Rgb(0, 210, 180))
.add_modifier(Modifier::BOLD),
)
.block(Block::default().borders(Borders::TOP | Borders::BOTTOM));
f.render_widget(header, chunks[0]);
let has_preview = area.width >= 120;
let content_chunks = if has_preview {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(chunks[1])
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(100)])
.split(chunks[1])
};
let list_area = content_chunks[0];
let max_name_width = (list_area.width as usize).saturating_sub(10);
let items: Vec<ListItem> = self
.tree
.iter()
.map(|item| {
let indent = " ".repeat(item.depth + 1);
let icon = if item.is_dir { " " } else { " " };
let style = if item.is_dir {
Style::default()
.fg(Color::Rgb(0, 210, 180))
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let name = if item.name.len() > max_name_width {
format!("{}…", &item.name[..max_name_width.saturating_sub(1)])
} else {
item.name.clone()
};
let label = format!("{}{}{}", indent, icon, name);
let size = if !item.is_dir {
let content_len = self
.files
.iter()
.find(|(p, _)| p == &item.path)
.map(|(_, c)| c.len())
.unwrap_or(0);
format!("{:.1}KB ", content_len as f64 / 1024.0)
} else {
let count = self
.files
.iter()
.filter(|(p, _)| p.starts_with(&format!("{}/", item.path)))
.count();
format!("{} files ", count)
};
let padding =
(list_area.width as usize).saturating_sub(label.len() + size.len() + 2);
let line = Line::from(vec![
Span::styled(label, style),
Span::raw(" ".repeat(padding)),
Span::styled(size, Style::default().fg(Color::DarkGray)),
]);
ListItem::new(line)
})
.collect();
let mut state = ListState::default();
state.select(Some(self.selected));
let list = List::new(items)
.highlight_style(
Style::default()
.fg(Color::Rgb(0, 210, 180))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("❯ ");
f.render_stateful_widget(list, list_area, &mut state);
if has_preview {
let preview_area = content_chunks[1];
let preview_content = if self.selected < self.tree.len() {
let item = &self.tree[self.selected];
if !item.is_dir {
self.files
.iter()
.find(|(p, _)| p == &item.path)
.map(|(_, c)| c.as_str())
.unwrap_or("")
} else {
""
}
} else {
""
};
let lines = if preview_content.is_empty() {
vec![]
} else {
highlight_content(preview_content, &self.tree[self.selected].path)
};
let preview =
Paragraph::new(lines).block(Block::default().borders(Borders::LEFT));
f.render_widget(preview, preview_area);
}
let footer =
Paragraph::new(" ↑↓ / jk navigate · ENTER open/collapse · ESC/q quit")
.style(Style::default().fg(Color::DarkGray));
f.render_widget(footer, chunks[2]);
}
ViewerMode::File { path, scroll } => {
let content = self
.files
.iter()
.find(|(p, _)| p == path)
.map_or("", |(_, c)| c.as_str());
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
let header = Paragraph::new(format!(" {path}"))
.style(
Style::default()
.fg(Color::Rgb(0, 210, 180))
.add_modifier(Modifier::BOLD),
)
.block(Block::default().borders(Borders::TOP | Borders::BOTTOM));
f.render_widget(header, chunks[0]);
let lines = highlight_content(content, path);
let content_widget = Paragraph::new(lines).scroll((*scroll as u16, 0));
f.render_widget(content_widget, chunks[1]);
let footer = Paragraph::new(" ↑↓ / jk scroll · q / ESC back")
.style(Style::default().fg(Color::DarkGray));
f.render_widget(footer, chunks[2]);
}
}
}
}