Skip to main content

binocular/preview/
directory.rs

1use crate::preview::doc::format_unix_timestamp;
2use ratatui::style::{Color, Modifier, Style};
3use ratatui::text::{Line, Span, Text};
4use std::fs;
5use std::path::Path;
6use std::time::UNIX_EPOCH;
7
8pub fn generate_preview(path: &Path) -> Text<'static> {
9    let mut lines: Vec<Line<'static>> = Vec::new();
10
11    let abs_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
12    lines.push(Line::from(vec![Span::styled(
13        format!("Directory: {}", abs_path.display()),
14        Style::default()
15            .fg(Color::Cyan)
16            .add_modifier(Modifier::BOLD),
17    )]));
18    lines.push(Line::from(""));
19
20    let entries = match collect_entries(path) {
21        Ok(e) => e,
22        Err(err) => {
23            lines.push(Line::from(vec![Span::styled(
24                format!("Error reading directory: {}", err),
25                Style::default().fg(Color::Red),
26            )]));
27            return Text::from(lines);
28        }
29    };
30
31    let dir_count = entries.iter().filter(|e| e.kind == EntryKind::Dir).count();
32    let file_count = entries.iter().filter(|e| e.kind == EntryKind::File).count();
33    let link_count = entries
34        .iter()
35        .filter(|e| e.kind == EntryKind::Symlink)
36        .count();
37
38    let summary = build_summary(dir_count, file_count, link_count);
39    lines.push(Line::from(vec![Span::styled(
40        summary,
41        Style::default().fg(Color::DarkGray),
42    )]));
43    lines.push(Line::from(""));
44
45    lines.push(Line::from(vec![Span::styled(
46        format!(
47            " {:<10}  {:>8}  {:<16}  {}",
48            "Permissions", "Size", "Modified", "Name"
49        ),
50        Style::default()
51            .fg(Color::DarkGray)
52            .add_modifier(Modifier::BOLD),
53    )]));
54    lines.push(Line::from(vec![Span::styled(
55        format!(" {}", "─".repeat(60)),
56        Style::default().fg(Color::DarkGray),
57    )]));
58
59    for entry in &entries {
60        lines.push(render_entry(entry));
61    }
62
63    Text::from(lines)
64}
65
66fn build_summary(dirs: usize, files: usize, links: usize) -> String {
67    let mut parts = Vec::new();
68    if dirs > 0 {
69        parts.push(format!(
70            "{} {}",
71            dirs,
72            if dirs == 1 {
73                "directory"
74            } else {
75                "directories"
76            }
77        ));
78    }
79    if files > 0 {
80        parts.push(format!(
81            "{} {}",
82            files,
83            if files == 1 { "file" } else { "files" }
84        ));
85    }
86    if links > 0 {
87        parts.push(format!(
88            "{} {}",
89            links,
90            if links == 1 { "symlink" } else { "symlinks" }
91        ));
92    }
93    if parts.is_empty() {
94        "  empty".to_string()
95    } else {
96        format!("  {}", parts.join(", "))
97    }
98}
99
100#[derive(PartialEq, Eq)]
101enum EntryKind {
102    Dir,
103    File,
104    Symlink,
105}
106
107struct DirEntry {
108    name: String,
109    kind: EntryKind,
110    size: Option<u64>,
111    mtime: Option<u64>,
112    permissions: String,
113    link_target: Option<String>,
114}
115
116fn collect_entries(path: &Path) -> std::io::Result<Vec<DirEntry>> {
117    let mut entries: Vec<DirEntry> = Vec::new();
118
119    for result in fs::read_dir(path)? {
120        let dir_entry = result?;
121        let name = dir_entry.file_name().to_string_lossy().into_owned();
122        let meta = dir_entry.path().symlink_metadata()?;
123        let file_type = meta.file_type();
124
125        let kind = if file_type.is_symlink() {
126            EntryKind::Symlink
127        } else if file_type.is_dir() {
128            EntryKind::Dir
129        } else {
130            EntryKind::File
131        };
132
133        let size = if file_type.is_dir() {
134            None
135        } else {
136            Some(meta.len())
137        };
138
139        let mtime = meta
140            .modified()
141            .ok()
142            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
143            .map(|d| d.as_secs());
144
145        let permissions = format_permissions(&meta);
146
147        let link_target = if file_type.is_symlink() {
148            fs::read_link(dir_entry.path())
149                .ok()
150                .map(|t| t.to_string_lossy().into_owned())
151        } else {
152            None
153        };
154
155        entries.push(DirEntry {
156            name,
157            kind,
158            size,
159            mtime,
160            permissions,
161            link_target,
162        });
163    }
164
165    entries.sort_by(|a, b| {
166        let order = |k: &EntryKind| match k {
167            EntryKind::Dir => 0,
168            EntryKind::Symlink => 1,
169            EntryKind::File => 2,
170        };
171        order(&a.kind)
172            .cmp(&order(&b.kind))
173            .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
174    });
175
176    Ok(entries)
177}
178
179fn render_entry(entry: &DirEntry) -> Line<'static> {
180    let perm_str = format!(" {:<10}  ", entry.permissions);
181    let size_str = match entry.size {
182        Some(s) => format!("{:>8}  ", format_size_short(s)),
183        None => format!("{:>8}  ", "-"),
184    };
185    let mtime_str = match entry.mtime {
186        Some(t) => format!("{:<16}  ", format_unix_timestamp(t)),
187        None => format!("{:<16}  ", "Unknown"),
188    };
189    let name_str = match (&entry.kind, &entry.link_target) {
190        (EntryKind::Dir, _) => format!("{}/", entry.name),
191        (EntryKind::Symlink, Some(target)) => format!("{} -> {}", entry.name, target),
192        (EntryKind::Symlink, None) => format!("{}@", entry.name),
193        (EntryKind::File, _) => entry.name.clone(),
194    };
195
196    let name_color = match entry.kind {
197        EntryKind::Dir => Color::Cyan,
198        EntryKind::Symlink => Color::Magenta,
199        EntryKind::File => name_color_for_file(&entry.name, &entry.permissions),
200    };
201
202    Line::from(vec![
203        Span::styled(perm_str, Style::default().fg(Color::DarkGray)),
204        Span::styled(size_str, Style::default().fg(Color::Yellow)),
205        Span::styled(mtime_str, Style::default().fg(Color::DarkGray)),
206        Span::styled(name_str, Style::default().fg(name_color)),
207    ])
208}
209
210fn format_size_short(size: u64) -> String {
211    const KB: u64 = 1024;
212    const MB: u64 = KB * 1024;
213    const GB: u64 = MB * 1024;
214    if size >= GB {
215        format!("{:.1}G", size as f64 / GB as f64)
216    } else if size >= MB {
217        format!("{:.1}M", size as f64 / MB as f64)
218    } else if size >= KB {
219        format!("{:.1}K", size as f64 / KB as f64)
220    } else {
221        format!("{}", size)
222    }
223}
224
225#[cfg(unix)]
226fn format_permissions(meta: &fs::Metadata) -> String {
227    use std::os::unix::fs::PermissionsExt;
228    let mode = meta.permissions().mode();
229    let ft = meta.file_type();
230    let type_char = if ft.is_symlink() {
231        'l'
232    } else if ft.is_dir() {
233        'd'
234    } else {
235        '-'
236    };
237    let bits = [
238        (0o400, 'r'),
239        (0o200, 'w'),
240        (0o100, 'x'),
241        (0o040, 'r'),
242        (0o020, 'w'),
243        (0o010, 'x'),
244        (0o004, 'r'),
245        (0o002, 'w'),
246        (0o001, 'x'),
247    ];
248    let mut s = String::with_capacity(10);
249    s.push(type_char);
250    for &(bit, ch) in &bits {
251        s.push(if mode & bit != 0 { ch } else { '-' });
252    }
253    s
254}
255
256#[cfg(not(unix))]
257fn format_permissions(meta: &fs::Metadata) -> String {
258    let ft = meta.file_type();
259    let type_char = if ft.is_symlink() {
260        'l'
261    } else if ft.is_dir() {
262        'd'
263    } else {
264        '-'
265    };
266    // Windows: only read-only flag is meaningful.
267    let rw = if meta.permissions().readonly() {
268        "r--r--r--"
269    } else {
270        "rw-rw-rw-"
271    };
272    format!("{}{}", type_char, rw)
273}
274
275/// On Unix, color executable files green. Otherwise white.
276fn name_color_for_file(name: &str, permissions: &str) -> Color {
277    let _ = name;
278    #[cfg(unix)]
279    {
280        // permissions[3] is the owner-execute bit position (index 3 in "drwxr-xr-x").
281        if permissions.as_bytes().get(3).copied() == Some(b'x') {
282            return Color::Green;
283        }
284    }
285    let _ = permissions;
286    Color::White
287}