🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
//! Model katalogu: wpisy, sortowanie, filtrowanie i formatowanie.
//!
//! Warstwa niezależna od UI — da się ją testować bez okna i bez Waylanda.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use libluna_i18n::tr;
use libluna_widgets::{Item, ItemKind};
/// Klucz sortowania listy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
Name,
Size,
Modified,
Kind,
}
impl SortKey {
pub fn label(self) -> String {
tr(match self {
SortKey::Name => "Name",
SortKey::Size => "Size",
SortKey::Modified => "Modified",
SortKey::Kind => "Type",
})
}
pub fn next(self) -> Self {
match self {
SortKey::Name => SortKey::Size,
SortKey::Size => SortKey::Modified,
SortKey::Modified => SortKey::Kind,
SortKey::Kind => SortKey::Name,
}
}
}
/// Rodzaj pliku (na podstawie rozszerzenia) — do ikon, podpisów i kolumny „Typ”.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Folder,
Image,
Audio,
Video,
Archive,
Document,
Code,
Text,
Other,
}
impl Kind {
pub fn label(self) -> String {
tr(match self {
Kind::Folder => "Folder",
Kind::Image => "Image",
Kind::Audio => "Audio",
Kind::Video => "Video",
Kind::Archive => "Archive",
Kind::Document => "Document",
Kind::Code => "Code",
Kind::Text => "Text file",
Kind::Other => "File",
})
}
pub fn widget_kind(self) -> ItemKind {
match self {
Kind::Folder => ItemKind::Folder,
Kind::Image => ItemKind::Image,
Kind::Audio => ItemKind::Audio,
Kind::Video => ItemKind::Video,
Kind::Archive => ItemKind::Archive,
Kind::Document => ItemKind::Document,
Kind::Code => ItemKind::Code,
Kind::Text => ItemKind::Text,
Kind::Other => ItemKind::File,
}
}
fn from_name(name: &str, is_dir: bool) -> Self {
if is_dir {
return Kind::Folder;
}
let ext = Path::new(name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "svg" | "ico" | "tiff" | "avif"
| "heic" => Kind::Image,
"mp3" | "flac" | "ogg" | "oga" | "wav" | "opus" | "m4a" | "aac" | "wma" => Kind::Audio,
"mp4" | "mkv" | "webm" | "avi" | "mov" | "m4v" | "wmv" | "flv" => Kind::Video,
"zip" | "tar" | "gz" | "tgz" | "bz2" | "xz" | "zst" | "7z" | "rar" | "deb" | "rpm"
| "iso" => Kind::Archive,
"pdf" | "odt" | "ods" | "odp" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
| "epub" | "rtf" => Kind::Document,
"rs" | "c" | "h" | "cpp" | "hpp" | "py" | "js" | "ts" | "tsx" | "jsx" | "go"
| "java" | "sh" | "toml" | "yaml" | "yml" | "json" | "xml" | "css" | "html" | "lua"
| "rb" | "php" | "cs" | "kt" | "swift" => Kind::Code,
"txt" | "md" | "log" | "conf" | "ini" | "cfg" | "csv" | "lang" => Kind::Text,
_ => Kind::Other,
}
}
}
/// Pojedynczy wpis w katalogu.
#[derive(Debug, Clone)]
pub struct Entry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
pub size: u64,
/// Czas modyfikacji (sekundy od epoki).
pub modified: Option<i64>,
pub hidden: bool,
pub symlink: bool,
pub kind: Kind,
}
impl Entry {
pub fn detail(&self) -> String {
if self.is_dir {
Kind::Folder.label()
} else {
human_size(self.size)
}
}
pub fn item(&self) -> Item {
Item::new(self.name.clone())
.detail(self.detail())
.kind(self.kind.widget_kind())
}
}
/// Zawartość katalogu + ustawienia prezentacji.
#[derive(Debug, Clone)]
pub struct DirView {
pub path: PathBuf,
pub entries: Vec<Entry>,
sort: SortKey,
ascending: bool,
show_hidden: bool,
filter: String,
}
impl DirView {
/// Wczytuje katalog (bez podążania za `..`).
pub fn load(path: impl Into<PathBuf>) -> io::Result<Self> {
let path = path.into();
let entries = read_entries(&path)?;
let mut view = Self {
path,
entries,
sort: SortKey::Name,
ascending: true,
show_hidden: false,
filter: String::new(),
};
view.resort();
Ok(view)
}
pub fn reload(&mut self) -> io::Result<()> {
self.entries = read_entries(&self.path)?;
self.resort();
Ok(())
}
pub fn sort_key(&self) -> SortKey {
self.sort
}
pub fn set_sort_key(&mut self, key: SortKey) {
self.sort = key;
self.resort();
}
pub fn ascending(&self) -> bool {
self.ascending
}
pub fn set_ascending(&mut self, ascending: bool) {
self.ascending = ascending;
self.resort();
}
pub fn show_hidden(&self) -> bool {
self.show_hidden
}
pub fn set_show_hidden(&mut self, show: bool) {
self.show_hidden = show;
}
pub fn filter(&self) -> &str {
&self.filter
}
pub fn set_filter(&mut self, filter: impl Into<String>) {
self.filter = filter.into().to_lowercase();
}
pub fn title(&self) -> String {
self.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| self.path.display().to_string())
}
pub fn parent(&self) -> Option<PathBuf> {
self.path.parent().map(Path::to_path_buf)
}
/// Indeksy wpisów widocznych po filtrze i ukrywaniu.
pub fn visible(&self) -> Vec<usize> {
self.entries
.iter()
.enumerate()
.filter(|(_, e)| (self.show_hidden || !e.hidden) && self.matches(e))
.map(|(i, _)| i)
.collect()
}
fn matches(&self, e: &Entry) -> bool {
self.filter.is_empty() || e.name.to_lowercase().contains(&self.filter)
}
/// Lista do `ItemView` (kolejność = `visible()`).
pub fn items(&self) -> Vec<Item> {
self.visible()
.into_iter()
.map(|i| self.entries[i].item())
.collect()
}
/// Widoczne wpisy w kolejności prezentacji.
pub fn visible_entries(&self) -> Vec<&Entry> {
self.visible()
.into_iter()
.filter_map(|i| self.entries.get(i))
.collect()
}
pub fn entry(&self, visible_index: usize) -> Option<&Entry> {
let i = *self.visible().get(visible_index)?;
self.entries.get(i)
}
fn resort(&mut self) {
let key = self.sort;
let asc = self.ascending;
self.entries.sort_by(|a, b| {
// Katalogi zawsze przed plikami (też przy sortowaniu malejąco).
b.is_dir.cmp(&a.is_dir).then_with(|| {
let ord = match key {
SortKey::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
SortKey::Size => a.size.cmp(&b.size),
SortKey::Modified => a.modified.unwrap_or(0).cmp(&b.modified.unwrap_or(0)),
SortKey::Kind => a
.kind
.label()
.cmp(&b.kind.label())
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
};
if asc {
ord
} else {
ord.reverse()
}
})
});
}
}
fn read_entries(path: &Path) -> io::Result<Vec<Entry>> {
let mut out = Vec::new();
for entry in fs::read_dir(path)? {
let Ok(entry) = entry else { continue };
let name = entry.file_name().to_string_lossy().into_owned();
let full = entry.path();
let file_type = entry.file_type().ok();
let symlink = file_type.map(|t| t.is_symlink()).unwrap_or(false);
// Dla symlinków idziemy za celem (katalog-link ma być katalogiem).
let meta = if symlink {
fs::metadata(&full).ok()
} else {
entry.metadata().ok()
};
let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
let size = meta
.as_ref()
.map(|m| if m.is_dir() { 0 } else { m.len() })
.unwrap_or(0);
let modified = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
out.push(Entry {
hidden: name.starts_with('.'),
kind: Kind::from_name(&name, is_dir),
name,
path: full,
is_dir,
size,
modified,
symlink,
});
}
Ok(out)
}
/// Rozmiar w jednostkach binarnych (np. `1,4 MiB`).
pub fn human_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else if value < 10.0 {
format!("{value:.1} {}", UNITS[unit])
} else {
format!("{value:.0} {}", UNITS[unit])
}
}
/// Data i godzina według czasu lokalnego (`YYYY-MM-DD HH:MM`).
pub fn format_mtime(secs: Option<i64>) -> String {
let Some(secs) = secs else {
return String::new();
};
let (y, m, d, h, mi, _) = local_time(secs);
format!("{y:04}-{m:02}-{d:02} {h:02}:{mi:02}")
}
/// Format `DeletionDate` z XDG Trash Spec (`YYYY-MM-DDThh:mm:ss`), czas lokalny.
pub fn trash_date(secs: i64) -> String {
let (y, m, d, h, mi, s) = local_time(secs);
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}")
}
/// Bieżący czas w sekundach od epoki.
pub fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Rozbicie czasu na pola lokalne (rok, miesiąc, dzień, godzina, minuta, sekunda).
fn local_time(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
// SAFETY: `localtime_r` wypełnia strukturę `tm` i nie alokuje; przekazujemy
// poprawny wskaźnik na `time_t`.
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
let t = secs as libc::time_t;
let ok = unsafe { !libc::localtime_r(&t, &mut tm).is_null() };
if !ok {
return (1970, 1, 1, 0, 0, 0);
}
(
tm.tm_year + 1900,
(tm.tm_mon + 1) as u32,
tm.tm_mday as u32,
tm.tm_hour as u32,
tm.tm_min as u32,
tm.tm_sec as u32,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn human_size_scales_binary_units() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(999), "999 B");
assert_eq!(human_size(1024), "1.0 KiB");
assert_eq!(human_size(1536), "1.5 KiB");
assert_eq!(human_size(10 * 1024), "10 KiB");
assert_eq!(human_size(5 * 1024 * 1024), "5.0 MiB");
}
#[test]
fn kind_is_derived_from_extension() {
assert_eq!(Kind::from_name("a.png", false), Kind::Image);
assert_eq!(Kind::from_name("a.MP4", false), Kind::Video);
assert_eq!(Kind::from_name("a.tar.gz", false), Kind::Archive);
assert_eq!(Kind::from_name("a.rs", false), Kind::Code);
assert_eq!(Kind::from_name("a.unknownext", false), Kind::Other);
assert_eq!(Kind::from_name("katalog", true), Kind::Folder);
}
fn view_with(names: &[(&str, bool)]) -> DirView {
let mut entries = Vec::new();
for (name, is_dir) in names {
entries.push(Entry {
name: (*name).to_owned(),
path: PathBuf::from("/tmp").join(name),
is_dir: *is_dir,
size: name.len() as u64,
modified: None,
hidden: name.starts_with('.'),
symlink: false,
kind: Kind::from_name(name, *is_dir),
});
}
DirView {
path: PathBuf::from("/tmp"),
entries,
sort: SortKey::Name,
ascending: true,
show_hidden: false,
filter: String::new(),
}
}
#[test]
fn directories_sort_first_and_hidden_are_filtered() {
let mut v = view_with(&[
("b.txt", false),
("a", true),
(".hidden", false),
("c", true),
]);
v.resort();
let names: Vec<&str> = v
.visible()
.into_iter()
.map(|i| v.entries[i].name.as_str())
.collect();
assert_eq!(names, vec!["a", "c", "b.txt"]);
v.set_show_hidden(true);
assert_eq!(v.visible().len(), 4);
}
#[test]
fn filter_matches_case_insensitively() {
let mut v = view_with(&[("Raport.pdf", false), ("notatki.txt", false)]);
v.set_filter("RAP");
let names: Vec<String> = v
.visible()
.into_iter()
.map(|i| v.entries[i].name.clone())
.collect();
assert_eq!(names, vec!["Raport.pdf"]);
}
#[test]
fn descending_keeps_directories_first() {
let mut v = view_with(&[("b.txt", false), ("a.txt", false), ("dir", true)]);
v.resort();
v.set_ascending(false);
let names: Vec<&str> = v
.visible()
.into_iter()
.map(|i| v.entries[i].name.as_str())
.collect();
assert_eq!(names, vec!["dir", "b.txt", "a.txt"]);
}
}