├── .github └── workflows │ └── main.yml ├── LICENSE ├── README └── shfm /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Shellcheck 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v1 8 | - name: Run shellcheck. 9 | run: shellcheck shfm 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Dylan Araps 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | shfm 2 | ________________________________________________________________________________ 3 | 4 | file manager written in posix shell 5 | 6 | screenshot: https://user-images.githubusercontent.com/6799467/89270554-2b40ab00-d644-11ea-9f2b-bdabcba61a09.png 7 | 8 | 9 | features 10 | ________________________________________________________________________________ 11 | 12 | * no dependencies other than a POSIX shell + POSIX [, printf, dd and stty *** 13 | * tiny 14 | * single file 15 | * no compilation needed 16 | * correctly handles files with funky names (newlines, etc) 17 | * works with very small terminal sizes. 18 | * cd on exit 19 | * works when run in subshell $(shfm) 20 | 21 | *** see portability notes towards bottom of README. 22 | 23 | 24 | keybinds 25 | ________________________________________________________________________________ 26 | 27 | j - down 28 | k - up 29 | l - open file or directory 30 | h - go up level 31 | g - go to top 32 | G - go to bottom 33 | q - quit 34 | : - cd to 35 | / - search current directory * 36 | - - go to last directory 37 | ~ - go home 38 | ! - spawn shell 39 | . - toggle hidden files 40 | ? - show help 41 | 42 | Also supported: 43 | 44 | down arrow - down 45 | up arrow - up 46 | left arrow - go up level 47 | right arrow - open file or directory 48 | 49 | backspace - up 50 | enter - open file or directory 51 | 52 | 53 | todo 54 | ________________________________________________________________________________ 55 | 56 | - [x] sanitize filenames for display. 57 | - [ ] print directories first (hard). 58 | - [x] fix buggy focus after exit from inline editor. 59 | - [ ] maybe file operations. 60 | - [x] add / to directories. 61 | - [x] going up directories should center entry. 62 | - [x] abstract over sequences. 63 | - [x] look into whether tput is feasible. 64 | 65 | 66 | cd on exit 67 | ________________________________________________________________________________ 68 | 69 | On exit, the utility will print the path to working directory to . To 70 | disable this behavior, run with 'shfm >/dev/null'. Usage of this output is 71 | rather flexible. 72 | 73 | # cd to directory on exit 74 | cd "$(shfm)" 75 | 76 | # store pwd in var on exit 77 | var=$(shfm) 78 | 79 | # store pwd in a file on exit 80 | shfm > file 81 | 82 | For ease of use, a wrapper function can be added to your .shellrc (.bashrc, etc). 83 | 84 | shfm() { 85 | cd "$(command shfm "$@")" 86 | } 87 | 88 | 89 | opener 90 | ________________________________________________________________________________ 91 | 92 | Opening files in different applications (based on mime-type or file extension) 93 | can be achieved via an environment variable (SHFM_OPENER) set to the location of 94 | a small external script. If unset, the default for all files is '$EDITOR' (and 95 | if that is unset, 'vi'). 96 | 97 | The script receives a single argument, the full path to the selected file. 98 | The opener script is also useful on the command-line. The environment variable 99 | is set as follows. 100 | 101 | export SHFM_OPENER=/path/to/script 102 | 103 | Example scripts: 104 | 105 | #!/bin/sh -e 106 | # 107 | # open file in application based on file extension 108 | 109 | case $1 in 110 | *.mp3|*.flac|*.wav) 111 | mpv --no-video "$1" 112 | ;; 113 | 114 | *.mp4|*.mkv|*.webm) 115 | mpv "$1" 116 | ;; 117 | 118 | *.png|*.gif|*.jpg|*.jpe|*.jpeg) 119 | gimp "$1" 120 | ;; 121 | 122 | *.html|*.pdf) 123 | firefox "$1" 124 | ;; 125 | 126 | # all other files 127 | *) 128 | "${EDITOR:=vi}" "$1" 129 | ;; 130 | esac 131 | 132 | 133 | #!/bin/sh -e 134 | # 135 | # open file in application based on mime-type 136 | 137 | mime_type=$(file -bi) 138 | 139 | case $mime_type in 140 | audio/*) 141 | mpv --no-video "$1" 142 | ;; 143 | 144 | video/*) 145 | mpv "$1" 146 | ;; 147 | 148 | image/*) 149 | gimp "$1" 150 | ;; 151 | 152 | text/html*|application/pdf*) 153 | firefox "$1" 154 | ;; 155 | 156 | text/*|) 157 | "${EDITOR:=vi}" "$1" 158 | ;; 159 | 160 | *) 161 | printf 'unknown mime-type %s\n' "$mime_type" 162 | ;; 163 | esac 164 | 165 | 166 | portability notes 167 | ________________________________________________________________________________ 168 | 169 | * SIGWINCH and the size parameter to stty are not /yet/ POSIX (but will be). 170 | 171 | - https://austingroupbugs.net/view.php?id=1053 172 | - https://austingroupbugs.net/view.php?id=1151 173 | 174 | 175 | * VT100/ANSI escape sequences (widely available) are used in place of tput. A 176 | few non-VT100 sequences /are/ needed however. 177 | 178 | - IL vt102 \033[L: upwards scroll. (required) 179 | - xterm \033[?1049[lh]: alternate screen. (optional) 180 | - DECTCEM vt520 \033[?25[lh]: cursor visibility. (optional) 181 | 182 | Why avoid tput? 183 | 184 | POSIX only specifies three operands for tput; clear, init and reset [0]. We 185 | cannot rely on anything additional working across operating systems and tput 186 | implementations. 187 | 188 | Further, a tput implementation may use terminfo names (example: setaf) or 189 | termcap names (example: AF). We cannot blindly use tput and expect it to 190 | work everywhere. [1] 191 | 192 | We could simply follow terminfo and yell at anyone who doesn't though I'm 193 | also not too keen on requiring tput as a dependency as not all systems have 194 | it. I've found that raw VT100/VT102 sequences work widely. 195 | 196 | Neofetch uses them and supports a wide array of operating systems (Linux, 197 | IRIX, AIX, HP-UX, various BSDs, Haiku, MINIX, OpenIndiana, FreeMiNT, etc. 198 | YMMV 199 | 200 | [0] https://pubs.opengroup.org/onlinepubs/009695399/utilities/tput.html 201 | [1] https://invisible-island.net/ncurses/man/tput.1.html#h2-PORTABILITY 202 | 203 | 204 | implementation details 205 | ________________________________________________________________________________ 206 | 207 | * Draws are partial! 208 | 209 | The file manager will only redraw what is necessary. Every line scrolled 210 | corresponds to three lines being redrawn. The current line (clear highlight), 211 | the destination line (set highlight) and the status line (update location). 212 | 213 | 214 | * POSIX shell has no arrays. 215 | 216 | It does however have an argument list (used for passing command-line arguments 217 | to the script and when calling functions). 218 | 219 | Restrictions: 220 | 221 | - Can only have one list at a time (in the same scope). 222 | - Can restrict a list's scope but cannot extend it. 223 | - Cannot grab element by index. 224 | 225 | Things I'm thankful for: 226 | 227 | - Elements can be "popped" off the front of the list (using shift). 228 | - List size is given to us (via $#). 229 | - No need to use a string delimited by some character. 230 | - Can loop over elements. 231 | 232 | 233 | * Cursor position is tracked manually. 234 | 235 | Grabbing the current cursor position cannot be done reliably from POSIX shell. 236 | Instead, the cursor starts at 0,0 and each movement modifies the value of a 237 | variable (relative Y position in screen). This variable is how the file 238 | manager knows which line of the screen the cursor is on. 239 | 240 | 241 | * Multi-byte input is handled by using a 2D case statement. 242 | 243 | (I don't really know what to call this, suggestions appreciated) 244 | 245 | Rather than using read timeouts (we can't sleep < 1s in POSIX shell anyway) 246 | to handle multi-byte input, shfm tracks location within sequences and handles 247 | this in a really nice way. 248 | 249 | The case statement matches "$char$esc" with "$esc" being an integer holding 250 | position in sequences. To give an example, down arrow emits '\033[B'. 251 | 252 | - When '\033?' is found, the value of 'esc' is set to '1'. 253 | - When '[1' is found, the value of 'esc' is set to '2'. 254 | - When 'B2' is found, we know it's '\033[B' and handle down arrow. 255 | - If input doesn't follow this sequence, 'esc' is reset to '0'. 256 | 257 | 258 | * Filename escaping works via looping over a string char by char. 259 | 260 | I didn't think this was possible in POSIX shell until I needed to do this in 261 | KISS Linux's package manager and found a way to do so. 262 | 263 | I'll let the code speak for itself (comments added for clarity): 264 | 265 | file_escape() { 266 | # store the argument (file name) in a temporary variable. 267 | # ensure that 'safe' is empty (we have no access to the local keyword 268 | # and can't use local variables without also using a sub-shell). This 269 | # variable will contain its prior value (if it has one) otherwise. 270 | tmp=$1 safe= 271 | 272 | # loop over string char by char. 273 | # this takes the approach of infinite loop + inner break condition as 274 | # we have no access to [ (personal restriction). 275 | while :; do 276 | # Remove everything after the first character. 277 | c=${tmp%"${tmp#?}"*} 278 | 279 | # Construct a new string, replacing anything unprintable with '?'. 280 | case $c in 281 | [[:print:]]) safe=$safe$c ;; 282 | '') return ;; # we have nothing more to do, return. 283 | *) safe=$safe\? ;; 284 | esac 285 | 286 | # Remove the first character. 287 | # This shifts our position forward. 288 | tmp=${tmp#?} 289 | done 290 | } 291 | 292 | # Afterwards, the variable 'safe' contains the escaped filename. Using 293 | # globals here is a must. Printing to the screen and capturing that 294 | # output is too slow. 295 | 296 | 297 | * SIGWINCH handler isn't executed until key press is made. 298 | 299 | SIGWINCH doesn't seem to execute asynchronously when the script is also 300 | waiting for input. This causes resize to require a key press. 301 | 302 | I'm not too bothered by this. It does save me implementing resize logic which 303 | is utter torture. :) 304 | -------------------------------------------------------------------------------- /shfm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | esc() { 4 | case $1 in 5 | # vt100 (IL is vt102) (DECTCEM is vt520) 6 | CUD) printf '%s[%sB' "$esc_c" "$2" ;; # cursor down 7 | CUP) printf '%s[%s;%sH' "$esc_c" "$2" "$3" ;; # cursor home 8 | CUU) printf '%s[%sA' "$esc_c" "$2" ;; # cursor up 9 | DECAWM) printf '%s[?7%s' "$esc_c" "$2" ;; # line wrap 10 | DECRC) printf '%s8' "$esc_c" ;; # cursor restore 11 | DECSC) printf '%s7' "$esc_c" ;; # cursor save 12 | DECSTBM) printf '%s[%s;%sr' "$esc_c" "$2" "$3" ;; # scroll region 13 | DECTCEM) printf '%s[?25%s' "$esc_c" "$2" ;; # cursor visible 14 | ED[0-2]) printf '%s[%sJ' "$esc_c" "${1#ED}" ;; # clear screen 15 | EL[0-2]) printf '%s[%sK' "$esc_c" "${1#EL}" ;; # clear line 16 | IL) printf '%s[%sL' "$esc_c" "$2" ;; # insert line 17 | SGR) printf '%s[%s;%sm' "$esc_c" "$2" "$3" ;; # colors 18 | 19 | # xterm (since 1988, supported widely) 20 | screen_alt) printf '%s[?1049%s' "$esc_c" "$2" ;; # alternate buffer 21 | esac 22 | } 23 | 24 | term_setup() { 25 | stty=$(stty -g) 26 | stty -icanon -echo 27 | esc screen_alt h 28 | esc DECAWM l 29 | esc DECTCEM l 30 | esc ED2 31 | esc DECSTBM 1 "$((LINES - 2))" 32 | } 33 | 34 | term_reset() { 35 | esc DECAWM h >&2 36 | esc DECTCEM h >&2 37 | esc ED2 >&2 38 | esc DECSTBM >&2 39 | esc screen_alt l >&2 40 | stty "$stty" 41 | 42 | # needed for cd-on-exit 43 | printf '%s\n' "$PWD" >&1 44 | } 45 | 46 | term_resize() { 47 | # false-positive, behavior intentional, globbing is disabled. 48 | # shellcheck disable=2046 49 | { 50 | set -f 51 | set +f -- $(stty size) 52 | } 53 | 54 | LINES=$1 COLUMNS=$2 55 | 56 | # space for status_line 57 | bottom=$((LINES - 2)) 58 | } 59 | 60 | term_scroll_down() { 61 | case $((y - $#)) in 62 | [0-9]*) return 63 | esac 64 | 65 | y=$((y + 1)) 66 | y2=$((y2 + 1 < bottom ? y2 + 1 : bottom)) 67 | 68 | line_print "$((y - 1))" "$@" 69 | printf '\n' 70 | line_print "$y" "$@" 71 | status_line "$#" 72 | } 73 | 74 | term_scroll_up() { 75 | case $y in 76 | -*|0|1) return 77 | esac 78 | 79 | y=$((y - 1)) 80 | 81 | line_print "$((y + 1))" "$@" 82 | 83 | case $y2 in 84 | 1) esc IL ;; 85 | *) esc CUU; y2=$((y2 > 1 ? y2 - 1 : 1)) 86 | esac 87 | 88 | line_print "$y" "$@" 89 | status_line "$#" 90 | } 91 | 92 | cmd_run() { 93 | stty "$stty" 94 | esc DECTCEM h 95 | esc DECSTBM 96 | esc ED2 97 | "$@" ||: 98 | esc DECSTBM 1 "$((LINES - 2))" 99 | esc DECTCEM l 100 | stty -icanon -echo 101 | hist=2 102 | } 103 | 104 | file_escape() { 105 | tmp=$1 safe= 106 | 107 | # loop over string char by char 108 | while c=${tmp%"${tmp#?}"}; do 109 | case $c in 110 | '') return ;; 111 | [[:cntrl:]]) safe=$safe\? ;; 112 | *) safe=$safe$c ;; 113 | esac 114 | 115 | tmp=${tmp#?} 116 | done 117 | } 118 | 119 | hist_search() { 120 | hist=0 j=1 121 | 122 | for file do 123 | case ${PWD%%/}/$file in 124 | "$old_pwd") y=$j y2=$((j >= bottom ? mid : j)) cur=$file 125 | esac 126 | 127 | j=$((j + 1)) 128 | done 129 | } 130 | 131 | list_print() { 132 | esc ED2 133 | esc CUP 134 | 135 | i=1 136 | end=$((bottom + 1)) 137 | mid=$((bottom / 4 < 5 ? 1 : bottom / 4)) 138 | 139 | case $# in 140 | 1) [ -e "$1" ] || [ "$1" = 'no results' ] || set -- empty 141 | esac 142 | 143 | case $hist in 144 | 2) # redraw after cmd run 145 | shift "$((y > y2 ? y - y2 : 0))" 146 | ;; 147 | 148 | 1) # redraw after go-to-parent 149 | hist_search "$@" 150 | shift "$((y >= bottom ? y - mid : 0))" 151 | ;; 152 | 153 | *) # everything else 154 | shift "$((y >= bottom ? y - bottom : 0))" 155 | ;; 156 | esac 157 | 158 | for file do 159 | case $i in 160 | "$y2") esc SGR 0 7 161 | esac 162 | 163 | case $((i - end)) in 164 | -*) 165 | line_format "$file" 166 | esc CUD 167 | ;; 168 | esac 169 | 170 | i=$((i + 1)) 171 | done 172 | 173 | esc CUP "$((y > y2 ? y2 : y))" 174 | } 175 | 176 | redraw() { 177 | list_print "$@" 178 | status_line "$#" 179 | } 180 | 181 | status_line() { 182 | esc DECSC 183 | esc CUP "$LINES" 184 | 185 | case $USER in 186 | root) esc SGR 31 7 ;; 187 | *) esc SGR 34 7 ;; 188 | esac 189 | 190 | printf '%*s\r%s ' "$COLUMNS" "" "($y/$1)" 191 | 192 | case $ltype in 193 | '') printf %s "$PWD" ;; 194 | *) printf %s "$ltype" 195 | esac 196 | 197 | esc SGR 0 0 198 | esc DECRC 199 | } 200 | 201 | prompt() { 202 | esc DECSC 203 | esc CUP "$LINES" 204 | printf %s "$1" 205 | esc DECTCEM h 206 | esc EL0 207 | 208 | case $2 in 209 | r) 210 | stty icanon echo 211 | read -r ans ||: 212 | stty -icanon -echo 213 | ;; 214 | esac 215 | 216 | esc DECRC 217 | esc DECTCEM l 218 | status_line "($y/$#) $PWD" 219 | } 220 | 221 | line_print() { 222 | offset=$1 223 | 224 | case $offset in 225 | "$y") esc SGR 0 7 226 | esac 227 | 228 | shift "$offset" 229 | 230 | case $offset in 231 | "$y") cur=$1 232 | esac 233 | 234 | line_format "$1" 235 | } 236 | 237 | line_format() { 238 | file_escape "$1" 239 | [ -d "$1" ] && esc SGR 1 31 240 | printf %s "$safe" 241 | [ -d "$1" ] && printf / 242 | esc SGR 0 0 243 | esc EL0 244 | printf '\r' 245 | } 246 | 247 | main() { 248 | set -e 249 | 250 | case $1 in 251 | -h|--help) 252 | printf 'shfm -[hv] \n' 253 | exit 0 254 | ;; 255 | 256 | -v|--version) 257 | printf 'shfm 0.4.2\n' 258 | exit 0 259 | ;; 260 | 261 | *) 262 | cd -- "${1:-"$PWD"}" 263 | ;; 264 | esac 265 | 266 | esc_c=$(printf '\033') 267 | bs_char=$(printf '\177') 268 | 269 | set -- * 270 | cur=$1 271 | 272 | term_resize 273 | term_setup 274 | 275 | trap 'term_reset' EXIT INT 276 | trap 'term_resize; term_setup; y=1 y2=1; redraw "$@"' WINCH 277 | 278 | y=1 y2=1 279 | redraw "$@" 280 | 281 | while key=$(dd ibs=1 count=1 2>/dev/null); do 282 | case $key${esc:=0} in 283 | k?|A2) 284 | term_scroll_up "$@" 285 | ;; 286 | 287 | j?|B2) 288 | term_scroll_down "$@" 289 | ;; 290 | 291 | l?|C2|"$esc") # ARROW RIGHT 292 | if [ -d "$cur" ] && cd -- "$cur" >/dev/null 2>&1; then 293 | set -- * 294 | y=1 y2=1 cur=$1 ltype= 295 | redraw "$@" 296 | 297 | elif [ -e "$cur" ]; then 298 | cmd_run "${SHFM_OPENER:="${EDITOR:=vi}"}" "$cur" 299 | redraw "$@" 300 | fi 301 | ;; 302 | 303 | h?|D2|"$bs_char"?) # ARROW LEFT 304 | old_pwd=$PWD 305 | 306 | case $ltype in 307 | '') cd .. || continue ;; 308 | *) ltype= ;; 309 | esac 310 | 311 | set -- * 312 | y=1 y2=1 cur=$1 hist=1 313 | redraw "$@" 314 | ;; 315 | 316 | g?) 317 | case $y in 318 | 1) continue 319 | esac 320 | 321 | y=1 y2=1 cur=$1 322 | redraw "$@" 323 | ;; 324 | 325 | G?) 326 | y=$# 327 | y2=$(($# < bottom ? $# : bottom)) 328 | line_print "$y" "$@" 329 | redraw "$@" 330 | ;; 331 | 332 | .?) 333 | case ${hidden:=1} in 334 | 1) hidden=0; set -- .* ;; 335 | 0) hidden=1; set -- * 336 | esac 337 | 338 | y=1 y2=1 cur=$1 339 | redraw "$@" 340 | ;; 341 | 342 | :?) 343 | prompt "cd: " r 344 | 345 | # false positive, behavior intentional 346 | # shellcheck disable=2088 347 | case $ans in 348 | '~') ans=$HOME ;; 349 | '~/'*) ans=$HOME/${ans#"~/"} 350 | esac 351 | 352 | cd -- "${ans:="$0"}" >/dev/null 2>&1|| continue 353 | set -- * 354 | y=1 y2=1 cur=$1 355 | redraw "$@" 356 | ;; 357 | 358 | /?) 359 | prompt / r 360 | 361 | IFS= 362 | # globbing intentional, word splitting is disabled. 363 | # shellcheck disable=2086 364 | set -- $ans* 365 | unset IFS 366 | 367 | case $1$# in 368 | "$ans*1") set -- 'no results' 369 | esac 370 | 371 | y=1 y2=1 cur=$1 ltype="search $PWD/$ans*" 372 | redraw "$@" 373 | status_line "$#" 374 | ;; 375 | 376 | -?) 377 | cd -- "$OLDPWD" >/dev/null 2>&1|| continue 378 | set -- * 379 | y=1 y2=1 cur=$1 380 | redraw "$@" 381 | ;; 382 | 383 | \~?) 384 | cd || continue 385 | set -- * 386 | y=1 y2=1 cur=$1 387 | redraw "$@" 388 | ;; 389 | 390 | \!?) 391 | export SHFM_LEVEL 392 | SHFM_LEVEL=$((SHFM_LEVEL + 1)) 393 | cmd_run "${SHELL:=/bin/sh}" 394 | redraw "$@" 395 | ;; 396 | 397 | \??) 398 | set -- 'j - down' \ 399 | 'k - up' \ 400 | 'l - open file or directory' \ 401 | 'h - go up level' \ 402 | 'g - go to top' \ 403 | 'G - go to bottom' \ 404 | 'q - quit' \ 405 | ': - cd to ' \ 406 | '/ - search current directory *' \ 407 | '- - go to last directory' \ 408 | '~ - go home' \ 409 | '! - spawn shell' \ 410 | '. - toggle hidden files' \ 411 | '? - show keybinds' 412 | 413 | y=1 y2=1 cur=$1 ltype=keybinds 414 | redraw "$@" 415 | status_line "$#" 416 | ;; 417 | 418 | q?) exit 0 ;; 419 | 420 | # handle keys which emit escape sequences 421 | "$esc_c"*) esc=1 ;; 422 | '[1') esc=2 ;; 423 | *) esc=0 ;; 424 | esac 425 | done 426 | } 427 | 428 | main "$@" >/dev/tty 429 | --------------------------------------------------------------------------------