Hello all !
Apologies in advance for the sounds-like-boasting tone, but I would like to share with you what started like a weekend inspiration that actually spanned a bit longer than I thought it would, but which produced a result I really could not live without now on any Unicode-capable graphical terminal. Quite simply, with it I almost rarely ever use my desktop's file manager any longer now
Here's what. With a little bit of shellcode and the help of awk, it made "ls" show directory listings like this:
It doesn't pollute scripts: when the standard output is not a Unicode-capable terminal (e.g. a pipe, or a CI/CD build script), the original 'ls' is used so the behaviour is unchanged. This display is carefully designed to prettify the ls output only when you, the interactive user, are typing the command in an interactive terminal.
I find the extra information given by icons invaluably helpful. I immediately see
folders,
executables,
symlinks,
pipes and
setuid programs. These are the most common but it can also display
character devices,
block devices, UNIX domain
sockets,
regular files, union mount
whiteouts each under their own, distinct icon (I see where you're looking at: indeed, at some point, I thought about assigning a different icon according to the file type e.g. a picture icon for image files, another one for videos, etc. but while experimenting around I found that idea a bit excessive, confusing and eventually less useful than the purely mode-based icons for file management, which is what 'ls' is merely about, so I chose to limit it to that).
All of the 'ls' options are supported, quite simply in the sense that if a 'ls' option is entered that the code doesn't know how to handle, it will fallback on the default 'ls', thus feature parity is guaranteed. 'ls -x' (cross-ordering), 'ls -R' (recursive), 'ls -n', 'ls -i', ls on multiple target folders, are usable with this pretty view. The border is optional and can be disabled with the "--tight" or "--no-border" option.
It can recognize BSD ls and GNU ls (coreutils), which means it works on Linux too. Here's how it looks on Linux (using the infamous Windows 11 terminal):
And the extra candy on top of the cake is that, with a little bit more of hacking, all these filenames can be made clickable. All one needs to do is to wrap them into OSC-8 URL escape sequences in the awk code at the appropriate place, like this:
Instead of doing
... but as this is a "sensitive" feature (especially in SSH sessions) I haven't included here. You can easily figure out how to add it back. But TBH, the real use of such a thing would be to browse around the filesystem by issuing "cd" commands in subdirectories by clicking on the directory names. AFAIK there's no standardized protocol for issuing directory change commands, and implementing one needs a terminal emulator patch.
Enough parading around. Here's the code:
How to use : copy/paste this whole code block in your .profile, and enjoy a beautiful ls.
If you want to test it before putting it in your profile, which is understandable, just paste it in your terminal and try a few 'ls' commands.
(Constructive) comments and suggestions are welcome! -- and if you find this gimmick useful, tips to https://paypal.me/pmbaty will directly go into my weekend beer budget
Apologies in advance for the sounds-like-boasting tone, but I would like to share with you what started like a weekend inspiration that actually spanned a bit longer than I thought it would, but which produced a result I really could not live without now on any Unicode-capable graphical terminal. Quite simply, with it I almost rarely ever use my desktop's file manager any longer now
Here's what. With a little bit of shellcode and the help of awk, it made "ls" show directory listings like this:
It doesn't pollute scripts: when the standard output is not a Unicode-capable terminal (e.g. a pipe, or a CI/CD build script), the original 'ls' is used so the behaviour is unchanged. This display is carefully designed to prettify the ls output only when you, the interactive user, are typing the command in an interactive terminal.
I find the extra information given by icons invaluably helpful. I immediately see
All of the 'ls' options are supported, quite simply in the sense that if a 'ls' option is entered that the code doesn't know how to handle, it will fallback on the default 'ls', thus feature parity is guaranteed. 'ls -x' (cross-ordering), 'ls -R' (recursive), 'ls -n', 'ls -i', ls on multiple target folders, are usable with this pretty view. The border is optional and can be disabled with the "--tight" or "--no-border" option.
It can recognize BSD ls and GNU ls (coreutils), which means it works on Linux too. Here's how it looks on Linux (using the infamous Windows 11 terminal):
And the extra candy on top of the cake is that, with a little bit more of hacking, all these filenames can be made clickable. All one needs to do is to wrap them into OSC-8 URL escape sequences in the awk code at the appropriate place, like this:
Instead of doing
printf "%s", filename you would do: printf "\033]8;;file://absolute_path_to_filename\a%s\033]8;;\a", filename and the resulting display would produce clickable things.... but as this is a "sensitive" feature (especially in SSH sessions) I haven't included here. You can easily figure out how to add it back. But TBH, the real use of such a thing would be to browse around the filesystem by issuing "cd" commands in subdirectories by clicking on the directory names. AFAIK there's no standardized protocol for issuing directory change commands, and implementing one needs a terminal emulator patch.
Enough parading around. Here's the code:
sh:
unalias ls 2>/dev/null
ls() # this code is intentionally dense, to make it easier to copy/paste in any Bourne shell's rc file.
{
if [ ! -t 1 ] || ! printf "%s%s%s" "${LANG}" "${CHARSET}" "${MM_CHARSET}"|grep -qi UTF; then
command ls "$@"; return "$?" # if output is not a terminal OR isn't set to Unicode, don't bother and passthrough to the original ls
fi
if command ls --version 2>/dev/null|grep -q 'GNU coreutils'; then
_LSCMD='LC_ALL=C command ls -linsH --color=never --hyperlink=never --time-style="+%b %e %H:%M:%S %Y"' # GNU ls. GNU's not UNIX.
else
_LSCMD='unset CLICOLOR CLICOLOR_FORCE; LC_ALL=C exec command ls -linsTH' # BSD ls (FreeBSD, NetBSD, OpenBSD). That, boy, is UNIX.
fi
_FORMATTER="TabC" # output formatter type, among "OneL" (one line per file), "TabC" (table, columns first), "TabL", (table, lines first)
_BORDER=1 # whether to display rounded border around directory contents. Toggle with the --no-border or --tight arguments
_PARSE="true" # ls options will be parsed until "--" is encountered, which will toggle this variable to false
_INOIDX=0 # index in the "ls -lins" output of the inode number field
_BKCIDX=0 # index in the "ls -lins" output of the block count field
for arg; do
"${_PARSE}" && case "${arg}" in
--) _PARSE="false";; # stop parsing for ls arguments after "--"" has been received
--*) # long form option
case "${arg}" in
--color|--color=*) continue;; # don't pass color options
--context) command ls "$@"; return "$?";; # security context wanted: don't go further
--dired) command ls "$@"; return "$?";; # Emacs form wanted: don't go further
--format=commas) command ls "$@"; return "$?";; # serialization wanted: don't go further
--format=long) command ls "$@"; return "$?";; # long form wanted: don't go further
--format=verbose) command ls "$@"; return "$?";; # long form wanted: don't go further
--full-time) command ls "$@"; return "$?";; # long form wanted: don't go further
--hyperlink|--hyperlink=*) continue;; # don't pass hyperlink options
--inode) _INOIDX=1; continue;; # with inode numbers
--no-group) command ls "$@"; return "$?";; # long form wanted: don't go further
--numeric-uid-gid) command ls "$@"; return "$?";; # long form wanted: don't go further
--size) _BKCIDX=2; continue;; # with size in blocks
--zero) command ls "$@"; return "$?";; # NUL-byte serialization wanted: don't go further
--no-border*|--tight) _BORDER=0; continue;; # don't display borders
esac;;
-*) # short form option
argchars="${arg#-}" # strip leading dash
filteredchars=""
while [ -n "${argchars}" ]; do # evaluate these option characters one after the other
argchar="${argchars%"${argchars#?}"}" # pick one character from the options word
argchars="${argchars#?}" # chop off that character from the word for the next pass
case "${argchar}" in
g|l|n|o|Z) command ls "$@"; return "$?";; # long form wanted: don't go further
i) _INOIDX="1"; continue;; # with inode numbers
m) command ls "$@"; return "$?";; # comma-separated serialization: don't go further
s) _BKCIDX="2"; continue;; # with size in blocks
1) _FORMATTER="OneL"; continue;; # one entry per line
x) _FORMATTER="TabL"; continue;; # columns sorted across the page
esac
filteredchars="${filteredchars}${argchar}" # reconstruct the filtered-out options word
done
test -n "${filteredchars}" && arg="-${filteredchars}" || arg="";; # now if we filtered anything, substitute it to the argument evaluated
esac
test -n "${arg}" && _LSCMD="${_LSCMD} $(printf '%s' "${arg}"|sed "s/'/'\\\\''/g; 1s/^/'/; \$s/\$/'/")" # quote and escape each concatenated argument
done; unset _PARSE
_TERMWIDTH="$(tput cols 2>/dev/null || stty size 2>/dev/null|cut -d ' ' -f 2 || { test -n "${COLUMNS}" && printf '%s' "${COLUMNS}" || printf '80'; })" # try (hard) to figure out the terminal width
_UTFLOCALE="$(locale -a 2>/dev/null|grep -i '^C.UTF-*8')"
eval "( ${_LSCMD}; )"|LC_CTYPE="${_UTFLOCALE}" awk -v formatter="${_FORMATTER}" -v ino_idx="${_INOIDX}" -v bkc_idx="${_BKCIDX}" -v termwidth="${_TERMWIDTH}" -v border="${_BORDER}" -v lscmd="${_LSCMD}" '
BEGIN { longest["ino"] = longest["blk"] = longest["nam"] = entry_count = 0 } # start this awk program by initializing some stuff
function flush(cmd, col_width, i, row_index, row_count, col_index, col_count, col_pos) { # this function is called when we reach a directory contents boundary, and at the end of the program
if (entry_count == 0) return # nothing to flush? okay, seeya
col_width = 2 # icon width
if (longest["ino"]) col_width += 1 + longest["ino"] # space separator + inode width
if (longest["blk"]) col_width += 1 + longest["blk"] # space separator + block count width
col_width += 1 + longest["nam"] + 1 # space separator + name width + space separator
col_count = (formatter == "OneL" ? 1 : int((termwidth - 1) / ((border ? 1 : 0) + col_width))) # figure out the number of colums we can have
if (col_count < 1) col_count = 1 # ensure there will be minimum 1 column
else if (col_count > entry_count) col_count = entry_count # ensure there will be no more than necessary
row_count = int((entry_count + col_count - 1) / col_count) # deduce the number of rows we need
if (formatter == "TabC") # are we in column-first mode? if so, attempt to reduce the number of columns on the right if there are too many
for (; col_count > 1; col_count--) { # as long as there are more than one column...
for (row_index = 0; row_index < row_count; row_index++) if (entries["" ((col_count - 1) * row_count + row_index) "|nam"] != "") break # see if any item in the last column is set
if (row_index < row_count) break # if at least one item is set, stop shrinking
}
for (row_index = 0; row_index < row_count; row_index++) { # for each row...
if (border && (row_index == 0)) { # if we want a pretty border around each block, display the top border
printf "\033[90m╭"; col_pos = 1; # start drawing the top border, and remember how far we are from the terminal bounds
for (col_index = 0; col_index < col_count; col_index++) { # draw the top border line until the terminal bounds are reached
if (col_index > 0) { printf "┬"; col_pos++; if (col_pos == termwidth - 1) break }
for (i = 0; i < col_width; i++) { printf "─"; col_pos++; if (col_pos == termwidth - 1) break }
}
printf "%s\033[0m\n", (col_pos < termwidth - 1 ? "╮" : "") # finish the border line unless terminal bounds are reached and drop a newline
}
if (border) { # when drawing a table line, start by drawing the rightmost border if we want one, to account for different character widths
col_pos = 1 + col_count * (1 + col_width) # figure out how far the rightmost border should be
if (col_pos < termwidth - 1) {
for (i = 0; i < col_pos - 1; i++) printf " " # NOTE: the awk printf "%*s" handling of UTF-8 codepoints vs. bytes is implementation-specific, thus unreliable!
printf "\033[90m│\033[0m" # only draw it if it does not go beyond the terminal bounds
}
}
for (col_index = col_count - 1; col_index >= 0; col_index--) { # draw columns backwards, to compensate for variable character widths
col_pos = (border ? 1 : 0) + col_index * ((border ? 1 : 0) + col_width) # locate at the right place in the line
printf "\r"; for (i = 0; i < col_pos - 1; i++) printf " " # NOTE: the awk printf "%*s" handling of UTF-8 codepoints vs. bytes is implementation-specific, thus unreliable!
if (border) printf "\033[90m│\033[0m"; else if (col_index > 0) printf " " # print column border, or spacer if we do not want borders, when appropriate
i = (formatter == "TabC" ? i = col_index * row_count + row_index : row_index * col_count + col_index) # pick the right item to display: columns first, else lines first
printf "%s ", entries["" i "|ico"] # start by printing the icon placeholder
if (ino_idx > 0) printf "%*s ", longest["ino"], entries["" i "|ino"] # if requested, append inode number and separator
if (bkc_idx > 0) printf "%*s ", longest["blk"], entries["" i "|blk"] # if requested, append item size in blocks and separator
printf "\033[%sm%s\033[0m", entries["" i "|clr"], entries["" i "|nam"] # now append the name in the right color
}
print "" # terminate the row with a newline
if (border && (row_index + 1 == row_count)) { # if we want a pretty border around each block, display the bottom border
printf "\033[90m╰"; col_pos = 1; # start drawing the bottom border, and remember how far we are from the terminal bounds
for (col_index = 0; col_index < col_count; col_index++) { # draw the top border line until the terminal bounds are reached
if (col_index > 0) { printf "┴"; col_pos++; if (col_pos == termwidth - 1) break }
for (i = 0; i < col_width; i++) { printf "─"; col_pos++; if (col_pos == termwidth - 1) break }
}
printf "%s\033[0m\n", (col_pos < termwidth - 1 ? "╯" : "") # finish the border line unless terminal bounds are reached and drop a newline
}
}
delete entries; longest["ino"] = longest["blk"] = longest["nam"] = entry_count = 0
}
/^[^[:space:]].*:$/ { flush(); print; next } # line is a ls directory heading: flush the preceding entries (if any), then emit the heading untouched
/^$/ { flush(); if (!border) print; next } # line is a blank line separates multiple ls operands: flush the preceding entries, and emit a blank line if borders are disabled
/^total[[:space:]]/ { next } # line is a directory summary line produced by ls -l: ignore
{
# line is a directory entry information provided by ls -lins
ino = blk = "" # collect the fields we want to include in our formatted output
for (field_idx = 1; field_idx < 11; field_idx++) {
if (field_idx == ino_idx) ino = "" $field_idx # collect the inode field if we want it
else if (field_idx == bkc_idx) blk = "" $field_idx # collect the size in blocks field if we want it
}
nam = $0; # collect the name
for (field_idx = 0; field_idx < 11; field_idx++)
sub(/^[[:space:]]*[^[:space:]]+[[:space:]]*/, "", nam) # the filename starts at field 10, so blank out everything before that
type = substr($3, 1, 1) # isolate the entry type from the mode tag
if (type == "l") { sub(/ -> .*/, "", nam) } # strip symlink target from name
else if (((type == "c") || (type == "b")) && (lscmd ~ "hyperlink")) { sub(/^[[:space:]]*[^[:space:]]+[[:space:]]*/, "", nam) } # if using GNU coreutils, strip device nodes from their extra minor field
# now decide about icon and color, and stuff all that in an associative array (in awk, array indices can be strings -- lemme abuse it)
if (type == "d" && substr($3, 6, 1) == "s") { entries["" entry_count "|ico"] = "🗂️"; entries["" entry_count "|clr"] = "97" } # shared directory
else if (type == "d") { entries["" entry_count "|ico"] = "📁"; entries["" entry_count "|clr"] = "97" } # directory
else if (type == "l") { entries["" entry_count "|ico"] = "🔗"; entries["" entry_count "|clr"] = "0" } # symlink
else if (type == "p") { entries["" entry_count "|ico"] = "🪈"; entries["" entry_count "|clr"] = "0" } # pipe
else if (type == "s") { entries["" entry_count "|ico"] = "🔌"; entries["" entry_count "|clr"] = "0" } # UNIX socket
else if (type == "c") { entries["" entry_count "|ico"] = "📠"; entries["" entry_count "|clr"] = "0" } # character device
else if (type == "b") { entries["" entry_count "|ico"] = "💽"; entries["" entry_count "|clr"] = "0" } # block device
else if (type == "w") { entries["" entry_count "|ico"] = "🚫"; entries["" entry_count "|clr"] = "0" } # union mount whiteout
else if (type == "-" && $3 ~ /s/) { entries["" entry_count "|ico"] = "☢️"; entries["" entry_count "|clr"] = "31" } # setuid executable
else if (type == "-" && $3 ~ /x/) { entries["" entry_count "|ico"] = "🚀"; entries["" entry_count "|clr"] = "97" } # executable
else if (type == "-") { entries["" entry_count "|ico"] = "📄"; entries["" entry_count "|clr"] = "0" } # regular file
else { entries["" entry_count "|ico"] = "❓"; entries["" entry_count "|clr"] = "0" } # unknown sort of directory entry
len = length(ino); if (len > longest["ino"]) longest["ino"] = len # update the longest inode length in the block
len = length(blk); if (len > longest["blk"]) longest["blk"] = len # update the longest item size in the block
len = length(nam); if (len > longest["nam"]) longest["nam"] = len # update the longest filename length in the block
entries["" entry_count "|ino"] = ino # save entry inode number
entries["" entry_count "|blk"] = blk # save entry block count
entries["" entry_count "|nam"] = nam # save entry name
entry_count++ # remember one item more was stacked up
}
END { flush() } # flush the contents of the last directory
'; unset _UTFLOCALE _TERMWIDTH _BORDER _BLKIDX _INOIDX _FORMATTER _LSCMD # cleanup
}
alias ls="ls --group-directories-first"
How to use : copy/paste this whole code block in your .profile, and enjoy a beautiful ls.
If you want to test it before putting it in your profile, which is understandable, just paste it in your terminal and try a few 'ls' commands.
(Constructive) comments and suggestions are welcome! -- and if you find this gimmick useful, tips to https://paypal.me/pmbaty will directly go into my weekend beer budget