Sharpening the sword: zlist
About two years ago I’ve randomly found tmux-sessionizer. At the time of writing it’s a pretty complex script, but initially it was about one screen in size. The first time I’ve seen it I was stunned - it did something I wanted to do for years, so simply-and fully done in bash! When I was imagining “my solution” to this problem, I never could put my finger on how exactly the “project switching” part should work to be intuitive and expansive. Turns out, there are only a few components needed to make it work:
tmuxor other terminal multiplexorfzfor other menu generator- any console-based text editor
I loved the idea, and immediately introduced it into my dotfiles. After all this time, I see that our implementations of these scripts diverged quite a bit - ThePrimeagen’s sessionizer script is now almost 400 lines long and accepts configuration, and does…too much to my taste. My version is 37 lines of code right now and does only and exactly what I want it to do. However, recently I’ve stumbled into a weird problem on my side with this script at work:
- We have multiple projects that have big overlap of parts of names (e.g. multiple
-airflowrepositories) - I still usually go to one of the 2-3 repositories, which do have distinct enough names
- The default way
fzfsorts options just happens to be constantly putting the least needed option for me to the top.
I want some other way to prioritize those directories - alphabetic sort does not cut it anymore. The mechanism has to account my actual usage of project switching somehow, and be very reactive: either of “sorting by last visit” or “sorting by most visits” would work for me
Introducing zoxide. In principle, this tool does the hardest part for me: it not only
keeps a dynamic database of locations you visit most frequently, but has a much smarter way of assigning priority
to those locations called frecency. The designed use case for zoxide
is a sort-of alternative to cd. I tend to use cd more actually, since I usually hop between projects using tmux
sessions, and therefore, I only need to move 1-2 levels deeper (and usually I just use some fuzzy file finder
in my editor for this anyway)
But, using zoxide to get the frecency scores for directories I want to visit is a huge win. However, zoxide,
from what I understand, only has a way of “dumping” the whole database into stdout with scores. Something like this:
268.0 /Users/dasd/sandbox/dotfiles
220.0 /Users/dasd/sandbox
192.0 /Users/dasd/sandbox/project1
80.0 /Users/dasd/sandbox/project2
19.0 /Users/dasd/sandbox/project3
6.2 /Users/dasd/sandbox/advent-of-code/2025
4.8 /Users/dasd/.config
4.0 /Users/dasd/sandbox/blog/themes
3.8 /Users/dasd/sandbox/advent-of-code/2025/t2
The format of this list is perfect: it’s easily sortable, and it’s easy to use fzf to select the second part of the selection
as path (see this wonderful blogpost).
Two problems here though:
- I don’t really want to create tmux sessions at task 2 of 2025’s advent of code now or any time in the foreseeable future.
- Projects I’ve never visited with
zoxideare not present in the database. I want new projects to pop up in my list automatically.
Initially I’ve tried to solve this issue by dumping more bash to my bash sessionizer script. And it sort of worked! However, I had to use awk
for this, and I have a strong dislike for awk in anything I want to maintain.
The code looked something like this:
#!/usr/bin/env bash
sandbox="$HOME/sandbox"
zlist="$(zoxide query --list --score)"
find "$sandbox" ~ -mindepth 1 -maxdepth 1 -type d | while read -r dir; do
score=$(printf '%s\n' "$zlist" | awk -v d="$dir" '$2==d {print $1}')
printf "%6s %s\n" "${score:-0}" "$dir"
done | sort -nr
This solution is:
- dumb: as in the “KISS” dumb, but also as “I’m just throwing coreutils into some text and solving this in O(N^2) hoping input does not get that large” kind of way
- short: there’s few things works in programming than a big shell script
- portable: bash is anywhere I care to run this
With new features the sessionizer script started taking about 200-300ms of real time on my machine. This is not much,
but definitely noticeable to naked eye. I had to actually adjust a delay between me calling the sessionizer script, and waiting for the
list of projects to show up. The culprit for such bad performance seemed to be awk - while by itself it’s pretty fast, calling awk
in a loop for every row of zoxide output had a significant overhead. There were only two solutions in sight:
- Call
awkonly once, and rewrite all the loop logic into awk somehow (so, solveawkproblem by throwing moreawkinto it)- Additional optimizations might work: for example, running
zoxideoutput throughgrep
- Additional optimizations might work: for example, running
- Rewrite the whole script into a language I can tolerate
Initially I chose Python for a very simple reason:
- It’s easily scriptable: I don’t need any dependencies, and any Python 3 would work for me here. No extra things to bootstrap the system needed
- I can ship it similar to my
sessionizerscript: it’s just a text file with a shebang at the top.
While increasing LOC from 10 to about 70, this simple change reduced the runtime from ~200ms down to ~100ms.
See full code here
#!/usr/bin/env python3
import operator
import subprocess
from pathlib import Path
def get_zoxide_scores():
""" {absolute_path_string: float_score} """
result = subprocess.run(
["zoxide", "query", "--list", "--score"],
capture_output=True,
text=True,
check=True,
)
scores = {}
for line in result.stdout.splitlines():
if not line.strip():
continue
# Format: "<score> <path>"
# Path may contain spaces -> split only once
score_str, path = line.strip().split(maxsplit=1)
scores[path] = float(score_str)
return scores
# on my machine, using `find` this way is faster than pathlib
def list_dirs_pathlib(*base: Path):
result = subprocess.run(
["find", *map(str, base), "-mindepth", "1", "-maxdepth", "1", "-type", "d"],
capture_output=True,
text=True,
check=True,
)
return [Path(line) for line in result.stdout.splitlines() if line.strip()]
def main():
scores = get_zoxide_scores()
dirs = list_dirs_pathlib(Path.home() / "sandbox") + list_dirs_pathlib(Path.home())
output = []
for d in dirs:
path_str = str(d)
score = scores.get(path_str, 0.0)
output.append((score, path_str))
output.sort(reverse=True, key=operator.itemgetter(0))
for score, path in output:
print(f"{score:6.1f} {path}")
if __name__ == "__main__":
main()
Much better, the delay is almost not noticeable. Almost.
First round of profiling showed that calling find in a subprocess was faster than using pathlib. I managed to reduce the
runtime down to 70ms. The second round of profiling showed a depressing picture: about 20% of the time script spends waiting for subprocesses to finish,
another 5% is the core logic of displaying the right directories, and the rest 70% is spent interpreting and importing system libraries like subprocess and pathlib.
And unfortunately, the lag was still sometimes noticeable - maybe some relevant file got out of systems’ cache or whatever, but it irritated me
a bit too much to ignore it.
So for the first time in a couple of years, I actually wrote a little bit of rust. I’ve bit the bullet - now my dotfiles expects you to
have cargo installed in your system, but I think it was worth it to see the runtime reduce down to stable ~20ms (10x speedup from the first attempt!)
The program is pretty small and using rust is surely overkill, but I needed something that:
- Does not spend 50+% of the <100ms time just doing busy work and bootstrapping the runtime
- I’m somewhat familiar with
zoxide is also written in Rust, so I was hoping that there could be an easy way to interop with their codebase, but they do not expose any sort of library or API for
database interaction, and I didn’t want to vendor zoxide within my silly program. I believe if I’d choose something like Go, the results would be within
the margin of error.
What was the most surprising for me here is that for this specific task, idiomatic Rust code looks strikingly similar to Python code. At least, usually Rust code
tends to look much more different than this. I actually really enjoyed this little side quest.
And using cargo as this makeshift tool manager (you can just cargo install --git anything these days I guess) simplifies the onboarding process for future me.
zlist source code can be found here
sessionizer script can be found here