├── .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 |
--------------------------------------------------------------------------------