Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
57f5055
Initial plan
Copilot Mar 6, 2026
0bf8a92
Add --color flag and Visualizer coloring support
Copilot Mar 6, 2026
eb866f3
Address review feedback: Color enum, minimize const visibility, simpl…
Copilot Mar 6, 2026
80e99c3
Address second review round: rename Colorless→Normal, refactor is_dir…
Copilot Mar 6, 2026
33316ce
Address third review round: ColoredSlice, rename coloring_map/is_dir,…
Copilot Mar 6, 2026
f622673
feat: lscolors integration, multi-color support, render_row macro, de…
Copilot Mar 7, 2026
c0d0062
refactor: single LazyLock<AnsiPrefixes> + format_args! with row destr…
Copilot Mar 7, 2026
3c22b24
refactor: rename aligned_colored/aligned_normal to aligned_colored_sl…
Copilot Mar 7, 2026
9e47e4d
refactor: consolidate AnsiPrefixes at crate root; Coloring struct; Co…
Copilot Mar 7, 2026
ab5cc0a
refactor(r7): rename AnsiPrefixes→LsColors, private fields, restructu…
Copilot Mar 7, 2026
bc6faff
refactor(r8): rename field, minimize visibility, move color calc, div…
Copilot Mar 7, 2026
92cede4
test: add predefined LS_COLORS constant to color tests
Copilot Mar 7, 2026
1b2eb7f
Replace unsafe set_var in color_always test with LsColors FromStr
Copilot Mar 7, 2026
c78baaa
Address review comments: refactor LsColors, add color test without LS…
Copilot Mar 7, 2026
2c0b44f
fix: rename LsColors::from_str to from_ls_colors_string to fix clippy…
Copilot Mar 7, 2026
d78e31e
fix: use full PathBuf key in build_coloring_map to prevent basename c…
Copilot Mar 7, 2026
1d3a5d3
chore: remove dead src/ansi_prefixes.rs file
Copilot Mar 7, 2026
860d755
refactor: address review threads 71-75: remove Hash+Eq bounds, rename…
Copilot Mar 7, 2026
2f6425a
revert: remove color feature flag, make lscolors an unconditional dep…
Copilot Mar 7, 2026
bc2f468
refactor: use Vec<OsString> as coloring map keys instead of PathBuf
Copilot Mar 7, 2026
9e97f67
feat: use `from_str`
KSXGitHub Mar 7, 2026
d4416d4
refactor: use `.without_env`
KSXGitHub Mar 7, 2026
2580418
fix: fmt
KSXGitHub Mar 7, 2026
34ed31e
style: merge imports
KSXGitHub Mar 7, 2026
fdbd102
test: rename a test
KSXGitHub Mar 7, 2026
ba3e69f
test: change the workspace
KSXGitHub Mar 7, 2026
eb4848f
refactor: shorten an expression
KSXGitHub Mar 7, 2026
415de11
refactor: rearrange
KSXGitHub Mar 7, 2026
ac318a9
refactor: correct a name
KSXGitHub Mar 7, 2026
81a548a
perf: reduce unnecessary allocations
KSXGitHub Mar 7, 2026
868c886
refactor: replace qualified path with `use`
KSXGitHub Mar 7, 2026
2543757
refactor: use `.pipe_as_ref`
KSXGitHub Mar 7, 2026
b27a943
refactor: rename a variable
KSXGitHub Mar 7, 2026
2311f29
feat: hide some APIs
KSXGitHub Mar 7, 2026
8120a77
refactor: remove `format_args!`
KSXGitHub Mar 7, 2026
d09ff5d
refactor: deduplicate code
KSXGitHub Mar 7, 2026
398e43e
docs: add some TODOs
KSXGitHub Mar 7, 2026
2ad9147
docs: missing docs
KSXGitHub Mar 7, 2026
cf3e12b
refactor: correct a name
KSXGitHub Mar 7, 2026
ade71b1
feat: hide `AnsiPrefix`
KSXGitHub Mar 7, 2026
20d508b
refactor: add some TODOs
KSXGitHub Mar 7, 2026
aea511c
test: fix windows
KSXGitHub Mar 7, 2026
e7dee6b
refactor: move coloring logic from methods.rs into coloring.rs (#343)
KSXGitHub Mar 7, 2026
d0200ef
chore(git): merge from master
KSXGitHub Mar 15, 2026
7d8d39b
chore(git): merge from master
KSXGitHub Mar 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ rayon = "1.10.0"
rounded-div = "0.1.4"
serde = { version = "1.0.228", optional = true }
serde_json = { version = "1.0.149", optional = true }
lscolors = { version = "0.21", features = ["nu-ansi-term"] }
smart-default = "0.7.1"
sysinfo = "0.38.2"
terminal_size = "0.4.3"
Expand All @@ -83,3 +84,4 @@ maplit = "1.0.2"
normalize-path = "0.2.1"
pretty_assertions = "1.4.1"
rand = "0.10.0"
strip-ansi-escapes = "0.2.1"
11 changes: 11 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,17 @@ Do not output `.shared.details` in the JSON output.

Do not output `.shared.summary` in the JSON output.

<a id="color" name="color"></a>
### `--color`

* _Default:_ `auto`.
* _Choices:_
- `auto`: Detect if the output is a TTY and render colors accordingly
- `always`: Always render colors
- `never`: Never render colors

Whether to show colors.

<a id="option-h" name="option-h"></a><a id="help" name="help"></a>
### `--help`

Expand Down
6 changes: 5 additions & 1 deletion exports/completion.bash
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ _pdu() {

case "${cmd}" in
pdu)
opts="-b -H -q -d -w -m -s -p -h -V --json-input --json-output --bytes-format --detect-links --dedupe-links --deduplicate-hardlinks --top-down --align-right --quantity --depth --max-depth --width --total-width --column-width --min-ratio --no-sort --no-errors --silent-errors --progress --threads --omit-json-shared-details --omit-json-shared-summary --help --version [FILES]..."
opts="-b -H -q -d -w -m -s -p -h -V --json-input --json-output --bytes-format --detect-links --dedupe-links --deduplicate-hardlinks --top-down --align-right --quantity --depth --max-depth --width --total-width --column-width --min-ratio --no-sort --no-errors --silent-errors --progress --threads --omit-json-shared-details --omit-json-shared-summary --color --help --version [FILES]..."
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
Expand Down Expand Up @@ -85,6 +85,10 @@ _pdu() {
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--color)
COMPREPLY=($(compgen -W "auto always never" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
Expand Down
1 change: 1 addition & 0 deletions exports/completion.elv
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ set edit:completion:arg-completer[pdu] = {|@words|
cand -m 'Minimal size proportion required to appear'
cand --min-ratio 'Minimal size proportion required to appear'
cand --threads 'Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer'
cand --color 'Whether to show colors'
cand --json-input 'Read JSON data from stdin'
cand --json-output 'Print JSON data instead of an ASCII chart'
cand -H 'Detect and subtract the sizes of hardlinks from their parent directory totals'
Expand Down
3 changes: 3 additions & 0 deletions exports/completion.fish
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ complete -c pdu -s w -l total-width -l width -d 'Width of the visualization' -r
complete -c pdu -l column-width -d 'Maximum widths of the tree column and width of the bar column' -r
complete -c pdu -s m -l min-ratio -d 'Minimal size proportion required to appear' -r
complete -c pdu -l threads -d 'Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer' -r
complete -c pdu -l color -d 'Whether to show colors' -r -f -a "auto\t'Detect if the output is a TTY and render colors accordingly'
always\t'Always render colors'
never\t'Never render colors'"
complete -c pdu -l json-input -d 'Read JSON data from stdin'
complete -c pdu -l json-output -d 'Print JSON data instead of an ASCII chart'
complete -c pdu -s H -l deduplicate-hardlinks -l detect-links -l dedupe-links -d 'Detect and subtract the sizes of hardlinks from their parent directory totals'
Expand Down
1 change: 1 addition & 0 deletions exports/completion.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Register-ArgumentCompleter -Native -CommandName 'pdu' -ScriptBlock {
[CompletionResult]::new('-m', '-m', [CompletionResultType]::ParameterName, 'Minimal size proportion required to appear')
[CompletionResult]::new('--min-ratio', '--min-ratio', [CompletionResultType]::ParameterName, 'Minimal size proportion required to appear')
[CompletionResult]::new('--threads', '--threads', [CompletionResultType]::ParameterName, 'Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer')
[CompletionResult]::new('--color', '--color', [CompletionResultType]::ParameterName, 'Whether to show colors')
[CompletionResult]::new('--json-input', '--json-input', [CompletionResultType]::ParameterName, 'Read JSON data from stdin')
[CompletionResult]::new('--json-output', '--json-output', [CompletionResultType]::ParameterName, 'Print JSON data instead of an ASCII chart')
[CompletionResult]::new('-H', '-H ', [CompletionResultType]::ParameterName, 'Detect and subtract the sizes of hardlinks from their parent directory totals')
Expand Down
3 changes: 3 additions & 0 deletions exports/completion.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ block-count\:"Count numbers of blocks"))' \
'-m+[Minimal size proportion required to appear]:MIN_RATIO:_default' \
'--min-ratio=[Minimal size proportion required to appear]:MIN_RATIO:_default' \
'--threads=[Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer]:THREADS:_default' \
'--color=[Whether to show colors]:COLOR:((auto\:"Detect if the output is a TTY and render colors accordingly"
always\:"Always render colors"
never\:"Never render colors"))' \
'(-q --quantity -H --deduplicate-hardlinks)--json-input[Read JSON data from stdin]' \
'--json-output[Print JSON data instead of an ASCII chart]' \
'-H[Detect and subtract the sizes of hardlinks from their parent directory totals]' \
Expand Down
10 changes: 10 additions & 0 deletions exports/long.help
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ Options:
--omit-json-shared-summary
Do not output `.shared.summary` in the JSON output

--color <COLOR>
Whether to show colors

Possible values:
- auto: Detect if the output is a TTY and render colors accordingly
- always: Always render colors
- never: Never render colors

[default: auto]

-h, --help
Print help (see a summary with '-h')

Expand Down
2 changes: 2 additions & 0 deletions exports/short.help
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Options:
Do not output `.shared.details` in the JSON output
--omit-json-shared-summary
Do not output `.shared.summary` in the JSON output
--color <COLOR>
Whether to show colors [default: auto] [possible values: auto, always, never]
-h, --help
Print help (see more with '--help')
-V, --version
Expand Down
16 changes: 14 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ pub mod sub;
pub use sub::Sub;

use crate::{
args::{Args, Quantity, Threads},
args::{Args, ColorWhen, Quantity, Threads},
bytes_format::BytesFormat,
get_size::{GetApparentSize, GetSize},
hardlink,
json_data::{JsonData, JsonDataBody, JsonShared, JsonTree},
ls_colors::LsColors,
reporter::{ErrorOnlyReporter, ErrorReport, ProgressAndErrorReporter, ProgressReport},
runtime_error::RuntimeError,
size,
Expand All @@ -16,7 +17,10 @@ use crate::{
use clap::Parser;
use hdd::any_path_is_in_hdd;
use pipe_trait::Pipe;
use std::{io::stdin, time::Duration};
use std::{
io::{stdin, stdout, IsTerminal},
time::Duration,
};
use sub::JsonOutputParam;
use sysinfo::{Disk, Disks};

Expand Down Expand Up @@ -86,6 +90,7 @@ impl App {
column_width_distribution,
direction,
bar_alignment,
coloring: None,
};

let JsonShared { details, summary } = shared;
Expand Down Expand Up @@ -169,6 +174,12 @@ impl App {
ErrorReport::TEXT
};

let color = match self.args.color {
ColorWhen::Always => Some(LsColors::from_env()),
ColorWhen::Never => None,
ColorWhen::Auto => stdout().is_terminal().then(LsColors::from_env),
};

trait GetSizeUtils: GetSize<Size: size::Size> {
const INSTANCE: Self;
const QUANTITY: Quantity;
Expand Down Expand Up @@ -307,6 +318,7 @@ impl App {
max_depth,
min_ratio,
no_sort,
color,
}
.run(),
)*} };
Expand Down
72 changes: 70 additions & 2 deletions src/app/sub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@ use crate::{
get_size::GetSize,
hardlink::{DeduplicateSharedSize, HardlinkIgnorant, RecordHardlinks},
json_data::{BinaryVersion, JsonData, JsonDataBody, JsonShared, JsonTree, SchemaVersion},
ls_colors::LsColors,
os_string_display::OsStringDisplay,
reporter::ParallelReporter,
runtime_error::RuntimeError,
size,
status_board::GLOBAL_STATUS_BOARD,
visualizer::{BarAlignment, ColumnWidthDistribution, Direction, Visualizer},
visualizer::{BarAlignment, Color, Coloring, ColumnWidthDistribution, Direction, Visualizer},
};
use pipe_trait::Pipe;
use serde::Serialize;
use std::{io::stdout, iter::once, path::PathBuf};
use std::{
collections::HashMap,
ffi::OsStr,
io::stdout,
iter::once,
path::{Path, PathBuf},
};

/// The sub program of the main application.
pub struct Sub<Size, SizeGetter, HardlinksHandler, Report>
Expand Down Expand Up @@ -49,6 +56,8 @@ where
pub min_ratio: Fraction,
/// Preserve order of entries.
pub no_sort: bool,
/// Whether to color the output.
pub color: Option<LsColors>,
}

impl<Size, SizeGetter, HardlinksHandler, Report> Sub<Size, SizeGetter, HardlinksHandler, Report>
Expand All @@ -74,6 +83,7 @@ where
reporter,
min_ratio,
no_sort,
color,
} = self;

let max_depth = max_depth.get();
Expand All @@ -98,6 +108,7 @@ where
files: vec![".".into()],
hardlinks_handler,
reporter,
color,
..self
}
.run();
Expand Down Expand Up @@ -187,12 +198,19 @@ where
.or(deduplication_result);
}

let coloring: Option<Coloring> = color.map(|ls_colors| {
let mut map = HashMap::new();
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When pdu is run with multiple path arguments (e.g., pdu path1 path2), the code inserts a synthetic root node whose name is later changed from "" to "(total)" at line 147, before build_coloring_map is called at line 203. As a result, build_coloring_map constructs paths such as (total)/path1/file.txt and calls file_color on them. Since (total)/path1/file.txt does not exist on the filesystem, is_symlink(), is_dir(), and is_executable() all return false, and every file gets the default Color::Normal color — ignoring the actual file type. Symlinks and directories will be miscolored (shown as normal files) whenever --color is used with multiple path arguments. A fix would be to reconstruct each file's actual path from the child sub-trees before calling file_color, or to build the coloring map for each sub-tree individually using its original root path.

Suggested change
let mut map = HashMap::new();
let mut map = HashMap::new();
// NOTE: When `pdu` is invoked with multiple path arguments, a synthetic
// root node (renamed to "(total)") is created. Calling `build_coloring_map`
// on that synthetic root can lead to non-existent filesystem paths like
// "(total)/path1/file.txt" being passed to `file_color`, which in turn
// causes all filesystem-type checks (is_dir, is_symlink, etc.) to fail and
// results in incorrect coloring. The intended behavior is to build the
// coloring map per real subtree (each original argument) so that only
// actual filesystem paths are used for type detection.
//
// For now, we keep the single-root behavior here; special handling for
// synthetic aggregate roots should be implemented inside `build_coloring_map`
// (by skipping synthetic roots and traversing their children individually).

Copilot uses AI. Check for mistakes.
build_coloring_map(&data_tree, &mut Vec::new(), &mut map);
Coloring::new(ls_colors, map)
});

let visualizer = Visualizer {
data_tree: &data_tree,
bytes_format,
direction,
bar_alignment,
column_width_distribution,
coloring: coloring.as_ref(),
};

print!("{visualizer}"); // visualizer already ends with "\n", println! isn't needed here.
Expand Down Expand Up @@ -262,5 +280,55 @@ where
}
}

/// Recursively walk a pruned [`DataTree`] and build a map of path-component vectors to [`Color`] values.
///
/// The `path_stack` argument is a reusable buffer of path components representing the current
/// ancestor chain. Each recursive call pushes the node's name and pops it on return, so no
/// cloning occurs during traversal — only at leaf insertions.
/// Leaf nodes (files or childless directories after pruning) are added to the map.
/// Nodes with children are skipped because the [`Visualizer`] uses the children count to
/// determine their color at render time.
fn build_coloring_map<'a>(
node: &'a DataTree<OsStringDisplay, impl size::Size>,
path_stack: &mut Vec<&'a OsStr>,
map: &mut HashMap<Vec<&'a OsStr>, Color>,
) {
path_stack.push(node.name().as_os_str());
if node.children().is_empty() {
let color = file_color(&path_stack.iter().collect::<PathBuf>());
map.insert(path_stack.clone(), color);
} else {
for child in node.children() {
build_coloring_map(child, path_stack, map);
}
}
path_stack.pop();
}

fn file_color(path: &Path) -> Color {
if path.is_symlink() {
Color::Symlink
} else if path.is_dir() {
Color::Directory
} else if is_executable(path) {
Color::Executable
} else {
Color::Normal
}
}

#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
path.metadata()
.map(|stats| stats.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}

#[cfg(not(unix))]
fn is_executable(_path: &Path) -> bool {
false
}

#[cfg(unix)]
mod unix_ext;
Loading
Loading