opodze.com/me

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:

  1. tmux or other terminal multiplexor
  2. fzf or other menu generator
  3. 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:

  1. We have multiple projects that have big overlap of parts of names (e.g. multiple -airflow repositories)
  2. I still usually go to one of the 2-3 repositories, which do have distinct enough names
  3. The default way fzf sorts 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:

  1. 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.
  2. Projects I’ve never visited with zoxide are 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:

  1. Call awk only once, and rewrite all the loop logic into awk somehow (so, solve awk problem by throwing more awk into it)
    • Additional optimizations might work: for example, running zoxide output through grep
  2. Rewrite the whole script into a language I can tolerate

Initially I chose Python for a very simple reason:

  1. 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
  2. I can ship it similar to my sessionizer script: 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:

  1. Does not spend 50+% of the <100ms time just doing busy work and bootstrapping the runtime
  2. 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