|
#!/bin/bash |
|
|
|
# `icd` to activate an interactive way to navigate through directories |
|
# Note: for consistency, treat all arrays as 0-indexed |
|
icd() { |
|
# This code distinguishes between |
|
# - visible directories (the ones currently shown on screen), |
|
# - filtered directories (the ones matching the current search string), and |
|
# - all directories (all subdirectories in the current directory) |
|
local MAX_VISIBLE_DIRS=10 MAX_DIR_WIDTH=60 SEARCH_ICON="\uf422" |
|
local SEARCH_PREFIX=" $(tput bold)${SEARCH_ICON}$(tput sgr0)$(tput el) " |
|
|
|
local is_bash=$([[ -n $BASH ]] && echo true || echo false) |
|
local is_zsh=$([[ -n $ZSH_NAME ]] && echo true || echo false) |
|
|
|
# Expected output: |
|
# - directory line (fixed line) |
|
# - instructions line (fixed line) |
|
# - search string line (fixed line) |
|
# - subdirectories (all_dirs, each on their own line, with up to MAX_VISIBLE_DIRS shown at a time) |
|
# - blank lines to fill up the rest of the screen as needed |
|
|
|
## --- Helper functions --- |
|
|
|
# Read a single keypress in zsh and translate it to a command name |
|
zsh_key_input() { |
|
read -sk1 key |
|
if [[ $key = $'\e' ]]; then |
|
read -sk2 -t 0.1 key |
|
[[ -z $key ]] && key=$'\e' # no follow-up character means escape key was pressed |
|
fi |
|
translate_input "$key" |
|
} |
|
|
|
# Read a single keypress in bash and translate it to a command name |
|
bash_key_input() { |
|
IFS='' read -rsn1 key |
|
if [[ $key = $'\e' ]]; then |
|
read -rsn2 -t 0.1 key |
|
[[ -z $key ]] && key=$'\e' # no follow-up character means escape key was pressed |
|
fi |
|
translate_input "$key" |
|
} |
|
|
|
# Read a single keypress and translate it (auto-detects shell) |
|
read_key() { |
|
if [[ $is_bash = true ]]; then |
|
bash_key_input |
|
else |
|
zsh_key_input |
|
fi |
|
} |
|
|
|
# Convert raw key input into readable command names (enter, backspace, arrow keys, etc.) |
|
translate_input() { |
|
# args: keyboard input |
|
case $1 in |
|
$'\n'|'') echo enter;; |
|
$'\177'|$'\b') echo backspace;; |
|
$'\e') echo escape;; |
|
"[A") echo up;; |
|
"[B") echo down;; |
|
"[C") echo right;; |
|
"[D") echo left;; |
|
*) echo "$1";; |
|
esac |
|
} |
|
|
|
# Get element at given index from array |
|
# Args: index, array |
|
index_array() { |
|
local i=$1 |
|
shift 1 |
|
echo "${@:$((i+1)):1}" # only way for array indexing to work for both bash and zsh |
|
# ${@:0:1} will return the function name |
|
} |
|
|
|
# Find the index of an element in an array, returns -1 if not found |
|
# Args: element, array |
|
index_of() { |
|
local e=$1 |
|
shift 1 |
|
|
|
local i=0 |
|
for s in "$@"; do |
|
if [[ $s = "$e" ]]; then |
|
echo $i |
|
return |
|
fi |
|
((i++)) |
|
done |
|
echo -1 |
|
} |
|
|
|
# Escape special regex characters so they're treated as literal text in search |
|
# Args: string |
|
local regex_chars='$^.?+*(){}[]/' |
|
escape_regex() { |
|
local str=$1 c='' |
|
for ((i=0; i<${#regex_chars}; i++)); do |
|
c=${regex_chars:$i:1} |
|
str=${str//"$c"/\\$c} |
|
done |
|
echo "$str" |
|
} |
|
|
|
# Convert string to lowercase (works in both bash and zsh) |
|
# Args: string |
|
to_lowercase() { |
|
local str=$1 |
|
if [[ $is_bash = true ]]; then |
|
echo "$str" | tr '[:upper:]' '[:lower:]' |
|
else |
|
echo "${str:l}" |
|
fi |
|
} |
|
|
|
## --- Rendering functions --- |
|
|
|
# Move cursor to where the first visible dir should be displayed |
|
cursor_to_first_visible_dir() { |
|
tput rc; tput cud1; tput cud1; tput cud1 |
|
} |
|
|
|
# Print a non-selected directory with scroll indicators if needed |
|
# Args: see below |
|
print_dir() { |
|
local dir_name=$1 |
|
local is_first_visible_row=$2 |
|
local is_last_visible_row=$3 |
|
local can_scroll_up=$4 |
|
local can_scroll_down=$5 |
|
local prefix=" " |
|
|
|
if [[ $is_first_visible_row = true ]] && [[ $can_scroll_up = true ]]; then |
|
prefix="↑" |
|
elif [[ $is_last_visible_row = true ]] && [[ $can_scroll_down = true ]]; then |
|
prefix="↓" |
|
fi |
|
|
|
echo " ${prefix} $dir_name $(tput el)" |
|
} |
|
|
|
# Print the selected directory with highlight and scroll indicators if needed |
|
# Args: see below |
|
print_selected_dir() { |
|
local dir_name=$1 |
|
local is_first_visible_row=$2 |
|
local is_last_visible_row=$3 |
|
local can_scroll_up=$4 |
|
local can_scroll_down=$5 |
|
local prefix=" " |
|
|
|
if [[ $is_first_visible_row = true ]] && [[ $can_scroll_up = true ]]; then |
|
prefix="↑" |
|
elif [[ $is_last_visible_row = true ]] && [[ $can_scroll_down = true ]]; then |
|
prefix="↓" |
|
fi |
|
|
|
echo " ${prefix} $(tput setab 7)$(tput setaf 0) $dir_name $(tput sgr0)$(tput el)" |
|
} |
|
|
|
# Render all visible directories with one highlighted as selected |
|
# Precondition: cursor is at the first line of the visible dirs |
|
# Precondition: there is at least one dir to show (otherwise this function should not be called) |
|
# Args: see below |
|
render_visible_dirs() { |
|
local selected_vindex=$1 # index of selected dir in visible dirs array, not in full dirs array |
|
local first_visible_dir_index=$2 # index of first visible dir in full dirs array |
|
local num_filtered_dirs=$3 |
|
shift 3 |
|
local visible_dirs=("$@") |
|
|
|
local can_scroll_up=false |
|
local can_scroll_down=false |
|
[[ $first_visible_dir_index -gt 0 ]] && can_scroll_up=true |
|
[[ $((first_visible_dir_index + ${#visible_dirs[@]})) -lt $num_filtered_dirs ]] && can_scroll_down=true |
|
|
|
local i=0 |
|
for dir in "${visible_dirs[@]}"; do |
|
local is_first=false |
|
local is_last=false |
|
[[ $i -eq 0 ]] && is_first=true |
|
[[ $i -eq $((${#visible_dirs[@]} - 1)) ]] && is_last=true |
|
|
|
if [[ $i -eq $selected_vindex ]]; then |
|
print_selected_dir "$dir" "$is_first" "$is_last" "$can_scroll_up" "$can_scroll_down" |
|
else |
|
print_dir "$dir" "$is_first" "$is_last" "$can_scroll_up" "$can_scroll_down" |
|
fi |
|
((i++)) |
|
done |
|
} |
|
|
|
# Display the current search string with cursor |
|
# Postcondition: cursor is at the first line of the visible dirs |
|
# Args: search string |
|
render_search_string() { |
|
tput rc; tput cud1; tput cud1; tput cuf 4 |
|
echo "$(tput setaf 3)${search_str}$(tput el)$(tput sgr0)_" |
|
} |
|
|
|
# Display the header showing current directory and keyboard controls |
|
render_heading() { |
|
tput rc |
|
local pwd_str=$(pwd) |
|
local lim_width=$(($(tput cols) - 20 - 5)) # 20 for "Change directory to ", 5 for buffer |
|
[[ lim_width -gt MAX_DIR_WIDTH ]] && lim_width=$MAX_DIR_WIDTH |
|
[[ ${#pwd_str} -gt $lim_width ]] && pwd_str="...${pwd_str:$((${#pwd_str} - lim_width + 3))}" |
|
echo "$(tput smul)Change directory to $(tput bold)${pwd_str}$(tput sgr0)$(tput el)" |
|
echo "$(tput dim)↑↓:select ←:parent →:enter ⏎:confirm ESC:cancel type:search$(tput sgr0)$(tput el)" |
|
} |
|
|
|
## --- Set up --- |
|
|
|
# Initialize variables |
|
local search_str='' prev_dir='' initial_pwd=$PWD initial_oldpwd=$OLDPWD |
|
local num_visible_dirs=$(($(tput lines) - 3 - 1)) # 3 fixed lines (header + instructions + search), 1 for buffer |
|
[[ $num_visible_dirs -gt $MAX_VISIBLE_DIRS ]] && num_visible_dirs=$MAX_VISIBLE_DIRS |
|
|
|
# Initialize interface |
|
tput civis |
|
stty -echo |
|
tput sc && render_heading |
|
echo -e "$SEARCH_PREFIX" |
|
for ((i=0; i<num_visible_dirs; i++)); do echo; done |
|
tput cuu $((num_visible_dirs + 3)) |
|
tput sc |
|
|
|
# Cleanup functions |
|
cleanup_base() { |
|
tput rc |
|
tput ed |
|
tput cnorm |
|
stty echo |
|
trap - INT |
|
} |
|
|
|
cleanup_confirm() { |
|
cleanup_base |
|
builtin cd $initial_pwd # support expected `cd -` behavior after exiting |
|
builtin cd $OLDPWD |
|
echo "Working directory changed: $(tput bold)$(pwd)$(tput el)$(tput sgr0)" |
|
} |
|
|
|
cleanup_cancel() { |
|
cleanup_base |
|
builtin cd $initial_oldpwd |
|
builtin cd $initial_pwd # restore expected `cd -` behavior |
|
echo "Restored working directory: $(tput bold)$(pwd)$(tput el)$(tput sgr0)" |
|
return |
|
} |
|
|
|
trap 'cleanup_cancel; return' INT |
|
|
|
## --- Main loop --- |
|
|
|
while true; do |
|
# Determine the subdirectories |
|
local filtered_dirs=() all_dirs=() |
|
local subdirs=$( (/bin/ls -F | grep /$ | sort -f) ) |
|
if [[ -n $subdirs ]]; then |
|
[[ $is_bash = true ]] && IFS=$'\n' read -r -d '' -a all_dirs <<< "$subdirs" |
|
[[ $is_zsh = true ]] && all_dirs+=("${(f)subdirs}") |
|
fi |
|
|
|
# Select previous dir (if left arrow key was pressed) |
|
local selected_index=0 |
|
local prev_dir_index=$(index_of "$prev_dir" "${all_dirs[@]}") |
|
[[ $prev_dir_index -ne -1 ]] && selected_index=$prev_dir_index; |
|
local key_pressed='' |
|
|
|
local first_visible_dir_index=$((selected_index - num_visible_dirs + 1)) |
|
[[ $first_visible_dir_index -lt 0 ]] && first_visible_dir_index=0 |
|
local do_rerender_dirs=true did_update_search=true |
|
|
|
render_heading |
|
|
|
# Loop while still in the same directory |
|
while true; do |
|
# Update filtered directories if search string changed |
|
if [[ $did_update_search = true ]]; then |
|
render_search_string "$search_str" |
|
|
|
# Filter directories by search string |
|
filtered_dirs=() |
|
if [[ -z $search_str ]]; then |
|
filtered_dirs=("${all_dirs[@]}") |
|
else |
|
for dir in "${all_dirs[@]}"; do |
|
local dir_lowercase=$(to_lowercase "$dir") |
|
local regex=$(to_lowercase "$search_str") |
|
regex=$(escape_regex "$regex") |
|
[[ "$dir_lowercase" =~ $regex ]] && filtered_dirs+=("$dir") |
|
done |
|
fi |
|
|
|
# Handle special cases for empty results |
|
local show_message=false |
|
local message="" |
|
if [[ ${#filtered_dirs[@]} -eq 0 ]]; then |
|
show_message=true |
|
if [[ ${#all_dirs[@]} -eq 0 ]]; then |
|
message="No subdirectories (use ← to go to parent)" |
|
else |
|
message="No matches found" |
|
fi |
|
fi |
|
|
|
local num_filtered_dirs=${#filtered_dirs[@]} |
|
else |
|
cursor_to_first_visible_dir |
|
fi |
|
did_update_search=false |
|
|
|
# Rerender directories if needed |
|
if [[ $do_rerender_dirs = true ]]; then |
|
# Determine "scroll" position |
|
if [[ $selected_index -lt $first_visible_dir_index ]]; then first_visible_dir_index=$selected_index |
|
elif [[ $selected_index -ge $((first_visible_dir_index + num_visible_dirs)) ]]; then first_visible_dir_index=$((selected_index - num_visible_dirs + 1)); fi |
|
|
|
# Print directories (or message if there are none) |
|
if [[ $show_message = true ]]; then |
|
echo " $(tput dim)${message}$(tput sgr0)$(tput el)" |
|
tput ed |
|
else |
|
visible_dirs=( "${filtered_dirs[@]:$first_visible_dir_index:$num_visible_dirs}" ) |
|
render_visible_dirs $((selected_index - first_visible_dir_index)) $first_visible_dir_index $num_filtered_dirs "${visible_dirs[@]}" |
|
tput ed |
|
fi |
|
fi |
|
do_rerender_dirs=true |
|
|
|
# Process user input |
|
[[ $is_bash = true ]] && key_pressed=$(bash_key_input) || key_pressed=$(zsh_key_input) |
|
case $key_pressed in |
|
escape) cleanup_cancel; return;; |
|
left) [[ $PWD != "$HOME" ]] && break;; |
|
up) [[ $num_filtered_dirs -gt 0 ]] && { ((selected_index--)); [[ $selected_index -lt 0 ]] && selected_index=$((num_filtered_dirs - 1)); };; |
|
down) [[ $num_filtered_dirs -gt 0 ]] && { ((selected_index++)); [[ $selected_index -ge $num_filtered_dirs ]] && selected_index=0; };; |
|
right) [[ $num_filtered_dirs -gt 0 ]] && break;; |
|
enter) break;; |
|
'\') do_rerender_dirs=false;; |
|
backspace) |
|
search_str="${search_str%?}" |
|
selected_index=0 |
|
did_update_search=true;; |
|
*) |
|
search_str+=$key_pressed |
|
selected_index=0 |
|
did_update_search=true;; |
|
esac |
|
done |
|
|
|
# cd accordingly |
|
case $key_pressed in |
|
right) |
|
if [[ $num_filtered_dirs -gt 0 ]]; then |
|
builtin cd "$(index_array "$selected_index" "${filtered_dirs[@]}")"; prev_dir='' |
|
else |
|
break # exit if there are no dirs to enter |
|
fi;; |
|
left) prev_dir=$(printf '%s/' "${PWD##*/}"); builtin cd ..;; |
|
enter) break;; |
|
esac |
|
|
|
search_str='' |
|
done |
|
|
|
cleanup_confirm |
|
} |