diff --git a/README.md b/README.md index fe1791b8c..61a4c4c1f 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ These options are available when running with `--long` (`-l`): - **-X**, **--dereference**: dereference symlinks for file information - **-Z**, **--context**: list each file’s security context - **-@**, **--extended**: list each file’s extended attributes and sizes +- **-e**, **--tags**: list each file's color tags stored in extended attributes - **--changed**: use the changed timestamp field - **--git**: list each file’s Git status, if tracked or ignored - **--git-repos**: list each directory’s Git status, if tracked diff --git a/completions/fish/eza.fish b/completions/fish/eza.fish index e3488239f..ae617f934 100644 --- a/completions/fish/eza.fish +++ b/completions/fish/eza.fish @@ -128,4 +128,5 @@ complete -c eza -l no-git -d "Suppress Git status" complete -c eza -l git-repos -d "List each git-repos status and branch name" complete -c eza -l git-repos-no-status -d "List each git-repos branch name (much faster)" complete -c eza -s '@' -l extended -d "List each file's extended attributes and sizes" +complete -c eza -s e -l tags -d "List each file's color tags stored in extended attributes" complete -c eza -s Z -l context -d "List each file's security context" diff --git a/completions/nush/eza.nu b/completions/nush/eza.nu index 3830fd9f1..3b5d785ef 100644 --- a/completions/nush/eza.nu +++ b/completions/nush/eza.nu @@ -60,6 +60,7 @@ export extern "eza" [ --git-repos # List each git-repos status and branch name --git-repos-no-status # List each git-repos branch name (much faster) --extended(-@) # List each file's extended attributes and sizes + --tags(-e) # List each file's color tags stored in extended attributes --context(-Z) # List each file's security context --smart-group # Only show group if it has a different name from owner --stdin # When piping to eza. Read file paths from stdin diff --git a/completions/zsh/_eza b/completions/zsh/_eza index 9636fe254..0027a3f1c 100644 --- a/completions/zsh/_eza +++ b/completions/zsh/_eza @@ -68,6 +68,7 @@ __eza() { --git-repos"[List each git-repos status and branch name]" \ --git-repos-no-status"[List each git-repos branch name (much faster)]" \ {-@,--extended}"[List each file's extended attributes and sizes]" \ + {-e,--tags}"[List each file's color tags stored in extended attributes]" \ {-Z,--context}"[List each file's security context]" \ {-M,--mounts}"[Show mount details (long mode only)]" \ '*:filename:_files' \ diff --git a/docs/theme.yml b/docs/theme.yml index f1f2c4a04..991d2251b 100644 --- a/docs/theme.yml +++ b/docs/theme.yml @@ -42,3 +42,26 @@ extensions: rs: {filename: {foreground: Red}, icon: {glyph: 🦀}} # Change the icon glyph and color nix: {icon: {glyph: ❄, style: {foreground: White}}} +tags: + # More natural tag colors for true-color terminals + grey: + foreground: Black + background: "#8E8E93" + green: + foreground: Black + background: "#5DBC5B" + purple: + foreground: Black + background: "#A838C2" + blue: + foreground: Black + background: "#3376E8" + yellow: + foreground: Black + background: "#EDC442" + red: + foreground: Black + background: "#E14444" + orange: + foreground: Black + background: "#E68C3F" diff --git a/man/eza.1.md b/man/eza.1.md index 4daeac48b..4bff9369f 100644 --- a/man/eza.1.md +++ b/man/eza.1.md @@ -275,6 +275,9 @@ Alternatively, `` can be a two line string, the first line will be used `-@`, `--extended` : List each file’s extended attributes and sizes. +`-e`, `--tags` +: list each file's color tags stored in extended attributes + `-Z`, `--context` : List each file's security context. diff --git a/man/eza_colors-explanation.5.md b/man/eza_colors-explanation.5.md index f9b87d659..39c65fe08 100644 --- a/man/eza_colors-explanation.5.md +++ b/man/eza_colors-explanation.5.md @@ -147,6 +147,16 @@ file_type: build source +tags: + none + grey + green + purple + blue + yellow + red + orange + punctuation: date: @@ -200,6 +210,10 @@ security_context: selinux: role: is_hidden: true + +tags: + none: + underline: true ``` Icons can now be customized as well in the `filenames` and `extensions` fields diff --git a/man/eza_colors.5.md b/man/eza_colors.5.md index d8e54ec90..0e37bfb4f 100644 --- a/man/eza_colors.5.md +++ b/man/eza_colors.5.md @@ -306,6 +306,30 @@ LIST OF CODES `ff` : BSD file flags +`Tn` +: Color of the default tag + +`Tg` +: Color of the `grey` tag + +`Te` +: Color of the `green` tag + +`Tp` +: Color of the `purple` tag + +`Tb` +: Color of the `blue` tag + +`Ty` +: Color of the `yellow` tag + +`Tr` +: Color of the `red` tag + +`To` +: Color of the `orange` tag + Values in `EXA_COLORS` override those given in `LS_COLORS`, so you don’t need to re-write an existing `LS_COLORS` variable with proprietary extensions. diff --git a/src/fs/feature/xattr.rs b/src/fs/feature/xattr.rs index d064d67d1..a4922b976 100644 --- a/src/fs/feature/xattr.rs +++ b/src/fs/feature/xattr.rs @@ -8,6 +8,7 @@ #![allow(trivial_casts)] // for ARM +use crate::fs::fields::{Tag, TagColor}; use std::fmt::{Display, Formatter}; use std::io; use std::path::Path; @@ -542,7 +543,7 @@ impl Display for Attribute { match &self.value { None => f.write_str(""), Some(value) => { - if let Some(val) = custom_value_display(value) { + if let Some(val) = custom_value_display(fix_trailing_zero(value)) { f.write_fmt(format_args!("<{val}>")) } else if let Ok(v) = str::from_utf8(value) { f.write_fmt(format_args!("{:?}", v.trim_end_matches(char::from(0)))) @@ -712,3 +713,77 @@ fn plist_value_display(value: &[u8]) -> Option { .map(|s| format!("{}", s.replace('\n', ""))) }) } + +/// Never happen on macOS, but happens on Linux with files and folders from macOS. +#[cfg(target_os = "macos")] +fn fix_trailing_zero(value: &[u8]) -> &[u8] { + value +} + +#[cfg(not(target_os = "macos"))] +fn fix_trailing_zero(value: &[u8]) -> &[u8] { + value.strip_suffix(&[0]).unwrap_or(value) +} + +pub fn display_tags(attribute: &Attribute) -> Option> { + check_tags_name(&attribute.name) + .then(|| { + attribute + .value + .as_ref() + .filter(|value| value.starts_with(b"bplist")) + .map(|value| plist_tags_display(value)) + }) + .flatten() +} + +const TAGS_ATTRIBUTE_NAME: &str = "com.apple.metadata:_kMDItemUserTags"; + +/// On macOS, the extended attribute name for tags is consistent with Finder. +#[cfg(target_os = "macos")] +fn check_tags_name(name: &str) -> bool { + name == TAGS_ATTRIBUTE_NAME +} + +/// On other systems, the extended attribute name for macOS tags may vary +/// depending on the copy method (e.g. Samba, rsync). +/// Examples: +/// - "user.DosStream.com.apple.metadata:_kMDItemUserTags:$DATA" +/// - "user.com.apple.metadata:_kMDItemUserTags" +#[cfg(not(target_os = "macos"))] +fn check_tags_name(name: &str) -> bool { + name.contains(TAGS_ATTRIBUTE_NAME) +} + +/// A tag is represented as a string containing: +/// - A tag name (predefined or user-defined). +/// - Optionally, a color code after a `\n`. +/// +/// The numeric color code corresponds to the [`TagColor`] enum. +/// See its [`FromStr`] implementation for supported mappings. +/// +/// See +fn plist_tags_display(value: &[u8]) -> Vec { + let reader = io::Cursor::new(fix_trailing_zero(value)); + plist::Value::from_reader(reader) + .ok() + .and_then(plist::Value::into_array) + .map(|arr| { + arr.into_iter() + .filter_map(|x| match x { + plist::Value::String(str) => { + let lines: Vec<&str> = str.lines().collect(); + let name = lines[0].to_string(); + let color = if lines.len() >= 2 { + lines[1].parse::().ok() + } else { + None + }; + Some(Tag { name, color }) + } + _ => None, + }) + .collect() + }) + .unwrap_or_default() +} diff --git a/src/fs/fields.rs b/src/fs/fields.rs index d51b89cfa..46073baa4 100644 --- a/src/fs/fields.rs +++ b/src/fs/fields.rs @@ -20,6 +20,8 @@ #![allow(non_camel_case_types)] #![allow(clippy::struct_excessive_bools)] +use std::str::FromStr; + /// The type of a file’s group ID. pub type gid_t = u32; @@ -298,3 +300,40 @@ impl Default for SubdirGitRepo { /// The user file flags on the file. This will only ever be a number; /// looking up the flags is done in the `display` module. pub struct Flags(pub flag_t); + +/// Tag colors in macOS. +/// +#[derive(Clone)] +pub enum TagColor { + None, + Grey, + Green, + Purple, + Blue, + Yellow, + Red, + Orange, +} + +impl FromStr for TagColor { + type Err = TagColor; + fn from_str(s: &str) -> Result { + match s.parse::() { + Ok(0) => Ok(TagColor::None), + Ok(1) => Ok(TagColor::Grey), + Ok(2) => Ok(TagColor::Green), + Ok(3) => Ok(TagColor::Purple), + Ok(4) => Ok(TagColor::Blue), + Ok(5) => Ok(TagColor::Yellow), + Ok(6) => Ok(TagColor::Red), + Ok(7) => Ok(TagColor::Orange), + _ => Err(TagColor::None), + } + } +} + +#[derive(Clone)] +pub struct Tag { + pub name: String, + pub color: Option, +} diff --git a/src/options/config.rs b/src/options/config.rs index d1af73141..eb522ce90 100644 --- a/src/options/config.rs +++ b/src/options/config.rs @@ -4,11 +4,11 @@ // SPDX-FileCopyrightText: 2023-2024 Christina Sørensen, eza contributors // SPDX-FileCopyrightText: 2014 Benjamin Sago // SPDX-License-Identifier: MIT -use crate::theme::ThemeFileType as FileType; use crate::theme::{ FileKinds, FileNameStyle, Git, GitRepo, IconStyle, Links, Permissions, SELinuxContext, SecurityContext, Size, UiStyles, Users, }; +use crate::theme::{Tags, ThemeFileType as FileType}; use nu_ansi_term::{Color, Style}; use serde::{Deserialize, Deserializer, Serialize}; use serde_norway; @@ -538,6 +538,34 @@ impl FromOverride for FileType { } } +#[rustfmt::skip] +#[derive(Clone, Eq, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct TagsOverride { + pub none: Option, // Tn + pub grey: Option, // Tg + pub green: Option, // Te + pub purple: Option, // Tp + pub blue: Option, // Tb + pub yellow: Option, // Ty + pub red: Option, // Tr + pub orange: Option, // To +} + +impl FromOverride for Tags { + fn from(value: TagsOverride, default: Self) -> Self { + Tags { + none: FromOverride::from(value.none, default.none), + grey: FromOverride::from(value.grey, default.grey), + green: FromOverride::from(value.green, default.green), + purple: FromOverride::from(value.purple, default.purple), + blue: FromOverride::from(value.blue, default.blue), + yellow: FromOverride::from(value.yellow, default.yellow), + red: FromOverride::from(value.red, default.red), + orange: FromOverride::from(value.orange, default.orange), + } + } +} + #[rustfmt::skip] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct UiStylesOverride { @@ -552,6 +580,7 @@ pub struct UiStylesOverride { pub git_repo: Option, pub security_context: Option, pub file_type: Option, + pub tags: Option, pub punctuation: Option, // xx pub date: Option, // da @@ -584,6 +613,7 @@ impl FromOverride for UiStyles { git_repo: FromOverride::from(value.git_repo, default.git_repo), security_context: FromOverride::from(value.security_context, default.security_context), file_type: FromOverride::from(value.file_type, default.file_type), + tags: FromOverride::from(value.tags, default.tags), punctuation: FromOverride::from(value.punctuation, default.punctuation), date: FromOverride::from(value.date, default.date), diff --git a/src/options/flags.rs b/src/options/flags.rs index 53eef54cb..3d95292c1 100644 --- a/src/options/flags.rs +++ b/src/options/flags.rs @@ -93,6 +93,7 @@ pub static NO_GIT: Arg = Arg { short: None, long: "no-git", pub static GIT_REPOS: Arg = Arg { short: None, long: "git-repos", takes_value: TakesValue::Forbidden }; pub static GIT_REPOS_NO_STAT: Arg = Arg { short: None, long: "git-repos-no-status", takes_value: TakesValue::Forbidden }; pub static EXTENDED: Arg = Arg { short: Some(b'@'), long: "extended", takes_value: TakesValue::Forbidden }; +pub static TAGS: Arg = Arg { short: Some(b'e'), long: "tags", takes_value: TakesValue::Forbidden }; pub static OCTAL: Arg = Arg { short: Some(b'o'), long: "octal-permissions", takes_value: TakesValue::Forbidden }; pub static SECURITY_CONTEXT: Arg = Arg { short: Some(b'Z'), long: "context", takes_value: TakesValue::Forbidden }; pub static STDIN: Arg = Arg { short: None, long: "stdin", takes_value: TakesValue::Forbidden }; @@ -113,5 +114,5 @@ pub static ALL_ARGS: Args = Args(&[ &NO_PERMISSIONS, &NO_FILESIZE, &NO_USER, &NO_TIME, &SMART_GROUP, &NO_SYMLINKS, &SHOW_SYMLINKS, &GIT, &NO_GIT, &GIT_REPOS, &GIT_REPOS_NO_STAT, - &EXTENDED, &OCTAL, &SECURITY_CONTEXT, &STDIN, &FILE_FLAGS + &EXTENDED, &TAGS, &OCTAL, &SECURITY_CONTEXT, &STDIN, &FILE_FLAGS ]); diff --git a/src/options/help.rs b/src/options/help.rs index 69070dbaf..ae3b1c1b3 100644 --- a/src/options/help.rs +++ b/src/options/help.rs @@ -100,6 +100,8 @@ static GIT_VIEW_HELP: &str = " \ "; static EXTENDED_HELP: &str = " \ -@, --extended list each file's extended attributes and sizes"; +static TAGS_HELP: &str = " \ + -e, --tags list each file's color tags stored in extended attributes"; static SECATTR_HELP: &str = " \ -Z, --context list each file's security context"; @@ -144,6 +146,7 @@ impl fmt::Display for HelpString { if xattr::ENABLED { write!(f, "\n{EXTENDED_HELP}")?; + write!(f, "\n{TAGS_HELP}")?; write!(f, "\n{SECATTR_HELP}")?; } diff --git a/src/options/view.rs b/src/options/view.rs index 18aedaf8d..36ac54f4b 100644 --- a/src/options/view.rs +++ b/src/options/view.rs @@ -164,6 +164,7 @@ impl details::Options { table: None, header: false, xattr: xattr::ENABLED && matches.has(&flags::EXTENDED)?, + tags: xattr::ENABLED && matches.has(&flags::TAGS)?, secattr: xattr::ENABLED && matches.has(&flags::SECURITY_CONTEXT)?, mounts: matches.has(&flags::MOUNTS)?, color_scale: ColorScaleOptions::deduce(matches, vars)?, @@ -186,6 +187,7 @@ impl details::Options { table: Some(TableOptions::deduce(matches, vars)?), header: matches.has(&flags::HEADER)?, xattr: xattr::ENABLED && matches.has(&flags::EXTENDED)?, + tags: xattr::ENABLED && matches.has(&flags::TAGS)?, secattr: xattr::ENABLED && matches.has(&flags::SECURITY_CONTEXT)?, mounts: matches.has(&flags::MOUNTS)?, color_scale: ColorScaleOptions::deduce(matches, vars)?, diff --git a/src/output/details.rs b/src/output/details.rs index 330ece774..00d2cb432 100644 --- a/src/output/details.rs +++ b/src/output/details.rs @@ -114,6 +114,9 @@ pub struct Options { /// Whether to show each file’s extended attributes. pub xattr: bool, + /// Whether to show each file’s tags attributes. + pub tags: bool, + /// Whether to show each file's security attribute. pub secattr: bool, @@ -341,6 +344,7 @@ impl<'a> Render<'a> { .for_file(egg.file, self.theme) .with_link_paths() .with_mount_details(self.opts.mounts) + .with_tags(self.opts.tags) .paint() .promote(); diff --git a/src/output/file_name.rs b/src/output/file_name.rs index 85c5e5c42..a221496ae 100644 --- a/src/output/file_name.rs +++ b/src/output/file_name.rs @@ -11,6 +11,8 @@ use nu_ansi_term::{AnsiString as ANSIString, Style}; use path_clean; use unicode_width::UnicodeWidthStr; +use crate::fs::feature::xattr::display_tags; +use crate::fs::fields::TagColor; use crate::fs::{File, FileTarget}; use crate::output::cell::TextCellContents; use crate::output::escape; @@ -59,6 +61,7 @@ impl Options { None }, mount_style: MountStyle::JustDirectoryNames, + tags: Tags::Off, } } } @@ -104,6 +107,13 @@ enum MountStyle { MountInfo, } +/// Whether to show macOS tags. +#[derive(PartialEq, Debug, Copy, Clone)] +enum Tags { + Off, + On, +} + /// Whether and how to show icons. #[derive(PartialEq, Debug, Copy, Clone)] pub enum ShowIcons { @@ -163,6 +173,8 @@ pub struct FileName<'a, 'dir, C> { /// How to handle displaying a mounted filesystem. mount_style: MountStyle, + + tags: Tags, } impl FileName<'_, '_, C> { @@ -187,6 +199,13 @@ impl FileName<'_, '_, C> { }; self } + + /// Sets the flag on this file name to display macOS tags. + #[must_use] + pub fn with_tags(mut self, enable: bool) -> Self { + self.tags = if enable { Tags::On } else { Tags::Off }; + self + } } impl FileName<'_, '_, C> { @@ -286,6 +305,7 @@ impl FileName<'_, '_, C> { link_style: LinkStyle::FullLinkPaths, options: target_options, mount_style: MountStyle::JustDirectoryNames, + tags: Tags::Off, }; for bit in target_name.escaped_file_name(filename_style_override) { @@ -335,6 +355,18 @@ impl FileName<'_, '_, C> { } } + if self.tags == Tags::On { + self.file.extended_attributes().iter().for_each(|attr| { + if let Some(tags) = display_tags(attr) { + for tag in &tags { + let color = tag.color.as_ref().unwrap_or(&TagColor::None); + bits.push(Style::default().paint(" ")); + bits.push(self.colours.tag(color).paint(tag.name.clone())); + } + } + }); + } + bits.into() } diff --git a/src/output/render/filetype.rs b/src/output/render/filetype.rs index e72270d0d..417f4c427 100644 --- a/src/output/render/filetype.rs +++ b/src/output/render/filetype.rs @@ -4,9 +4,9 @@ // SPDX-FileCopyrightText: 2023-2024 Christina Sørensen, eza contributors // SPDX-FileCopyrightText: 2014 Benjamin Sago // SPDX-License-Identifier: MIT -use nu_ansi_term::{AnsiString as ANSIString, Style}; - use crate::fs::fields as f; +use crate::fs::fields::TagColor; +use nu_ansi_term::{AnsiString as ANSIString, Style}; impl f::Type { pub fn render(self, colours: &C) -> ANSIString<'static> { @@ -33,4 +33,5 @@ pub trait Colours { fn char_device(&self) -> Style; fn socket(&self) -> Style; fn special(&self) -> Style; + fn tag(&self, tag: &TagColor) -> Style; } diff --git a/src/theme/default_theme.rs b/src/theme/default_theme.rs index 4cc57c825..725b1ac71 100644 --- a/src/theme/default_theme.rs +++ b/src/theme/default_theme.rs @@ -13,6 +13,7 @@ use crate::theme::ui_styles::{ FileKinds, FileType, Git, GitRepo, Links, Permissions, SELinuxContext, SecurityContext, Size, UiStyles, Users, }; +use crate::theme::Tags; impl UiStyles { #[must_use] pub fn default_theme(scale: ColorScaleOptions) -> Self { @@ -126,6 +127,7 @@ impl Default for UiStyles { source: Some(Yellow.bold()), // Need to discuss color }), + tags: Some(Tags::default()), punctuation: Some(DarkGray.bold()), date: Some(Blue.normal()), inode: Some(Purple.normal()), diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 77138f5f3..4bd4bd5c3 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -6,14 +6,14 @@ // SPDX-License-Identifier: MIT use nu_ansi_term::Style; -use std::collections::HashMap; - +use crate::fs::fields::TagColor; use crate::fs::File; use crate::info::filetype::FileType; use crate::options::config::ThemeConfig; use crate::output::color_scale::ColorScaleOptions; use crate::output::file_name::Colours as FileNameColours; use crate::output::render; +use std::collections::HashMap; mod ui_styles; pub(crate) use self::ui_styles::FileType as ThemeFileType; @@ -358,6 +358,18 @@ impl render::FiletypeColours for Theme { fn char_device(&self) -> Style { self.ui.filekinds.unwrap_or_default().char_device() } fn socket(&self) -> Style { self.ui.filekinds.unwrap_or_default().socket() } fn special(&self) -> Style { self.ui.filekinds.unwrap_or_default().special() } + fn tag(&self, color: &TagColor) -> Style { + match color { + TagColor::None => self.ui.tags.unwrap_or_default().none(), + TagColor::Grey => self.ui.tags.unwrap_or_default().grey(), + TagColor::Green => self.ui.tags.unwrap_or_default().green(), + TagColor::Purple => self.ui.tags.unwrap_or_default().purple(), + TagColor::Blue => self.ui.tags.unwrap_or_default().blue(), + TagColor::Yellow => self.ui.tags.unwrap_or_default().yellow(), + TagColor::Red => self.ui.tags.unwrap_or_default().red(), + TagColor::Orange => self.ui.tags.unwrap_or_default().orange(), + } + } } #[rustfmt::skip] diff --git a/src/theme/ui_styles.rs b/src/theme/ui_styles.rs index ec9812977..d48c7f528 100644 --- a/src/theme/ui_styles.rs +++ b/src/theme/ui_styles.rs @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT use crate::theme::lsc::Pair; use nu_ansi_term::{ - Color::{Blue, Cyan, Green, Purple, Red, Yellow}, + Color::{Black, Blue, Cyan, Fixed, Green, Purple, Red, Yellow}, Style, }; use serde::{Deserialize, Serialize}; @@ -39,6 +39,7 @@ pub struct UiStyles { pub git_repo: Option, pub security_context: Option, pub file_type: Option, + pub tags: Option, pub punctuation: Option