Zap started from a very simple need :
I wanted a fast terminal tool to search files, folders, and old shell commands without breaking flow.
The idea was straightforward:
- search files and folders from the current directory
- search zsh history too
- open files directly
- jump into folders quickly
Simple idea, but while building v1, I ran into two problems that taught me a lot more than I expected:
search felt slower than it should
selecting a folder sometimes printed a path instead of actually changing my shell directory
This post is about those two problems and how I understood them while building zap.
Why I built zap
When I’m inside a repo, many times I only remember part of a filename or folder name. Maybe I remember pac, not the full path. I don’t want to manually cd around or type long nested paths just to reach package.json or packages/.
So the goal of zap was simple:
- type a query
- get useful matches
- select one
- act on it immediately That was the whole point. Fast navigation from terminal.
The first version worked, but something felt off
I got the basic flow running:
- scan the current directory
- fuzzy-match files and folders
- show them in a terminal picker
- open a file or move to a folder At first it looked fine. But once I started actually using it, one thing became obvious very quickly:
Search felt slower than it should
For a search tool, speed is the main thing. Even a small delay feels bad because the whole point is instant recall.
The repo I was testing on was not even huge, but the search still felt heavier than expected. That told me the issue was probably not the fuzzy matching idea itself. It was likely in how I was walking the filesystem.
Understanding the slowdown: readdirSync and statSync
The original traversal logic worked roughly like this:
- use readdirSync(dir) to list entries inside a folder
- for each entry, call statSync(fullPath)
- check whether it is a file or a folder
- recurse if it is a folder That works, but it does more filesystem work than needed.
What readdirSync does
readdirSync(path) reads a directory and gives you the names inside it.
For example:
src
package.json
README.md
But it does not tell you what each item is.
What statSync does
statSync(path) asks the OS for metadata about a path. That tells you things like:
- is it a file
- is it a directory
- size
- timestamps
So the old flow was:
list names with readdirSync
then make another OS call with statSync for every single item
That is where the extra cost was coming from.
And because these were synchronous calls, Node was waiting on each one in sequence. For a terminal search tool, that is exactly the kind of thing that starts making the tool feel sluggish.
The fix: cheaper directory walking
The cleaner approach was to switch to:
fs.readdirSync(dir, { withFileTypes: true })
This returns Dirent objects instead of plain string names.
That means each item already knows whether it is a file or directory through methods like:
item.isDirectory()
item.isFile()
So I no longer needed statSync just to classify every entry.
This was a very practical improvement because it made traversal cheaper without changing the search experience itself. Same behavior, less unnecessary filesystem overhead.
The shell problem: why folder selection printed CD: instead of changing directory
This was the most interesting part of the build for me.
At one point I ran the CLI directly like this:
node apps/cli/dist/index.js pac
Then I selected a folder and saw:
CD:/home/sujal/zap/packages
At first, it looked broken. Why print the path instead of just moving into it?
The answer is simple once you understand what is happening:
a child process cannot change the parent shell’s current directory.
That is the key point.
When you run a Node CLI from terminal, that Node process is a child of your shell. Even if the CLI knows which folder you selected, it cannot make your current shell session run cd.
So if I execute the CLI directly, the best it can do is report the selected directory somehow. It cannot itself move my shell.
Once I understood that, the behavior made complete sense.
The actual fix: a zsh wrapper and a temp file
To make cd work, the shell itself needs to perform the cd.
So the solution was:
- create a shell wrapper function called zap
- create a temp file
- pass the temp file path to the CLI through an environment variable
- let the CLI write the selected directory into that temp file
- after the CLI exits, let the shell wrapper read the path and run cd The wrapper looked like this:
zap() {
local cd_file
local exit_code
local target
cd_file=$(mktemp)
ZAP_CD_FILE="$cd_file" command zap "$@"
exit_code=$?
if [[ $exit_code -eq 0 && -s "$cd_file" ]]; then
target=$(<"$cd_file")
if [[ -d "$target" ]]; then
cd "$target"
fi
fi
rm -f "$cd_file"
return $exit_code
}
And on the CLI side, the logic was simple:
if ZAP_CD_FILE exists, write the chosen folder path there
otherwise print CD:...
That was the missing bridge between the CLI and the shell.
What v1 really taught me
The biggest lesson from building zap v1 is that many frustrating bugs are not logic bugs in the usual sense. They are boundary problems.
In my case:
- slow search was about how I was talking to the filesystem
- missing cd was about the boundary between a child process and the parent shell
- confusing command behavior was about understanding what executable was actually being run Once I understood the boundary properly, the behavior stopped feeling random.
Final thoughts
I started zap as a small utility to speed up navigation in terminal. But building v1 ended up teaching me much more than I expected.
It taught me:
- how expensive repeated filesystem calls can feel in a terminal UX
- why shell behavior can seem broken when it is actually just process isolation
- why tiny tools still force you to understand systems properly And honestly, that is one of the best parts of building small tools. They look simple, but they make you learn real things.
You can use zap by installing it through npm registry :
npm i zap-search
zap init zsh >> ~/.zshrc
source ~/.zshrc
if wanna contribute to zap then you're welcome :
https://github.com/Sran012/zap
That was my zap version 1.0.1 story.

Top comments (0)