Expired Queues
Published since 2009 Core-CSS.net → CSSJunction.com → Expired Queues Web development
Developer Toolspoststools

A better ls on macOS: use eza, but not as an alias

I spend most of my day in a terminal, and I don't need ls to tell me who owns a file.

Here is the front end of one of my projects, as macOS ls -l sees it:

total 640
-rw-r--r--@  1 adrian  staff    6739 Apr 15 09:12 AGENTS.md
drwxr-xr-x@ 14 adrian  staff     448 Apr 16 08:41 app
drwxr-xr-x@ 15 adrian  staff     480 Apr 16 08:41 components
-rw-r--r--@  1 adrian  staff     382 Apr 15 09:12 eslint.config.mjs
drwxr-xr-x@  5 adrian  staff     160 Apr 16 08:41 lib
-rw-r--r--@  1 adrian  staff     251 Apr 15 09:12 next-env.d.ts
-rw-r--r--@  1 adrian  staff     129 Apr 14 22:07 next.config.ts
drwxr-xr-x@ 16 adrian  staff     512 Apr 15 09:12 node_modules
-rw-r--r--@  1 adrian  staff     660 Apr 15 09:12 package.json
-rw-r--r--@  1 adrian  staff  132858 Apr 16 16:14 pnpm-lock.yaml
-rw-r--r--@  1 adrian  staff     146 Apr 14 22:07 postcss.config.mjs
drwxr-xr-x@  3 adrian  staff      96 Apr 14 22:07 public
-rw-r--r--@  1 adrian  staff     275 Apr 16 23:16 README.md
drwxr-xr-x@  4 adrian  staff     128 Apr 15 09:12 src
-rw-r--r--@  1 adrian  staff     698 Apr 14 22:07 tsconfig.json
-rw-r--r--@  1 adrian  staff  155028 Apr 16 08:41 tsconfig.tsbuildinfo

Useful information, certainly. Four columns of it before you reach a single filename. But most of the time I am not looking at a Unix filesystem because I need to audit permissions. I am looking for a file.

I would rather see this:

A zsh session: ls listing two directories, then ls website listing the same project with a folder or file-type icon in front of every name.
The same directory, and the one above it. Directories first, icons by file type, one entry per line.

Same directory, same information I actually wanted, and I can find package.json in it without reading a single permission bit.

That is what this setup does. We replace ls in the interactive shell with eza, give it icons and colours, and - the part most write-ups get wrong - make sure it still behaves when you pass it arguments.

Why eza

There are plenty of ways to customise a terminal. I am not interested in turning mine into a dashboard. eza is the maintained fork of the old exa, and the useful parts of it are simple:

  • sensible file listings
  • directories grouped first
  • optional icons
  • file-type colours
  • tree views
  • useful sorting options
  • Git integration
  • familiar ls-style arguments

The important bit is the last one. It still feels like ls.

1. Install a Nerd Font

If you want icons, your terminal needs a font that carries the Nerd Font glyphs. Check what you already have:

system_profiler SPFontsDataType | grep -i "nerd"

If nothing comes back, Fira Code is a reasonable choice:

brew install --cask font-fira-code-nerd-font

Make sure your terminal is actually using it

This is the part that is easy to miss. Your editor font and your terminal font are two different settings. In VS Code:

{
  "editor.fontFamily": "Fira Code",
  "terminal.integrated.fontFamily": "FiraCode Nerd Font",
  "terminal.integrated.lineHeight": 1.2
}

You do not need to restart VS Code - run Developer: Reload Window from the Command Palette.

In iTerm2 the setting lives under Settings → Profiles → Text → Font; pick FiraCode Nerd Font there.

2. Install eza

which eza || brew install eza

Check that it runs:

eza -1 --group-directories-first

At that point you already have a better ls. The -1 matters: it puts each entry on its own line instead of eza's default grid.

3. Add a theme

Added October 2024: eza 0.20 reads a theme file, which earlier versions did not. If you are on an older build, skip this section - everything else still works.

Create the config directory:

mkdir -p ~/.config/eza

I use Tokyo Night:

curl -L https://raw.githubusercontent.com/eza-community/eza-themes/refs/heads/main/themes/tokyonight.yml \
  -o ~/.config/eza/theme.yml

Then tell eza where to look, in ~/.zshrc:

export EZA_CONFIG_DIR="$HOME/.config/eza"

Reload and confirm:

source ~/.zshrc
echo $EZA_CONFIG_DIR

4. Don't alias this one

Here is the bit worth the post. You will be tempted to write:

alias ls='eza -1 --icons=always --color=always --group-directories-first | sed "s/^/ › /"'

Don't. It looks fine until you do the thing in the screenshot above:

ls website

A shell alias is a textual substitution, so your command becomes:

eza -1 --icons=always --color=always --group-directories-first | sed "s/^/ › /" website

website now belongs to sed, not to eza. Once sed has a file operand it stops reading stdin, so eza's listing is thrown away and you get whatever sed makes of website instead - nothing at all for a directory, or the entire contents of the file if you happened to name one:

 › {
 ›   "name": "@pwtray/website",
 ›   "version": "1.0.1",

ls -la is worse only in that it tells you:

sed: -la: No such file or directory

Same story for every argument you type after the command name.

Use a shell function instead:

unalias ls 2>/dev/null
function ls { eza -1 --icons=always --color=always --group-directories-first "$@" | sed 's/^/ › /'; }

The whole fix is "$@". It puts your arguments where they belong - on eza, before the pipe. So ls, ls website, ls -a, and ls website/app -T -L 2 all do what you expect.

5. Put it at the end of .zshrc

This matters if you use Oh My Zsh or another framework: define the function after the framework loads, or the framework's own ls alias wins.

# plugins, framework, PATH, etc.

export EZA_CONFIG_DIR="$HOME/.config/eza"

unalias ls 2>/dev/null
function ls { eza -1 --icons=always --color=always --group-directories-first "$@" | sed 's/^/ › /'; }

Why the unalias? If ls is already an alias (oh-my-zsh defines one, for example), zsh substitutes the alias text into your function definition line before running it. The result is garbage syntax, and you get:

parse error near `()'

Removing the alias with unalias ls 2>/dev/null first means the next function line defines a clean function named ls. The function keyword adds a safety net: unlike the ls() { ... } syntax, the name after function is never alias-expanded.

source ~/.zshrc

6. Try it

ls

Here, the ls gives you the listing from the top of the post - with icons in front of each name, if your terminal font has them. ls website gives you the same treatment one directory down, which is the whole reason for the function.

The rest of eza is still there too:

ls -a           # hidden files
ls -T -L 2      # a two-level tree
ls -s modified  # sorted by modification time
ls -D           # directories only
ls | grep .md   # and it still pipes

Flags and paths in the same command work the way they always did:

ls website/app -T -L 2 drawing a two-level tree of a Next.js app directory, with connector lines and file-type icons.
ls website/app -T -L 2 - a path and two eza flags, passed straight through by "$@".

Keeping the real ls

There are still times I want the traditional output, and it never went anywhere:

command ls -la
\ls -la

The function only exists in your interactive shell. It does not touch /bin/ls, and scripts running outside that shell never see it. That is exactly how this kind of customisation should work: convenient at the prompt, invisible everywhere else.

One detail: --color=always

Command-line tools are normally careful about colour. If stdout is a terminal they emit colour codes; if stdout is a pipe they usually turn colour off, so the escape sequences don't end up inside whatever is reading them.

Our function deliberately pipes eza through sed:

eza → sed → terminal

So eza does not see a terminal. It sees a pipe, and it would drop both the colours and the icons. That is why --color=always and --icons=always are in there: they force the decoration back on.

The trade-off is that the escape codes are now real characters in the output. Anything that understands ANSI is fine:

ls | less -R

Anything that doesn't will show you the codes, so use command ls when you are feeding the output to something that parses it.

The final setup

export EZA_CONFIG_DIR="$HOME/.config/eza"

unalias ls 2>/dev/null
function ls { eza -1 --icons=always --color=always --group-directories-first "$@" | sed 's/^/ › /'; }

That is the lot. No replaced system binaries, no changes to macOS, no wrapper script sitting in /usr/local/bin. Just a small function that makes the command I run a few hundred times a day show me what I actually came to see.

You might be interested in: