Repository: lsd-rs/lsd (language: rust)

Issue:
Block Headers
Is there a way to have block headers?
![image](https://user-images.githubusercontent.com/86342787/153562615-d3c78c7e-9ba5-4d60-8301-5e3b4f2c73eb.png)



Discussion / hints:
Not at the moment, but this would be a good feature to add in.
Hi, I'll try to implement this :)
How should the headers look like? Like the screenshot above or something different?
Is flag "--header" ok for this? I see the -h short flag is taken, any preferences for short flag?
Thanks @MichaelAug for looking into this. Something like the screenshot would be a good start. One possible improvement that I can think of is that the underline could span the entire block width. Also, maybe we could center the header.

`--header` as the flag for this sounds good. I don't think we need a short flag for this.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 391ac8521..ceee1c0bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased] - ReleaseDate
 ### Added
+- Add support for `--header` from [MichaelAug](https://github.com/MichaelAug)
 - Add support for `--no-sort` `-U` from [MichaelAug](https://github.com/MichaelAug)
 - Add `--group-directories-first` as an alias for `--group-dirs=first` to improve compatibility with `coreutils/ls`
 - Add `--permission` flag to choose permission formatting (rwx, octal) from [meain](https://github.com/meain)
diff --git a/README.md b/README.md
index b1e913c84..0413f22c2 100644
--- a/README.md
+++ b/README.md
@@ -215,6 +215,11 @@ hyperlink: never
 # == Symlink arrow ==
 # Specifies how the symlink arrow display, chars in both ascii and utf8
 symlink-arrow: ⇒
+
+# == Header ==
+# Whether to display block headers.
+# Possible values: false, true
+header: false
 ```
 
 ## Theme
diff --git a/doc/lsd.md b/doc/lsd.md
index 19e503c20..e24ff1b38 100644
--- a/doc/lsd.md
+++ b/doc/lsd.md
@@ -134,6 +134,9 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich
 `-Z` `--context`
 : Display SELinux or SMACK security context
 
+`--header`
+: Display block headers
+
 # ARGS
 
 `<FILE>...`
diff --git a/src/app.rs b/src/app.rs
index 65d1a1d03..ddfcbc058 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -329,6 +329,11 @@ pub fn build() -> App<'static, 'static> {
                 .number_of_values(1)
                 .help("Attach hyperlink to filenames"),
         )
+        .arg(
+            Arg::with_name("header")
+                .long("header")
+                .help("Display block headers"),
+        )
 }
 
 fn validate_date_argument(arg: String) -> Result<(), String> {
diff --git a/src/config_file.rs b/src/config_file.rs
index b4820c667..fd661c5a6 100644
--- a/src/config_file.rs
+++ b/src/config_file.rs
@@ -44,6 +44,7 @@ pub struct Config {
     pub total_size: Option<bool>,
     pub symlink_arrow: Option<String>,
     pub hyperlink: Option<HyperlinkOption>,
+    pub header: Option<bool>,
 }
 
 #[derive(Eq, PartialEq, Debug, Deserialize)]
@@ -95,6 +96,7 @@ impl Config {
             total_size: None,
             symlink_arrow: None,
             hyperlink: None,
+            header: None,
         }
     }
 
@@ -347,17 +349,14 @@ mod tests {
         assert_eq!(
             Config {
                 classic: Some(false),
-                blocks: Some(
-                    vec![
-                        "permission".into(),
-                        "user".into(),
-                        "group".into(),
-                        "size".into(),
-                        "date".into(),
-                        "name".into(),
-                    ]
-                    .into()
-                ),
+                blocks: Some(vec![
+                    "permission".into(),
+                    "user".into(),
+                    "group".into(),
+                    "size".into(),
+                    "date".into(),
+                    "name".into(),
+                ]),
                 color: Some(config_file::Color {
                     when: Some(ColorOption::Auto),
                     theme: Some(ThemeOption::Default)
@@ -388,6 +387,7 @@ mod tests {
                 total_size: Some(false),
                 symlink_arrow: Some("⇒".into()),
                 hyperlink: Some(HyperlinkOption::Never),
+                header: None
             },
             c
         );
diff --git a/src/display.rs b/src/display.rs
index 1bc6f974a..c64ec8bf7 100644
--- a/src/display.rs
+++ b/src/display.rs
@@ -59,6 +59,7 @@ fn inner_display_grid(
     term_width: Option<usize>,
 ) -> String {
     let mut output = String::new();
+    let mut cells = Vec::new();
 
     let padding_rules = get_padding_rules(metas, flags);
     let mut grid = match flags.layout {
@@ -101,7 +102,7 @@ fn inner_display_grid(
         for block in blocks {
             let block_str = block.to_string();
 
-            grid.add(Cell {
+            cells.push(Cell {
                 width: get_visible_width(
                     &block_str,
                     matches!(flags.hyperlink, HyperlinkOption::Always),
@@ -111,6 +112,15 @@ fn inner_display_grid(
         }
     }
 
+    // Print block headers
+    if flags.header.0 && flags.layout == Layout::OneLine && !cells.is_empty() {
+        add_header(flags, &cells, &mut grid);
+    }
+
+    for cell in cells {
+        grid.add(cell);
+    }
+
     if flags.layout == Layout::Grid {
         if let Some(tw) = term_width {
             if let Some(gridded_output) = grid.fit_into_width(tw) {
@@ -156,6 +166,42 @@ fn inner_display_grid(
     output
 }
 
+fn add_header(flags: &Flags, cells: &[Cell], grid: &mut Grid) {
+    let num_columns: usize = flags.blocks.0.len();
+
+    let mut widths = flags
+        .blocks
+        .0
+        .iter()
+        .map(|b| {
+            get_visible_width(
+                &b.get_header(),
+                matches!(flags.hyperlink, HyperlinkOption::Always),
+            )
+        })
+        .collect::<Vec<usize>>();
+
+    // find max widths of each column
+    for (index, cell) in cells.iter().enumerate() {
+        let index = index % num_columns;
+        widths[index] = std::cmp::max(widths[index], cell.width);
+    }
+
+    for (idx, block) in flags.blocks.0.iter().enumerate() {
+        // center and underline header
+        let underlined_header = crossterm::style::Stylize::attribute(
+            format!("{: ^1$}", block.get_header(), widths[idx]),
+            crossterm::style::Attribute::Underlined,
+        )
+        .to_string();
+
+        grid.add(Cell {
+            width: widths[idx],
+            contents: underlined_header,
+        });
+    }
+}
+
 fn inner_display_tree(
     metas: &[Meta],
     flags: &Flags,
@@ -695,4 +741,72 @@ mod tests {
 
         assert!(output.ends_with("└── two\n"));
     }
+
+    #[test]
+    fn test_grid_all_block_headers() {
+        let argv = vec![
+            "lsd",
+            "--header",
+            "--blocks",
+            "permission,user,group,size,date,name,inode,links",
+        ];
+        let matches = app::build().get_matches_from_safe(argv).unwrap();
+        let flags = Flags::configure_from(&matches, &Config::with_none()).unwrap();
+
+        let dir = assert_fs::TempDir::new().unwrap();
+        dir.child("testdir").create_dir_all().unwrap();
+        dir.child("test").touch().unwrap();
+        let metas = Meta::from_path(Path::new(dir.path()), false)
+            .unwrap()
+            .recurse_into(1, &flags)
+            .unwrap()
+            .unwrap();
+        let output = grid(
+            &metas,
+            &flags,
+            &Colors::new(color::ThemeOption::NoColor),
+            &Icons::new(icon::Theme::NoIcon, " ".to_string()),
+        );
+
+        dir.close().unwrap();
+
+        assert!(output.contains("Permissions"));
+        assert!(output.contains("User"));
+        assert!(output.contains("Group"));
+        assert!(output.contains("Size"));
+        assert!(output.contains("Date Modified"));
+        assert!(output.contains("Name"));
+        assert!(output.contains("INode"));
+        assert!(output.contains("Links"));
+    }
+
+    #[test]
+    fn test_grid_no_header_with_empty_meta() {
+        let argv = vec!["lsd", "--header", "-l"];
+        let matches = app::build().get_matches_from_safe(argv).unwrap();
+        let flags = Flags::configure_from(&matches, &Config::with_none()).unwrap();
+
+        let dir = assert_fs::TempDir::new().unwrap();
+        dir.child("testdir").create_dir_all().unwrap();
+        let metas = Meta::from_path(Path::new(dir.path()), false)
+            .unwrap()
+            .recurse_into(1, &flags)
+            .unwrap()
+            .unwrap();
+        let output = grid(
+            &metas,
+            &flags,
+            &Colors::new(color::ThemeOption::NoColor),
+            &Icons::new(icon::Theme::NoIcon, " ".to_string()),
+        );
+
+        dir.close().unwrap();
+
+        assert!(!output.contains("Permissions"));
+        assert!(!output.contains("User"));
+        assert!(!output.contains("Group"));
+        assert!(!output.contains("Size"));
+        assert!(!output.contains("Date Modified"));
+        assert!(!output.contains("Name"));
+    }
 }
diff --git a/src/flags.rs b/src/flags.rs
index 75f3c63ae..59db6bf2d 100644
--- a/src/flags.rs
+++ b/src/flags.rs
@@ -3,6 +3,7 @@ pub mod color;
 pub mod date;
 pub mod dereference;
 pub mod display;
+pub mod header;
 pub mod hyperlink;
 pub mod icons;
 pub mod ignore_globs;
@@ -23,6 +24,7 @@ pub use color::{ColorOption, ThemeOption};
 pub use date::DateFlag;
 pub use dereference::Dereference;
 pub use display::Display;
+pub use header::Header;
 pub use hyperlink::HyperlinkOption;
 pub use icons::IconOption;
 pub use icons::IconSeparator;
@@ -69,6 +71,7 @@ pub struct Flags {
     pub total_size: TotalSize,
     pub symlink_arrow: SymlinkArrow,
     pub hyperlink: HyperlinkOption,
+    pub header: Header,
 }
 
 impl Flags {
@@ -97,6 +100,7 @@ impl Flags {
             total_size: TotalSize::configure_from(matches, config),
             symlink_arrow: SymlinkArrow::configure_from(matches, config),
             hyperlink: HyperlinkOption::configure_from(matches, config),
+            header: Header::configure_from(matches, config),
         })
     }
 }
diff --git a/src/flags/blocks.rs b/src/flags/blocks.rs
index 3d33cac85..d9263976e 100644
--- a/src/flags/blocks.rs
+++ b/src/flags/blocks.rs
@@ -191,6 +191,23 @@ pub enum Block {
     Links,
 }
 
+impl Block {
+    pub fn get_header(&self) -> String {
+        match self {
+            Block::INode => String::from("INode"),
+            Block::Links => String::from("Links"),
+            Block::Permission => String::from("Permissions"),
+            Block::User => String::from("User"),
+            Block::Group => String::from("Group"),
+            Block::Context => String::from("Context"),
+            Block::Size => String::from("Size"),
+            Block::SizeValue => String::from("SizeValue"),
+            Block::Date => String::from("Date Modified"),
+            Block::Name => String::from("Name"),
+        }
+    }
+}
+
 impl TryFrom<&str> for Block {
     type Error = String;
 
@@ -576,4 +593,18 @@ mod test_block {
     fn test_context() {
         assert_eq!(Ok(Block::Context), Block::try_from("context"));
     }
+
+    #[test]
+    fn test_block_headers() {
+        assert_eq!(Block::INode.get_header(), String::from("INode"));
+        assert_eq!(Block::Links.get_header(), String::from("Links"));
+        assert_eq!(Block::Permission.get_header(), String::from("Permissions"));
+        assert_eq!(Block::User.get_header(), String::from("User"));
+        assert_eq!(Block::Group.get_header(), String::from("Group"));
+        assert_eq!(Block::Context.get_header(), String::from("Context"));
+        assert_eq!(Block::Size.get_header(), String::from("Size"));
+        assert_eq!(Block::SizeValue.get_header(), String::from("SizeValue"));
+        assert_eq!(Block::Date.get_header(), String::from("Date Modified"));
+        assert_eq!(Block::Name.get_header(), String::from("Name"));
+    }
 }
diff --git a/src/flags/header.rs b/src/flags/header.rs
new file mode 100644
index 000000000..845033ab5
--- /dev/null
+++ b/src/flags/header.rs
@@ -0,0 +1,77 @@
+//! This module defines the [Header] flag. To set it up from [ArgMatches], a [Config] and its
+//! [Default] value, use the [configure_from](Configurable::configure_from) method.
+
+use super::Configurable;
+
+use crate::config_file::Config;
+
+use clap::ArgMatches;
+
+/// The flag showing whether to display block headers.
+#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)]
+pub struct Header(pub bool);
+
+impl Configurable<Self> for Header {
+    /// Get a potential `Header` value from [ArgMatches].
+    ///
+    /// If the "header" argument is passed, this returns a `Header` with value `true` in a
+    /// [Some]. Otherwise this returns [None].
+    fn from_arg_matches(matches: &ArgMatches) -> Option<Self> {
+        if matches.is_present("header") {
+            Some(Self(true))
+        } else {
+            None
+        }
+    }
+
+    /// Get a potential `Header` value from a [Config].
+    ///
+    /// If the `Config::header` has value,
+    /// this returns it as the value of the `Header`, in a [Some].
+    /// Otherwise this returns [None].
+    fn from_config(config: &Config) -> Option<Self> {
+        config.header.map(Self)
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::Header;
+
+    use crate::app;
+    use crate::config_file::Config;
+    use crate::flags::Configurable;
+
+    #[test]
+    fn test_from_arg_matches_none() {
+        let argv = vec!["lsd"];
+        let matches = app::build().get_matches_from_safe(argv).unwrap();
+        assert_eq!(None, Header::from_arg_matches(&matches));
+    }
+
+    #[test]
+    fn test_from_arg_matches_true() {
+        let argv = vec!["lsd", "--header"];
+        let matches = app::build().get_matches_from_safe(argv).unwrap();
+        assert_eq!(Some(Header(true)), Header::from_arg_matches(&matches));
+    }
+
+    #[test]
+    fn test_from_config_none() {
+        assert_eq!(None, Header::from_config(&Config::with_none()));
+    }
+
+    #[test]
+    fn test_from_config_true() {
+        let mut c = Config::with_none();
+        c.header = Some(true);
+        assert_eq!(Some(Header(true)), Header::from_config(&c));
+    }
+
+    #[test]
+    fn test_from_config_false() {
+        let mut c = Config::with_none();
+        c.header = Some(false);
+        assert_eq!(Some(Header(false)), Header::from_config(&c));
+    }
+}


Test patch: