diff --git a/Cargo.lock b/Cargo.lock index 9e50c2ae0..24e3b388e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -804,6 +804,35 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "jiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3590fea8e9e22d449600c9bbd481a8163bef223e4ff938e5f55899f8cf1adb93" +dependencies = [ + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", + "windows-sys 0.59.0", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -940,6 +969,7 @@ dependencies = [ "git2", "globset", "human-sort", + "jiff", "libc", "lscolors", "once_cell", @@ -1072,6 +1102,21 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "predicates" version = "3.1.0" @@ -1230,18 +1275,28 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.197" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 06aa44b45..e847b51a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ sys-locale = "0.3" once_cell = "1.21" chrono = { version = "0.4", features = ["unstable-locales"] } chrono-humanize = "0.2" +jiff = "0.2" # incompatible with v0.1.11 unicode-width = "0.2" lscolors = "0.20.0" diff --git a/src/color.rs b/src/color.rs index 07a8b095f..165982033 100644 --- a/src/color.rs +++ b/src/color.rs @@ -7,6 +7,7 @@ pub use crate::flags::color::ThemeOption; use crate::git::GitStatus; use crate::print_output; use crate::theme::{Theme, color::ColorTheme}; +use jiff::{Span, SpanTotal, Timestamp, ToSpan, Unit}; #[allow(dead_code)] #[derive(Hash, Debug, Eq, PartialEq, Clone)] @@ -45,9 +46,8 @@ pub enum Elem { System, /// Last Time Modified - DayOld, - HourOld, - Older, + Date(i64), + InvalidDate, /// User / Group Name User, @@ -123,10 +123,6 @@ impl Elem { Elem::Hidden => theme.attributes.hidden, Elem::System => theme.attributes.system, - Elem::DayOld => theme.date.day_old, - Elem::HourOld => theme.date.hour_old, - Elem::Older => theme.date.older, - Elem::User => theme.user, Elem::Group => theme.group, Elem::NonFile => theme.size.none, @@ -169,15 +165,30 @@ impl Elem { Elem::GitStatus { status: GitStatus::Conflicted, } => theme.git_status.conflicted, + Elem::Date(_) | Elem::InvalidDate => { + // These are handled in style_default, not here + Color::Blue + } } } } pub type ColoredString = StyledContent; +/// Unified timestamp-based date color entry +#[derive(Clone, Debug, PartialEq, Eq)] +struct TimestampColorEntry { + timestamp: i64, + color: Color, +} + pub struct Colors { theme: Option, lscolors: Option, + default_date_color: Color, + /// Sorted timestamp table: all entries (legacy, relative, absolute) converted to timestamps + /// Sorted in ascending order (oldest first) + timestamp_colors: Vec, } impl Colors { @@ -209,7 +220,72 @@ impl Colors { _ => None, }; - Self { theme, lscolors } + let (default_date_color, timestamp_colors) = if let Some(t) = &theme { + let now = Timestamp::now().as_second(); + let mut timestamp_entries: Vec = Vec::new(); + + // Convert legacy config to timestamp entries (relative to now) + // Always add hour_old (1 hour threshold) + if let Some(hour_old) = t.date.hour_old { + timestamp_entries.push(TimestampColorEntry { + timestamp: now - 1.hours().total(Unit::Second).unwrap() as i64, + color: hour_old, + }); + } + + // Always add day_old (1 day threshold) + if let Some(day_old) = t.date.day_old { + timestamp_entries.push(TimestampColorEntry { + timestamp: now + - 1.days() + .total(SpanTotal::from(Unit::Second).days_are_24_hours()) + .unwrap() as i64, + color: day_old, + }); + } + + timestamp_entries.push(TimestampColorEntry { + timestamp: i64::MAX, + color: t.date.older, + }); + + // Convert relative config to timestamp entries + for relative in &t.date.relative { + if let Ok(span) = relative.threshold.parse::() { + if let Ok(total_seconds) = span.total(Unit::Second) { + let timestamp = now - total_seconds as i64; + timestamp_entries.push(TimestampColorEntry { + timestamp, + color: relative.color, + }); + } + } + } + + // Convert absolute config to timestamp entries + for absolute in &t.date.absolute { + if let Ok(threshold) = absolute.threshold.parse::() { + timestamp_entries.push(TimestampColorEntry { + timestamp: threshold.as_second(), + color: absolute.color, + }); + } + } + + // Sort by timestamp (ascending order - oldest first) + timestamp_entries.sort_by_key(|e| e.timestamp); + + (t.date.older, timestamp_entries) + } else { + (Color::Blue, Vec::new()) + }; + + Self { + theme, + lscolors, + default_date_color, + timestamp_colors, + } } pub fn colorize>(&self, input: S, elem: &Elem) -> ColoredString { @@ -250,7 +326,45 @@ impl Colors { fn style_default(&self, elem: &Elem) -> ContentStyle { if let Some(t) = &self.theme { - let style_fg = ContentStyle::default().with(elem.get_color(t)); + let color = match elem { + Elem::Date(timestamp) => { + // Iterate through sorted timestamp table (ascending order - oldest first) + // Find the color for the most specific (highest) threshold that the file is older than + // If file is older than all thresholds, use the first (oldest) threshold's color + // If file is newer than all thresholds, use default color + let mut color = self.default_date_color; + let mut found_threshold = false; + + for entry in &self.timestamp_colors { + if *timestamp >= entry.timestamp { + // File is newer than or equal to this threshold, use its color + color = entry.color; + found_threshold = true; + } else { + // File is older than this threshold, stop searching + break; + } + } + + // If no threshold was found (file is older than all thresholds), + // use the oldest (first) threshold's color + if !found_threshold && !self.timestamp_colors.is_empty() { + color = self.timestamp_colors.first().unwrap().color; + } + + color + } + Elem::InvalidDate => { + // For invalid dates, use the oldest color if available, otherwise default + self.timestamp_colors + .first() + .map(|e| e.color) + .unwrap_or(self.default_date_color) + } + _ => elem.get_color(t), + }; + + let style_fg = ContentStyle::default().with(color); if elem.has_suid() { style_fg.on(Color::AnsiValue(124)) // Red3 } else { @@ -439,9 +553,11 @@ mod elem { special: Color::AnsiValue(44), // DarkTurquoise }, date: color::Date { - hour_old: Color::AnsiValue(40), // Green3 - day_old: Color::AnsiValue(42), // SpringGreen2 - older: Color::AnsiValue(36), // DarkCyan + hour_old: Some(Color::AnsiValue(40)), // Green3 + day_old: Some(Color::AnsiValue(42)), // SpringGreen2 + older: Color::AnsiValue(36), // DarkCyan + relative: Vec::new(), + absolute: Vec::new(), }, size: color::Size { none: Color::AnsiValue(245), // Grey diff --git a/src/meta/date.rs b/src/meta/date.rs index 007023c35..eb65f89d0 100644 --- a/src/meta/date.rs +++ b/src/meta/date.rs @@ -33,16 +33,14 @@ impl From<&Metadata> for Date { impl Date { pub fn render(&self, colors: &Colors, flags: &Flags) -> ColoredString { - let now = Local::now(); - #[allow(deprecated)] + let date_string = self.date_string(flags); let elem = match self { - &Date::Date(modified) if modified > now - Duration::hours(1) => Elem::HourOld, - &Date::Date(modified) if modified > now - Duration::days(1) => Elem::DayOld, - &Date::Date(_) | Date::Invalid => Elem::Older, + Self::Date(modified) => Elem::Date(modified.timestamp()), + Self::Invalid => Elem::InvalidDate, }; - colors.colorize(self.date_string(flags), &elem) - } + colors.colorize(date_string, &elem) + } fn date_string(&self, flags: &Flags) -> String { let locale = current_locale(); diff --git a/src/theme/color.rs b/src/theme/color.rs index 1632d24fa..33f50bffc 100644 --- a/src/theme/color.rs +++ b/src/theme/color.rs @@ -4,6 +4,13 @@ use crossterm::style::Color; use serde::{Deserialize, de::IntoDeserializer}; use std::fmt; +fn deserialize_option_color<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + Option::::deserialize(deserializer) +} + // Custom color deserialize fn deserialize_color<'de, D>(deserializer: D) -> Result where @@ -202,14 +209,47 @@ pub struct Symlink { #[derive(Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] #[serde(deny_unknown_fields)] -#[serde(default)] -pub struct Date { +pub struct RelativeTimeColor { + pub threshold: String, + #[serde(deserialize_with = "deserialize_color")] + pub color: Color, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +pub struct AbsoluteTimeColor { + pub threshold: String, #[serde(deserialize_with = "deserialize_color")] - pub hour_old: Color, + pub color: Color, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +pub struct DateColorEntry { + pub threshold: String, #[serde(deserialize_with = "deserialize_color")] - pub day_old: Color, + pub color: Color, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +#[serde(default)] +pub struct Date { + // Legacy fields for backward compatibility + #[serde(deserialize_with = "deserialize_option_color")] + pub hour_old: Option, + #[serde(deserialize_with = "deserialize_option_color")] + pub day_old: Option, + #[serde(deserialize_with = "deserialize_color")] pub older: Color, + #[serde(default)] + pub relative: Vec, + #[serde(default)] + pub absolute: Vec, } #[derive(Debug, Deserialize, PartialEq, Eq)] @@ -344,9 +384,20 @@ impl Default for Symlink { impl Default for Date { fn default() -> Self { Date { - hour_old: Color::AnsiValue(40), // Green3 - day_old: Color::AnsiValue(42), // SpringGreen2 - older: Color::AnsiValue(36), // DarkCyan + hour_old: None, + day_old: None, + older: Color::AnsiValue(36), // DarkCyan + relative: vec![ + RelativeTimeColor { + threshold: "1h".into(), + color: Color::AnsiValue(40), // Green3 + }, + RelativeTimeColor { + threshold: "1d".into(), + color: Color::AnsiValue(42), // SpringGreen2 + }, + ], + absolute: Vec::new(), } } }