Git integration (#822)

This commit is contained in:
Pascal H 2023-04-30 18:26:15 +02:00 committed by GitHub
parent 6840c01905
commit 2fe3fcdd35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1056 additions and 38 deletions

View file

@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Add [Git integration](https://github.com/Peltoche/lsd/issues/7) from [hpwxf](https://github.com/hpwxf)
- In keeping with the coreutils change, add quotes and escapes for necessary filenames from [merelymyself](https://github.com/merelymyself)
- Add support for icon theme from [zwpaper](https://github.com/zwpaper)
- Add icon for kt and kts from [LeeWeeder](https://github.com/LeeWeeder)

62
Cargo.lock generated
View file

@ -82,6 +82,9 @@ name = "cc"
version = "1.0.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f"
dependencies = [
"jobserver",
]
[[package]]
name = "cfg-if"
@ -295,6 +298,19 @@ dependencies = [
"wasi 0.10.2+wasi-snapshot-preview1",
]
[[package]]
name = "git2"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf7f68c2995f392c49fffb4f95ae2c873297830eb25c6bc4c114ce8f4562acc"
dependencies = [
"bitflags",
"libc",
"libgit2-sys",
"log",
"url",
]
[[package]]
name = "glob"
version = "0.3.0"
@ -431,6 +447,15 @@ dependencies = [
"either",
]
[[package]]
name = "jobserver"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2"
dependencies = [
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.58"
@ -452,6 +477,30 @@ version = "0.2.139"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79"
[[package]]
name = "libgit2-sys"
version = "0.14.2+1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f3d95f6b51075fe9810a7ae22c7095f12b98005ab364d8544797a825ce946a4"
dependencies = [
"cc",
"libc",
"libz-sys",
"pkg-config",
]
[[package]]
name = "libz-sys"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linked-hash-map"
version = "0.5.4"
@ -503,6 +552,7 @@ dependencies = [
"clap_complete",
"crossterm",
"dirs",
"git2",
"globset",
"human-sort",
"libc",
@ -643,6 +693,12 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
[[package]]
name = "pkg-config"
version = "0.3.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160"
[[package]]
name = "predicates"
version = "1.0.8"
@ -1104,6 +1160,12 @@ dependencies = [
"log",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.4"

View file

@ -21,7 +21,7 @@ clap_complete = "4.1"
version_check = "0.9.*"
[dependencies]
crossterm = { version = "0.24.0", features = ["serde"]}
crossterm = { version = "0.24.0", features = ["serde"] }
dirs = "3.0.*"
libc = "0.2.*"
human-sort = "0.2.2"
@ -42,6 +42,10 @@ serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.8"
url = "2.1.*"
[target."cfg(not(all(windows, target_arch = \"x86\", target_env = \"gnu\")))".dependencies]
# if ssl feature is enabled compilation will fail on arm-unknown-linux-gnueabihf and i686-pc-windows-gnu
git2 = { version = "0.16", optional = true, default-features = false }
[target.'cfg(unix)'.dependencies]
users = "0.11.*"
xattr = "0.2.*"
@ -61,7 +65,9 @@ tempfile = "3"
serial_test = "0.5"
[features]
default = ["git2"]
sudo = []
no-git = [] # force disabling git even if available by default
[profile.release]
lto = true

View file

@ -103,7 +103,7 @@ classic: false
# == Blocks ==
# This specifies the columns and their order when using the long and the tree
# layout.
# Possible values: permission, user, group, context, size, date, name, inode, links
# Possible values: permission, user, group, context, size, date, name, inode, links, git
blocks:
- permission
- user

View file

@ -34,4 +34,15 @@ fn main() {
generate_to(Zsh, &mut app, bin_name, &outdir).expect("Failed to generate Zsh completions");
generate_to(PowerShell, &mut app, bin_name, &outdir)
.expect("Failed to generate PowerShell completions");
// Disable git feature for these target where git2 is not well supported
if !std::env::var("CARGO_FEATURE_GIT2")
.map(|flag| flag == "1")
.unwrap_or(false)
|| std::env::var("TARGET")
.map(|target| target == "i686-pc-windows-gnu")
.unwrap_or(false)
{
println!(r#"cargo:rustc-cfg=feature="no-git""#);
}
}

View file

@ -4,7 +4,7 @@
set -ex
build() {
cargo build --target "$TARGET" --release --verbose
cargo build --target "$TARGET" --features="$FEATURES" --release --verbose
}
pack() {

View file

@ -38,6 +38,9 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich
`-X`, `--extensionsort`
: Sort by file extension
`--git`
: Display git status. Directory git status is a reduction of included file statuses (recursively).
`--help`
: Prints help information
@ -90,7 +93,7 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich
: Natural sort of (version) numbers within text
`--blocks <blocks>...`
: Specify the blocks that will be displayed and in what order [possible values: permission, user, group, size, date, name, inode]
: Specify the blocks that will be displayed and in what order [possible values: permission, user, group, size, date, name, inode, git]
`--color <color>...`
: When to use terminal colours [default: auto] [possible values: always, auto, never]
@ -126,7 +129,7 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich
: How to display size [default: default] [possible values: default, short, bytes]
`--sort <WORD>...`
: Sort by WORD instead of name [possible values: size, time, version, extension]
: Sort by WORD instead of name [possible values: size, time, version, extension, git]
`-U`, `--no-sort`
: Do not sort. List entries in directory order

View file

@ -96,6 +96,10 @@ pub struct Cli {
#[arg(short = 'X', long)]
pub extensionsort: bool,
/// Sort by git status
#[arg(short = 'G', long)]
pub gitsort: bool,
/// Natural sort of (version) numbers within text
#[arg(short = 'v', long)]
pub versionsort: bool,
@ -104,13 +108,13 @@ pub struct Cli {
#[arg(
long,
value_name = "TYPE",
value_parser = ["size", "time", "version", "extension", "none"],
overrides_with_all = ["timesort", "sizesort", "extensionsort", "versionsort", "no_sort"]
value_parser = ["size", "time", "version", "extension", "git", "none"],
overrides_with_all = ["timesort", "sizesort", "extensionsort", "versionsort", "gitsort", "no_sort"]
)]
pub sort: Option<String>,
/// Do not sort. List entries in directory order
#[arg(short = 'U', long, overrides_with_all = ["timesort", "sizesort", "extensionsort", "versionsort", "sort"])]
#[arg(short = 'U', long, overrides_with_all = ["timesort", "sizesort", "extensionsort", "versionsort", "gitsort", "sort"])]
pub no_sort: bool,
/// Reverse the order of the sort
@ -127,9 +131,9 @@ pub struct Cli {
/// Specify the blocks that will be displayed and in what order
#[arg(
long,
value_delimiter = ',',
value_parser = ["permission", "user", "group", "context", "size", "date", "name", "inode", "links"],
long,
value_delimiter = ',',
value_parser = ["permission", "user", "group", "context", "size", "date", "name", "inode", "links", "git"],
)]
pub blocks: Vec<String>,
@ -150,6 +154,11 @@ pub struct Cli {
#[arg(short, long)]
pub inode: bool,
/// Show git status on file and directory"
/// Only when used with --long option
#[arg(short, long)]
pub git: bool,
/// When showing file information for a symbolic link,
/// show information for the file the link references rather than for the link itself
#[arg(short = 'L', long)]
@ -196,15 +205,15 @@ pub fn validate_time_format(formatter: &str) -> Result<String, String> {
Some('f') => (),
Some(n @ ('3' | '6' | '9')) => match chars.next() {
Some('f') => (),
Some(c) => return Err(format!("invalid format specifier: %.{}{}", n, c)),
Some(c) => return Err(format!("invalid format specifier: %.{n}{c}")),
None => return Err("missing format specifier".to_owned()),
},
Some(c) => return Err(format!("invalid format specifier: %.{}", c)),
Some(c) => return Err(format!("invalid format specifier: %.{c}")),
None => return Err("missing format specifier".to_owned()),
},
Some(n @ (':' | '#')) => match chars.next() {
Some('z') => (),
Some(c) => return Err(format!("invalid format specifier: %{}{}", n, c)),
Some(c) => return Err(format!("invalid format specifier: %{n}{c}")),
None => return Err("missing format specifier".to_owned()),
},
Some(n @ ('-' | '_' | '0')) => match chars.next() {
@ -212,7 +221,7 @@ pub fn validate_time_format(formatter: &str) -> Result<String, String> {
'C' | 'd' | 'e' | 'f' | 'G' | 'g' | 'H' | 'I' | 'j' | 'k' | 'l' | 'M' | 'm'
| 'S' | 's' | 'U' | 'u' | 'V' | 'W' | 'w' | 'Y' | 'y',
) => (),
Some(c) => return Err(format!("invalid format specifier: %{}{}", n, c)),
Some(c) => return Err(format!("invalid format specifier: %{n}{c}")),
None => return Err("missing format specifier".to_owned()),
},
Some(
@ -223,10 +232,10 @@ pub fn validate_time_format(formatter: &str) -> Result<String, String> {
) => (),
Some(n @ ('3' | '6' | '9')) => match chars.next() {
Some('f') => (),
Some(c) => return Err(format!("invalid format specifier: %{}{}", n, c)),
Some(c) => return Err(format!("invalid format specifier: %{n}{c}")),
None => return Err("missing format specifier".to_owned()),
},
Some(c) => return Err(format!("invalid format specifier: %{}", c)),
Some(c) => return Err(format!("invalid format specifier: %{c}")),
None => return Err("missing format specifier".to_owned()),
},
None => break,
@ -235,3 +244,19 @@ pub fn validate_time_format(formatter: &str) -> Result<String, String> {
}
Ok(formatter.to_owned())
}
// Wrapper for value_parser to simply remove non supported option (mainly git flag)
// required since value_parser requires impl Into<ValueParser> that Vec do not support
// should be located here, since this file is included by build.rs
struct LabelFilter<Filter: Fn(&'static str) -> bool, const C: usize>([&'static str; C], Filter);
impl<Filter: Fn(&'static str) -> bool, const C: usize> From<LabelFilter<Filter, C>>
for clap::builder::ValueParser
{
fn from(label_filter: LabelFilter<Filter, C>) -> Self {
let filter = label_filter.1;
let values = label_filter.0.into_iter().filter(|x| filter(x));
let inner = clap::builder::PossibleValuesParser::from(values);
Self::from(inner)
}
}

View file

@ -4,6 +4,7 @@ use lscolors::{Indicator, LsColors};
use std::path::Path;
pub use crate::flags::color::ThemeOption;
use crate::git::GitStatus;
use crate::theme::{color::ColorTheme, Theme};
#[allow(dead_code)]
@ -61,6 +62,10 @@ pub enum Elem {
},
TreeEdge,
GitStatus {
status: GitStatus,
},
}
impl Elem {
@ -121,6 +126,7 @@ impl Elem {
Elem::TreeEdge => theme.tree_edge,
Elem::Links { valid: false } => theme.links.invalid,
Elem::Links { valid: true } => theme.links.valid,
Elem::GitStatus { .. } => theme.git_status.default,
}
}
}
@ -389,6 +395,7 @@ mod elem {
invalid: Color::AnsiValue(245), // Grey
},
tree_edge: Color::AnsiValue(245), // Grey
git_status: Default::default(),
}
}

View file

@ -203,7 +203,7 @@ classic: false
# == Blocks ==
# This specifies the columns and their order when using the long and the tree
# layout.
# Possible values: permission, user, group, context, size, date, name, inode
# Possible values: permission, user, group, context, size, date, name, inode, git
blocks:
- permission
- user
@ -388,7 +388,7 @@ mod tests {
total_size: Some(false),
symlink_arrow: Some("".into()),
hyperlink: Some(HyperlinkOption::Never),
header: None
header: None,
},
c
);

View file

@ -1,7 +1,9 @@
use crate::color::Colors;
use crate::display;
use crate::flags::{ColorOption, Display, Flags, HyperlinkOption, Layout, SortOrder, ThemeOption};
use crate::git::GitCache;
use crate::icon::Icons;
use crate::meta::Meta;
use crate::{print_error, print_output, sort, ExitCode};
use std::path::PathBuf;
@ -11,6 +13,8 @@ use std::io;
#[cfg(not(target_os = "windows"))]
use std::os::unix::io::AsRawFd;
use crate::flags::blocks::Block;
use crate::git_theme::GitTheme;
#[cfg(target_os = "windows")]
use terminal_size::terminal_size;
@ -18,6 +22,7 @@ pub struct Core {
flags: Flags,
icons: Icons,
colors: Colors,
git_theme: GitTheme,
sorters: Vec<(SortOrder, sort::SortFn)>,
}
@ -75,6 +80,7 @@ impl Core {
flags,
colors: Colors::new(color_theme),
icons: Icons::new(tty_available, icon_when, icon_theme, icon_separator),
git_theme: GitTheme::new(),
sorters,
}
}
@ -106,12 +112,19 @@ impl Core {
}
};
let cache = if self.flags.blocks.0.contains(&Block::GitStatus) {
Some(GitCache::new(&path))
} else {
None
};
let recurse =
self.flags.layout == Layout::Tree || self.flags.display != Display::DirectoryOnly;
if recurse {
match meta.recurse_into(depth, &self.flags) {
match meta.recurse_into(depth, &self.flags, cache.as_ref()) {
Ok((content, path_exit_code)) => {
meta.content = content;
meta.git_status = cache.and_then(|cache| cache.get(&meta.path, true));
meta_list.push(meta);
exit_code.set_if_greater(path_exit_code);
}
@ -122,6 +135,7 @@ impl Core {
}
};
} else {
meta.git_status = cache.and_then(|cache| cache.get(&meta.path, true));
meta_list.push(meta);
};
}
@ -147,9 +161,21 @@ impl Core {
fn display(&self, metas: &[Meta]) {
let output = if self.flags.layout == Layout::Tree {
display::tree(metas, &self.flags, &self.colors, &self.icons)
display::tree(
metas,
&self.flags,
&self.colors,
&self.icons,
&self.git_theme,
)
} else {
display::grid(metas, &self.flags, &self.colors, &self.icons)
display::grid(
metas,
&self.flags,
&self.colors,
&self.icons,
&self.git_theme,
)
};
print_output!("{}", output);

View file

@ -1,5 +1,7 @@
use crate::color::{Colors, Elem};
use crate::flags::{Block, Display, Flags, HyperlinkOption, Layout};
use crate::flags::blocks::Block;
use crate::flags::{Display, Flags, HyperlinkOption, Layout};
use crate::git_theme::GitTheme;
use crate::icon::Icons;
use crate::meta::name::DisplayOption;
use crate::meta::{FileType, Meta};
@ -13,7 +15,13 @@ const LINE: &str = "\u{2502} "; // "│ "
const CORNER: &str = "\u{2514}\u{2500}\u{2500}"; // "└──"
const BLANK: &str = " ";
pub fn grid(metas: &[Meta], flags: &Flags, colors: &Colors, icons: &Icons) -> String {
pub fn grid(
metas: &[Meta],
flags: &Flags,
colors: &Colors,
icons: &Icons,
git_theme: &GitTheme,
) -> String {
let term_width = terminal_size().map(|(w, _)| w.0 as usize);
inner_display_grid(
@ -22,12 +30,19 @@ pub fn grid(metas: &[Meta], flags: &Flags, colors: &Colors, icons: &Icons) -> St
flags,
colors,
icons,
git_theme,
0,
term_width,
)
}
pub fn tree(metas: &[Meta], flags: &Flags, colors: &Colors, icons: &Icons) -> String {
pub fn tree(
metas: &[Meta],
flags: &Flags,
colors: &Colors,
icons: &Icons,
git_theme: &GitTheme,
) -> String {
let mut grid = Grid::new(GridOptions {
filling: Filling::Spaces(1),
direction: Direction::LeftToRight,
@ -42,19 +57,30 @@ pub fn tree(metas: &[Meta], flags: &Flags, colors: &Colors, icons: &Icons) -> St
}
}
for cell in inner_display_tree(metas, flags, colors, icons, (0, ""), &padding_rules, index) {
for cell in inner_display_tree(
metas,
flags,
colors,
icons,
git_theme,
(0, ""),
&padding_rules,
index,
) {
grid.add(cell);
}
grid.fit_into_columns(flags.blocks.0.len()).to_string()
}
#[allow(clippy::too_many_arguments)] // should wrap flags, colors, icons, git_theme into one struct
fn inner_display_grid(
display_option: &DisplayOption,
metas: &[Meta],
flags: &Flags,
colors: &Colors,
icons: &Icons,
git_theme: &GitTheme,
depth: usize,
term_width: Option<usize>,
) -> String {
@ -93,6 +119,7 @@ fn inner_display_grid(
meta,
colors,
icons,
git_theme,
flags,
display_option,
&padding_rules,
@ -152,6 +179,7 @@ fn inner_display_grid(
flags,
colors,
icons,
git_theme,
depth + 1,
term_width,
);
@ -192,11 +220,13 @@ fn add_header(flags: &Flags, cells: &[Cell], grid: &mut Grid) {
}
}
#[allow(clippy::too_many_arguments)]
fn inner_display_tree(
metas: &[Meta],
flags: &Flags,
colors: &Colors,
icons: &Icons,
git_theme: &GitTheme,
tree_depth_prefix: (usize, &str),
padding_rules: &HashMap<Block, usize>,
tree_index: usize,
@ -220,6 +250,7 @@ fn inner_display_tree(
meta,
colors,
icons,
git_theme,
flags,
&DisplayOption::FileName,
padding_rules,
@ -248,6 +279,7 @@ fn inner_display_tree(
flags,
colors,
icons,
git_theme,
(tree_depth_prefix.0 + 1, &new_prefix),
padding_rules,
tree_index,
@ -279,10 +311,12 @@ fn display_folder_path(meta: &Meta) -> String {
format!("\n{}:\n", meta.path.to_string_lossy())
}
#[allow(clippy::too_many_arguments)]
fn get_output(
meta: &Meta,
colors: &Colors,
icons: &Icons,
git_theme: &GitTheme,
flags: &Flags,
display_option: &DisplayOption,
padding_rules: &HashMap<Block, usize>,
@ -366,6 +400,11 @@ fn get_output(
block_vec.push(meta.symlink.render(colors, flags))
}
}
Block::GitStatus => {
if let Some(_s) = &meta.git_status {
block_vec.push(_s.render(colors, git_theme));
}
}
};
strings.push(
block_vec
@ -457,6 +496,7 @@ mod tests {
use assert_fs::prelude::*;
use clap::Parser;
use std::path::Path;
use tempfile::tempdir;
#[test]
fn test_display_get_visible_width_without_icons() {
@ -559,8 +599,7 @@ mod tests {
// check if the color is present.
assert!(
output.starts_with("\u{1b}[38;5;"),
"{:?} should start with color",
output,
"{output:?} should start with color"
);
assert!(output.ends_with("[39m"), "reset foreground color");
@ -646,7 +685,7 @@ mod tests {
dir.child("one.d/.hidden").touch().unwrap();
let mut metas = Meta::from_path(Path::new(dir.path()), false)
.unwrap()
.recurse_into(42, &flags)
.recurse_into(42, &flags, None)
.unwrap()
.0
.unwrap();
@ -656,6 +695,7 @@ mod tests {
&flags,
&Colors::new(color::ThemeOption::NoColor),
&Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()),
&GitTheme::new(),
);
assert_eq!("one.d\n├── .hidden\n└── two\n", output);
@ -678,7 +718,7 @@ mod tests {
dir.child("dir/file").touch().unwrap();
let metas = Meta::from_path(Path::new(dir.path()), false)
.unwrap()
.recurse_into(42, &flags)
.recurse_into(42, &flags, None)
.unwrap()
.0
.unwrap();
@ -687,6 +727,7 @@ mod tests {
&flags,
&Colors::new(color::ThemeOption::NoColor),
&Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()),
&GitTheme::new(),
);
let length_before_b = |i| -> usize {
@ -718,7 +759,7 @@ mod tests {
dir.child("dir/file").touch().unwrap();
let metas = Meta::from_path(Path::new(dir.path()), false)
.unwrap()
.recurse_into(42, &flags)
.recurse_into(42, &flags, None)
.unwrap()
.0
.unwrap();
@ -727,6 +768,7 @@ mod tests {
&flags,
&Colors::new(color::ThemeOption::NoColor),
&Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()),
&GitTheme::new(),
);
assert_eq!(output.lines().nth(1).unwrap().chars().next().unwrap(), '└');
@ -757,7 +799,7 @@ mod tests {
dir.child("one.d/two").touch().unwrap();
let metas = Meta::from_path(Path::new(dir.path()), false)
.unwrap()
.recurse_into(42, &flags)
.recurse_into(42, &flags, None)
.unwrap()
.0
.unwrap();
@ -766,6 +808,7 @@ mod tests {
&flags,
&Colors::new(color::ThemeOption::NoColor),
&Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()),
&GitTheme::new(),
);
assert!(output.ends_with("└── two\n"));
@ -787,7 +830,7 @@ mod tests {
dir.child("test").touch().unwrap();
let metas = Meta::from_path(Path::new(dir.path()), false)
.unwrap()
.recurse_into(1, &flags)
.recurse_into(1, &flags, None)
.unwrap()
.0
.unwrap();
@ -796,6 +839,7 @@ mod tests {
&flags,
&Colors::new(color::ThemeOption::NoColor),
&Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()),
&GitTheme::new(),
);
dir.close().unwrap();
@ -820,7 +864,7 @@ mod tests {
dir.child("testdir").create_dir_all().unwrap();
let metas = Meta::from_path(Path::new(dir.path()), false)
.unwrap()
.recurse_into(1, &flags)
.recurse_into(1, &flags, None)
.unwrap()
.0
.unwrap();
@ -829,6 +873,7 @@ mod tests {
&flags,
&Colors::new(color::ThemeOption::NoColor),
&Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()),
&GitTheme::new(),
);
dir.close().unwrap();
@ -840,4 +885,115 @@ mod tests {
assert!(!output.contains("Date Modified"));
assert!(!output.contains("Name"));
}
#[test]
fn test_folder_path() {
let tmp_dir = tempdir().expect("failed to create temp dir");
let file_path = tmp_dir.path().join("file");
std::fs::File::create(&file_path).expect("failed to create the file");
let file = Meta::from_path(&file_path, false).unwrap();
let dir_path = tmp_dir.path().join("dir");
std::fs::create_dir(&dir_path).expect("failed to create the dir");
let dir = Meta::from_path(&dir_path, false).unwrap();
assert_eq!(
display_folder_path(&dir),
format!(
"\n{}{}dir:\n",
tmp_dir.path().to_string_lossy(),
std::path::MAIN_SEPARATOR
)
);
const YES: bool = true;
const NO: bool = false;
assert_eq!(
should_display_folder_path(0, &[file.clone()], &Flags::default()),
YES // doesn't matter since there is no folder
);
assert_eq!(
should_display_folder_path(0, &[dir.clone()], &Flags::default()),
NO
);
assert_eq!(
should_display_folder_path(0, &[file.clone(), dir.clone()], &Flags::default()),
YES
);
assert_eq!(
should_display_folder_path(0, &[dir.clone(), dir.clone()], &Flags::default()),
YES
);
assert_eq!(
should_display_folder_path(0, &[file.clone(), file.clone()], &Flags::default()),
YES // doesn't matter since there is no folder
);
drop(dir); // to avoid clippy complains about previous .clone()
drop(file);
}
#[cfg(unix)]
#[test]
fn test_folder_path_with_links() {
let tmp_dir = tempdir().expect("failed to create temp dir");
let file_path = tmp_dir.path().join("file");
std::fs::File::create(&file_path).expect("failed to create the file");
let file = Meta::from_path(&file_path, false).unwrap();
let dir_path = tmp_dir.path().join("dir");
std::fs::create_dir(&dir_path).expect("failed to create the dir");
let dir = Meta::from_path(&dir_path, false).unwrap();
let link_path = tmp_dir.path().join("link");
std::os::unix::fs::symlink("dir", &link_path).unwrap();
let link = Meta::from_path(&link_path, false).unwrap();
let grid_flags = Flags {
layout: Layout::Grid,
..Flags::default()
};
let oneline_flags = Flags {
layout: Layout::OneLine,
..Flags::default()
};
const YES: bool = true;
const NO: bool = false;
assert_eq!(
should_display_folder_path(0, &[link.clone()], &grid_flags),
NO
);
assert_eq!(
should_display_folder_path(0, &[link.clone()], &oneline_flags),
YES // doesn't matter since this link will be expanded as a directory
);
assert_eq!(
should_display_folder_path(0, &[file.clone(), link.clone()], &grid_flags),
YES
);
assert_eq!(
should_display_folder_path(0, &[file.clone(), link.clone()], &oneline_flags),
YES // doesn't matter since this link will be expanded as a directory
);
assert_eq!(
should_display_folder_path(0, &[dir.clone(), link.clone()], &grid_flags),
YES
);
assert_eq!(
should_display_folder_path(0, &[dir.clone(), link.clone()], &oneline_flags),
YES
);
drop(dir); // to avoid clippy complains about previous .clone()
drop(file);
drop(link);
}
}

View file

@ -17,7 +17,6 @@ pub mod symlink_arrow;
pub mod symlinks;
pub mod total_size;
pub use blocks::Block;
pub use blocks::Blocks;
pub use color::Color;
pub use color::{ColorOption, ThemeOption};

View file

@ -66,6 +66,28 @@ impl Blocks {
None => self.0.insert(0, Block::Context),
}
}
/// Checks whether `self` already contains a [Block] of variant [GitStatus](Block::GitSatus).
fn contains_git_status(&self) -> bool {
self.0.contains(&Block::GitStatus)
}
/// Put a [Block] of variant [GitStatus](Block::GitSatus) on the left of [GitStatus](Block::Name) to `self`.
fn add_git_status(&mut self) {
if let Some(position) = self.0.iter().position(|&b| b == Block::Name) {
self.0.insert(position, Block::GitStatus);
} else {
self.0.push(Block::GitStatus);
}
}
/// Prepends a [Block] of variant [GitStatus](Block::GitSatus), if `self` does not already contain a
/// Block of that variant.
fn optional_add_git_status(&mut self) {
if !self.contains_git_status() {
self.add_git_status()
}
}
}
impl Configurable<Self> for Blocks {
@ -103,6 +125,10 @@ impl Configurable<Self> for Blocks {
blocks.optional_prepend_inode();
}
if !cfg!(feature = "no-git") && cli.git && cli.long {
blocks.optional_add_git_status();
}
blocks
}
@ -168,6 +194,7 @@ pub enum Block {
Name,
INode,
Links,
GitStatus,
}
impl Block {
@ -183,6 +210,7 @@ impl Block {
Block::SizeValue => "SizeValue",
Block::Date => "Date Modified",
Block::Name => "Name",
Block::GitStatus => "Git",
}
}
}
@ -202,6 +230,7 @@ impl TryFrom<&str> for Block {
"name" => Ok(Self::Name),
"inode" => Ok(Self::INode),
"links" => Ok(Self::Links),
"git" => Ok(Self::GitStatus),
_ => Err(format!("Not a valid block name: {string}")),
}
}
@ -371,6 +400,30 @@ mod test_blocks {
assert_eq!(Blocks::from_cli(&cli), Some(test_blocks));
}
#[cfg(not(feature = "no-git"))]
#[test]
fn test_from_cli_implicit_add_git_block() {
let argv = vec![
"lsd",
"--blocks",
"permission,name,group,date",
"--git",
"--long",
];
let cli = Cli::try_parse_from(argv).unwrap();
let test_blocks = Blocks(vec![
Block::Permission,
Block::GitStatus,
Block::Name,
Block::Group,
Block::Date,
]);
assert_eq!(
Blocks::configure_from(&cli, &Config::with_none()),
test_blocks
);
}
#[test]
fn test_from_config_none() {
assert_eq!(None, Blocks::from_config(&Config::with_none()));
@ -541,5 +594,11 @@ mod test_block {
assert_eq!(Block::SizeValue.get_header(), "SizeValue");
assert_eq!(Block::Date.get_header(), "Date Modified");
assert_eq!(Block::Name.get_header(), "Name");
assert_eq!(Block::GitStatus.get_header(), "Git");
}
#[test]
fn test_git_status() {
assert_eq!(Ok(Block::GitStatus), Block::try_from("git"));
}
}

View file

@ -44,6 +44,7 @@ pub enum SortColumn {
Time,
Size,
Version,
GitStatus,
}
impl Configurable<Self> for SortColumn {
@ -62,6 +63,8 @@ impl Configurable<Self> for SortColumn {
Some(Self::Extension)
} else if cli.versionsort || sort == Some("version") {
Some(Self::Version)
} else if cli.gitsort || sort == Some("git") {
Some(Self::GitStatus)
} else if cli.no_sort || sort == Some("none") {
Some(Self::None)
} else {
@ -212,6 +215,13 @@ mod test_sort_column {
assert_eq!(Some(SortColumn::Size), SortColumn::from_cli(&cli));
}
#[test]
fn test_from_cli_git() {
let argv = ["lsd", "--gitsort"];
let cli = Cli::try_parse_from(argv).unwrap();
assert_eq!(Some(SortColumn::GitStatus), SortColumn::from_cli(&cli));
}
#[test]
fn test_from_cli_version() {
let argv = ["lsd", "--versionsort"];
@ -249,6 +259,14 @@ mod test_sort_column {
assert_eq!(Some(SortColumn::None), SortColumn::from_cli(&cli));
}
#[cfg(not(feature = "no-git"))]
#[test]
fn test_from_arg_cli_sort_git() {
let argv = ["lsd", "--sort", "git"];
let cli = Cli::try_parse_from(argv).unwrap();
assert_eq!(Some(SortColumn::GitStatus), SortColumn::from_cli(&cli));
}
#[test]
fn test_multi_sort() {
let argv = ["lsd", "--sort", "size", "--sort", "time"];
@ -334,6 +352,17 @@ mod test_sort_column {
});
assert_eq!(Some(SortColumn::Version), SortColumn::from_config(&c));
}
#[test]
fn test_from_config_git_status() {
let mut c = Config::with_none();
c.sorting = Some(Sorting {
column: Some(SortColumn::GitStatus),
reverse: None,
dir_grouping: None,
});
assert_eq!(Some(SortColumn::GitStatus), SortColumn::from_config(&c));
}
}
#[cfg(test)]

460
src/git.rs Normal file
View file

@ -0,0 +1,460 @@
use crate::meta::git_file_status::GitFileStatus;
use std::path::{Path, PathBuf};
#[allow(dead_code)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
pub enum GitStatus {
/// No status info
#[default]
Default,
/// No changes (got from git status)
Unmodified,
/// Entry is ignored item in workdir
Ignored,
/// Entry does not exist in old version (now in stage)
NewInIndex,
/// Entry does not exist in old version (not in stage)
NewInWorkdir,
/// Type of entry changed between old and new
Typechange,
/// Entry does not exist in new version
Deleted,
/// Entry was renamed between old and new
Renamed,
/// Entry content changed between old and new
Modified,
/// Entry in the index is conflicted
Conflicted,
}
pub struct GitCache {
#[cfg(not(feature = "no-git"))]
statuses: Vec<(PathBuf, git2::Status)>,
}
#[cfg(feature = "no-git")]
impl GitCache {
pub fn new(_: &Path) -> Self {
Self {}
}
pub fn get(&self, _filepath: &PathBuf, _is_directory: bool) -> Option<GitFileStatus> {
None
}
}
#[cfg(not(feature = "no-git"))]
impl GitCache {
pub fn new(path: &Path) -> GitCache {
let repo = match git2::Repository::discover(path) {
Ok(r) => r,
Err(_e) => {
// Unable to retrieve Git info; it doesn't seem to be a git directory
return Self::empty();
}
};
if let Some(workdir) = repo.workdir().and_then(|x| std::fs::canonicalize(x).ok()) {
let mut statuses = Vec::new();
// Retrieving Git statuses for workdir
match repo.statuses(None) {
Ok(status_list) => {
for status_entry in status_list.iter() {
// git2-rs provides / separated path even on Windows. We have to rebuild it
let str_path = status_entry.path().unwrap();
let path: PathBuf =
str_path.split('/').collect::<Vec<_>>().iter().collect();
let path = workdir.join(path);
let elem = (path, status_entry.status());
statuses.push(elem);
}
}
Err(err) => {
crate::print_error!(
"Cannot retrieve Git statuses for directory {:?}: {}",
workdir,
err
);
}
}
GitCache { statuses }
} else {
// No workdir
Self::empty()
}
}
pub fn empty() -> Self {
GitCache {
statuses: Vec::new(),
}
}
pub fn get(&self, filepath: &PathBuf, is_directory: bool) -> Option<GitFileStatus> {
match std::fs::canonicalize(filepath) {
Ok(filename) => Some(self.inner_get(&filename, is_directory)),
Err(err) => {
crate::print_error!("Cannot get git status for {:?}: {}", filepath, err);
None
}
}
}
fn inner_get(&self, filepath: &PathBuf, is_directory: bool) -> GitFileStatus {
if is_directory {
self.statuses
.iter()
.filter(|&x| x.0.starts_with(filepath))
.map(|x| GitFileStatus::new(x.1))
.fold(GitFileStatus::default(), |acc, x| GitFileStatus {
index: std::cmp::max(acc.index, x.index),
workdir: std::cmp::max(acc.workdir, x.workdir),
})
} else {
self.statuses
.iter()
.find(|&x| filepath == &x.0)
.map(|e| GitFileStatus::new(e.1))
.unwrap_or_default()
}
}
}
#[cfg(not(feature = "no-git"))]
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::prelude::*;
use assert_fs::TempDir;
use git2::build::CheckoutBuilder;
use git2::{CherrypickOptions, Index, Oid, Repository, RepositoryInitOptions};
use std::collections::HashMap;
use std::fs::remove_file;
#[allow(unused)]
use std::process::Command;
#[test]
fn compare_git_status() {
assert!(GitStatus::Unmodified < GitStatus::Conflicted);
}
macro_rules! t {
($e:expr) => {
match $e {
Ok(e) => e,
Err(e) => panic!("{} failed with {}", stringify!($e), e),
}
};
}
fn repo_init() -> (TempDir, Repository) {
let td = t!(TempDir::new());
let mut opts = RepositoryInitOptions::new();
opts.initial_head("master");
let repo = Repository::init_opts(td.path(), &opts).unwrap();
{
let mut config = t!(repo.config());
t!(config.set_str("user.name", "name"));
t!(config.set_str("user.email", "email"));
let mut index = t!(repo.index());
let id = t!(index.write_tree());
let tree = t!(repo.find_tree(id));
let sig = t!(repo.signature());
t!(repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[]));
}
(td, repo)
}
fn commit(repo: &Repository, index: &mut Index, msg: &str) -> (Oid, Oid) {
let tree_id = t!(index.write_tree());
let tree = t!(repo.find_tree(tree_id));
let sig = t!(repo.signature());
let head_id = t!(repo.refname_to_id("HEAD"));
let parent = t!(repo.find_commit(head_id));
let commit = t!(repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &[&parent]));
(commit, tree_id)
}
fn check_cache(root: &Path, statuses: &HashMap<&PathBuf, GitFileStatus>, msg: &str) {
let cache = GitCache::new(root);
for (&path, status) in statuses.iter() {
if let Ok(filename) = std::fs::canonicalize(&root.join(path)) {
let is_directory = filename.is_dir();
assert_eq!(
&cache.inner_get(&filename, is_directory),
status,
"Invalid status for file {} at stage {}",
filename.to_string_lossy(),
msg
);
}
}
}
#[test]
fn test_git_workflow() {
// rename as test_git_workflow
let (root, repo) = repo_init();
let mut index = repo.index().unwrap();
let mut expected_statuses = HashMap::new();
// Check now
check_cache(root.path(), &expected_statuses, "initialization");
let f0 = PathBuf::from(".gitignore");
root.child(&f0).write_str("*.bak").unwrap();
expected_statuses.insert(
&f0,
GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::NewInWorkdir,
},
);
let _success = Command::new("git")
.current_dir(root.path())
.arg("status")
.status()
.expect("Git status failed")
.success();
// Check now
check_cache(root.path(), &expected_statuses, "new .gitignore");
index.add_path(f0.as_path()).unwrap();
// Check now
check_cache(root.path(), &expected_statuses, "unstaged .gitignore");
index.write().unwrap();
*expected_statuses.get_mut(&f0).unwrap() = GitFileStatus {
index: GitStatus::NewInIndex,
workdir: GitStatus::Unmodified,
};
// Check now
check_cache(root.path(), &expected_statuses, "staged .gitignore");
commit(&repo, &mut index, "Add gitignore");
*expected_statuses.get_mut(&f0).unwrap() = GitFileStatus {
index: GitStatus::Default,
workdir: GitStatus::Default,
};
// Check now
check_cache(root.path(), &expected_statuses, "Committed .gitignore");
let d1 = PathBuf::from("d1");
let f1 = d1.join("f1");
root.child(&f1).touch().unwrap();
let f2 = d1.join("f2.bak");
root.child(&f2).touch().unwrap();
expected_statuses.insert(
&d1,
GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::NewInWorkdir,
},
);
expected_statuses.insert(
&f1,
GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::NewInWorkdir,
},
);
expected_statuses.insert(
&f2,
GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::Ignored,
},
);
// Check now
check_cache(root.path(), &expected_statuses, "New files");
index.add_path(f1.as_path()).unwrap();
index.write().unwrap();
*expected_statuses.get_mut(&d1).unwrap() = GitFileStatus {
index: GitStatus::NewInIndex,
workdir: GitStatus::Ignored,
};
*expected_statuses.get_mut(&f1).unwrap() = GitFileStatus {
index: GitStatus::NewInIndex,
workdir: GitStatus::Unmodified,
};
// Check now
check_cache(root.path(), &expected_statuses, "Unstaged new files");
index.add_path(f2.as_path()).unwrap();
index.write().unwrap();
*expected_statuses.get_mut(&d1).unwrap() = GitFileStatus {
index: GitStatus::NewInIndex,
workdir: GitStatus::Unmodified,
};
*expected_statuses.get_mut(&f2).unwrap() = GitFileStatus {
index: GitStatus::NewInIndex,
workdir: GitStatus::Unmodified,
};
// Check now
check_cache(root.path(), &expected_statuses, "Staged new files");
let (commit1_oid, _) = commit(&repo, &mut index, "Add new files");
*expected_statuses.get_mut(&d1).unwrap() = GitFileStatus {
index: GitStatus::Default,
workdir: GitStatus::Default,
};
*expected_statuses.get_mut(&f1).unwrap() = GitFileStatus {
index: GitStatus::Default,
workdir: GitStatus::Default,
};
*expected_statuses.get_mut(&f2).unwrap() = GitFileStatus {
index: GitStatus::Default,
workdir: GitStatus::Default,
};
// Check now
check_cache(root.path(), &expected_statuses, "Committed new files");
remove_file(root.child(&f2).path()).unwrap();
*expected_statuses.get_mut(&d1).unwrap() = GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::Deleted,
};
*expected_statuses.get_mut(&f2).unwrap() = GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::Deleted,
};
// Check now
check_cache(root.path(), &expected_statuses, "Remove file");
root.child(&f1).write_str("New content").unwrap();
*expected_statuses.get_mut(&d1).unwrap() = GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::Modified,
}; // more important to see modified vs deleted ?
*expected_statuses.get_mut(&f1).unwrap() = GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::Modified,
};
// Check now
check_cache(root.path(), &expected_statuses, "Change file");
index.remove_path(&f2).unwrap();
index.write().unwrap();
*expected_statuses.get_mut(&d1).unwrap() = GitFileStatus {
index: GitStatus::Deleted,
workdir: GitStatus::Modified,
};
*expected_statuses.get_mut(&f2).unwrap() = GitFileStatus {
index: GitStatus::Deleted,
workdir: GitStatus::Unmodified,
};
// Check now
check_cache(root.path(), &expected_statuses, "Staged changes");
commit(&repo, &mut index, "Remove backup file");
*expected_statuses.get_mut(&d1).unwrap() = GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::Modified,
};
*expected_statuses.get_mut(&f2).unwrap() = GitFileStatus {
index: GitStatus::Default,
workdir: GitStatus::Default,
};
// Check now
check_cache(
root.path(),
&expected_statuses,
"Committed changes (first part)",
);
index.add_path(&f1).unwrap();
index.write().unwrap();
commit(&repo, &mut index, "Save modified file");
*expected_statuses.get_mut(&d1).unwrap() = GitFileStatus {
index: GitStatus::Default,
workdir: GitStatus::Default,
};
*expected_statuses.get_mut(&f1).unwrap() = GitFileStatus {
index: GitStatus::Default,
workdir: GitStatus::Default,
};
// Check now
check_cache(
root.path(),
&expected_statuses,
"Committed changes (second part)",
);
let branch_commit = repo.find_commit(commit1_oid).unwrap();
let branch = repo
.branch("conflict-branch", &branch_commit, true)
.unwrap();
repo.set_head(format!("refs/heads/{}", branch.name().unwrap().unwrap()).as_str())
.unwrap();
let mut checkout_opts = CheckoutBuilder::new();
checkout_opts.force();
repo.checkout_head(Some(&mut checkout_opts)).unwrap();
root.child(&f1)
.write_str("New conflicting content")
.unwrap();
root.child(&f2)
.write_str("New conflicting content")
.unwrap();
index.add_path(&f1).unwrap();
index.add_path(&f2).unwrap();
index.write().unwrap();
let (commit2_oid, _) = commit(&repo, &mut index, "Save conflicting changes");
// Check now
check_cache(
root.path(),
&expected_statuses,
"Committed changes in branch",
);
repo.set_head("refs/heads/master").unwrap();
repo.checkout_head(Some(&mut checkout_opts)).unwrap();
let mut cherrypick_opts = CherrypickOptions::new();
let branch_commit = repo.find_commit(commit2_oid).unwrap();
repo.cherrypick(&branch_commit, Some(&mut cherrypick_opts))
.unwrap();
*expected_statuses.get_mut(&d1).unwrap() = GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::Conflicted,
};
*expected_statuses.get_mut(&f1).unwrap() = GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::Conflicted,
};
*expected_statuses.get_mut(&f2).unwrap() = GitFileStatus {
index: GitStatus::Unmodified,
workdir: GitStatus::Conflicted,
};
// let _success = Command::new("git")
// .current_dir(root.path())
// .arg("status")
// .status()
// .expect("Git status failed")
// .success();
// Check now
check_cache(
root.path(),
&expected_statuses,
"Conflict between master and branch",
);
}
}

31
src/git_theme.rs Normal file
View file

@ -0,0 +1,31 @@
use crate::git::GitStatus;
use crate::theme::git::GitThemeSymbols;
pub struct GitTheme {
symbols: GitThemeSymbols,
}
impl GitTheme {
pub fn new() -> GitTheme {
let git_symbols = GitThemeSymbols::default();
Self {
symbols: git_symbols,
}
}
pub fn get_symbol(&self, status: &GitStatus) -> String {
let symbol = match status {
GitStatus::Default => &self.symbols.default,
GitStatus::Unmodified => &self.symbols.unmodified,
GitStatus::Ignored => &self.symbols.ignored,
GitStatus::NewInIndex => &self.symbols.new_in_index,
GitStatus::NewInWorkdir => &self.symbols.new_in_workdir,
GitStatus::Typechange => &self.symbols.typechange,
GitStatus::Deleted => &self.symbols.deleted,
GitStatus::Renamed => &self.symbols.renamed,
GitStatus::Modified => &self.symbols.modified,
GitStatus::Conflicted => &self.symbols.conflicted,
};
symbol.to_string()
}
}

View file

@ -224,7 +224,7 @@ mod test {
let tmp_dir = tempdir().expect("failed to create temp dir");
for (ext, file_icon) in &IconTheme::get_default_icons_by_extension() {
let file_path = tmp_dir.path().join(format!("file.{}", ext));
let file_path = tmp_dir.path().join(format!("file.{ext}"));
File::create(&file_path).expect("failed to create file");
let meta = Meta::from_path(&file_path, false).unwrap();

View file

@ -33,6 +33,8 @@ mod config_file;
mod core;
mod display;
mod flags;
mod git;
mod git_theme;
mod icon;
mod meta;
mod sort;

View file

@ -0,0 +1,67 @@
use crate::color::{self, ColoredString, Colors};
use crate::git::GitStatus;
use crate::git_theme::GitTheme;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct GitFileStatus {
pub index: GitStatus,
pub workdir: GitStatus,
}
impl Default for GitFileStatus {
fn default() -> Self {
Self {
index: GitStatus::Default,
workdir: GitStatus::Default,
}
}
}
impl GitFileStatus {
#[cfg(not(feature = "no-git"))]
pub fn new(status: git2::Status) -> Self {
Self {
index: match status {
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::NewInIndex,
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Deleted,
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::Typechange,
_ => GitStatus::Unmodified,
},
workdir: match status {
s if s.contains(git2::Status::WT_NEW) => GitStatus::NewInWorkdir,
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Deleted,
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
s if s.contains(git2::Status::IGNORED) => GitStatus::Ignored,
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::Typechange,
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflicted,
_ => GitStatus::Unmodified,
},
}
}
pub fn render(&self, colors: &Colors, git_theme: &GitTheme) -> ColoredString {
let res = [
colors.colorize(
git_theme.get_symbol(&self.index),
&color::Elem::GitStatus { status: self.index },
),
colors.colorize(
git_theme.get_symbol(&self.workdir),
&color::Elem::GitStatus {
status: self.workdir,
},
),
]
.into_iter()
// From the experiment, the maximum string size is 153 bytes
.fold(String::with_capacity(160), |mut acc, x| {
acc.push_str(&x.to_string());
acc
});
ColoredString::new(Colors::default_style(), res)
}
}

View file

@ -1,6 +1,7 @@
mod access_control;
mod date;
mod filetype;
pub mod git_file_status;
mod indicator;
mod inode;
mod links;
@ -17,6 +18,7 @@ mod windows_utils;
pub use self::access_control::AccessControl;
pub use self::date::Date;
pub use self::filetype::FileType;
pub use self::git_file_status::GitFileStatus;
pub use self::indicator::Indicator;
pub use self::inode::INode;
pub use self::links::Links;
@ -30,6 +32,7 @@ pub use crate::icon::Icons;
use crate::flags::{Display, Flags, Layout};
use crate::{print_error, ExitCode};
use crate::git::GitCache;
use std::io::{self, Error, ErrorKind};
use std::path::{Component, Path, PathBuf};
@ -48,6 +51,7 @@ pub struct Meta {
pub links: Option<Links>,
pub content: Option<Vec<Meta>>,
pub access_control: Option<AccessControl>,
pub git_status: Option<GitFileStatus>,
}
impl Meta {
@ -55,6 +59,7 @@ impl Meta {
&self,
depth: usize,
flags: &Flags,
cache: Option<&GitCache>,
) -> io::Result<(Option<Vec<Meta>>, ExitCode)> {
if depth == 0 {
return Ok((None, ExitCode::OK));
@ -94,6 +99,9 @@ impl Meta {
Self::from_path(&self.path.join(Component::ParentDir), flags.dereference.0)?;
parent_meta.name.name = "..".to_owned();
current_meta.git_status = cache.and_then(|cache| cache.get(&current_meta.path, true));
parent_meta.git_status = cache.and_then(|cache| cache.get(&parent_meta.path, true));
content.push(current_meta);
content.push(parent_meta);
}
@ -150,7 +158,7 @@ impl Meta {
// check dereferencing
if flags.dereference.0 || !matches!(entry_meta.file_type, FileType::SymLink { .. }) {
match entry_meta.recurse_into(depth - 1, flags) {
match entry_meta.recurse_into(depth - 1, flags, cache) {
Ok((content, rec_exit_code)) => {
entry_meta.content = content;
exit_code.set_if_greater(rec_exit_code);
@ -163,6 +171,9 @@ impl Meta {
};
}
let is_directory = entry.file_type()?.is_dir();
entry_meta.git_status =
cache.and_then(|cache| cache.get(&entry_meta.path, is_directory));
content.push(entry_meta);
}
@ -300,6 +311,7 @@ impl Meta {
file_type,
content: None,
access_control,
git_status: None,
})
}
}

View file

@ -120,7 +120,7 @@ impl Name {
if let Ok(url) = Url::from_file_path(rp) {
// Crossterm does not support hyperlinks as of now
// https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
format!("\x1B]8;;{}\x1B\x5C{}\x1B]8;;\x1B\x5C", url, name)
format!("\x1B]8;;{url}\x1B\x5C{name}\x1B]8;;\x1B\x5C")
} else {
print_error!("{}: unable to form url.", name);
name

View file

@ -23,6 +23,7 @@ pub fn assemble_sorters(flags: &Flags) -> Vec<(SortOrder, SortFn)> {
SortColumn::Time => sorters.push((flags.sorting.order, by_date)),
SortColumn::Version => sorters.push((flags.sorting.order, by_version)),
SortColumn::Extension => sorters.push((flags.sorting.order, by_extension)),
SortColumn::GitStatus => sorters.push((flags.sorting.order, by_git_status)),
SortColumn::None => {}
}
sorters
@ -72,6 +73,10 @@ fn by_extension(a: &Meta, b: &Meta) -> Ordering {
a.name.extension().cmp(&b.name.extension())
}
fn by_git_status(a: &Meta, b: &Meta) -> Ordering {
a.git_status.cmp(&b.git_status)
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -1,4 +1,5 @@
pub mod color;
pub mod git;
pub mod icon;
use std::path::Path;
@ -11,6 +12,7 @@ use crate::config_file;
use crate::print_error;
use color::ColorTheme;
use git::GitThemeSymbols;
use icon::IconTheme;
#[derive(Debug, Deserialize, Default, PartialEq, Eq)]
@ -20,6 +22,7 @@ use icon::IconTheme;
pub struct Theme {
pub color: ColorTheme,
pub icon: IconTheme,
pub git_theme: GitThemeSymbols,
}
#[derive(Error, Debug)]

View file

@ -96,6 +96,7 @@ pub struct ColorTheme {
#[serde(deserialize_with = "deserialize_color")]
pub tree_edge: Color,
pub links: Links,
pub git_status: GitStatus,
#[serde(skip)]
pub file_type: FileType,
@ -233,6 +234,15 @@ pub struct Links {
pub invalid: Color,
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[serde(deny_unknown_fields)]
#[serde(default)]
pub struct GitStatus {
#[serde(deserialize_with = "deserialize_color")]
pub default: Color,
}
impl Default for Permission {
fn default() -> Self {
Permission {
@ -324,6 +334,14 @@ impl Default for Links {
}
}
impl Default for GitStatus {
fn default() -> Self {
GitStatus {
default: Color::AnsiValue(13), // Pink
}
}
}
impl Default for ColorTheme {
fn default() -> Self {
// TODO(zwpaper): check terminal color and return light or dark
@ -343,6 +361,7 @@ impl ColorTheme {
inode: INode::default(),
links: Links::default(),
tree_edge: Color::AnsiValue(245), // Grey
git_status: Default::default(),
}
}
}

35
src/theme/git.rs Normal file
View file

@ -0,0 +1,35 @@
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[serde(deny_unknown_fields)]
#[serde(default)]
pub struct GitThemeSymbols {
pub default: String,
pub unmodified: String,
pub new_in_index: String,
pub new_in_workdir: String,
pub deleted: String,
pub modified: String,
pub renamed: String,
pub ignored: String,
pub typechange: String,
pub conflicted: String,
}
impl Default for GitThemeSymbols {
fn default() -> GitThemeSymbols {
GitThemeSymbols {
default: "-".into(),
unmodified: ".".into(),
new_in_index: "N".into(),
new_in_workdir: "?".into(),
deleted: "D".into(),
modified: "M".into(),
renamed: "R".into(),
ignored: "I".into(),
typechange: "T".into(),
conflicted: "C".into(),
}
}
}