sfm (50046B)
1 #!/bin/sh 2 # sfm - Simple File Manager in POSIX sh (flicker-free) 3 # version 0.4 4 5 # --- terminal control --- 6 tput_cmd() { command -v tput >/dev/null 2>&1 && tput "$@"; } 7 8 RESET=$(tput_cmd sgr0) 9 BOLD=$(tput_cmd bold) 10 REV=$(tput_cmd rev) 11 RED=$(tput_cmd setaf 1) 12 GREEN=$(tput_cmd setaf 2) 13 YELLOW=$(tput_cmd setaf 3) 14 CYAN=$(tput_cmd setaf 6) 15 WHITE=$(tput_cmd setaf 7) 16 MAGENTA=$(tput_cmd setaf 5) 17 BLUE=$(tput_cmd setaf 4) 18 ERASE_LINE=$(tput_cmd el || printf '\033[K') 19 20 # map a filename to a display colour based on extension 21 file_colour() { 22 _fc_name="$1" 23 _fc_ext="${_fc_name##*.}" 24 _fc_ext=$(printf '%s' "$_fc_ext" | tr '[:upper:]' '[:lower:]') 25 # executable? 26 [ -x "${CWD}/${_fc_name}" ] && { printf '%s' "${RED}${BOLD}"; return; } 27 case "$_fc_ext" in 28 # images 29 jpg|jpeg|png|gif|bmp|tiff|tif|webp|svg|ico|heic|heif|avif) 30 printf '%s' "${MAGENTA}" ;; 31 # video 32 mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpeg|mpg|3gp|ogv) 33 printf '%s' "${MAGENTA}${BOLD}" ;; 34 # audio 35 mp3|flac|ogg|wav|aac|m4a|opus|wma|aiff) 36 printf '%s' "${CYAN}${BOLD}" ;; 37 # archives 38 zip|tar|gz|bz2|xz|zst|7z|rar|tgz|tbz2) 39 printf '%s' "${RED}" ;; 40 # documents / text 41 pdf|doc|docx|odt|xls|xlsx|ods|ppt|pptx|odp) 42 printf '%s' "${YELLOW}" ;; 43 # code / scripts 44 sh|bash|zsh|fish|py|rb|pl|lua|js|ts|jsx|tsx|\ 45 c|h|cpp|cc|cxx|hpp|rs|go|java|kt|swift|cs|php|r) 46 printf '%s' "${GREEN}" ;; 47 # data / config 48 json|xml|yaml|yml|toml|ini|conf|cfg|env) 49 printf '%s' "${CYAN}" ;; 50 # markdown / text 51 md|markdown|rst|txt|log) 52 printf '%s' "${WHITE}" ;; 53 # default 54 *) printf '%s' "${WHITE}" ;; 55 esac 56 } 57 58 goto() { printf '\033[%d;%dH' "$1" "$2"; } 59 hide_cursor() { printf '\033[?25l'; } 60 show_cursor() { printf '\033[?25h'; } 61 term_rows() { tput_cmd lines || echo 24; } 62 term_cols() { tput_cmd cols || echo 80; } 63 64 # --- state --- 65 if [ -n "$1" ]; then 66 CWD=$(cd "$1" 2>/dev/null && pwd) || { printf 'sfm: %s: no such directory\n' "$1" >&2; exit 1; } 67 else 68 CWD=$(cd "$PWD" 2>/dev/null && pwd) || CWD="/" 69 fi 70 SEL=0 71 OFFSET=0 72 PREV_SEL=0 73 PREV_OFFSET=0 74 NEED_FULL_REDRAW=1 75 LAST_CHILD="" # name of dir we came from, used to restore selection on go-back 76 FILTER="" # current search string; empty = no filter 77 ALL_ENTRIES="" # unfiltered entries 78 ALL_COUNT=0 79 SEARCHING=0 # 1 while in search input mode 80 SELECTED="" # newline-separated list of multi-selected entry names 81 CLIPBOARD="" # full path of yanked/cut entry 82 CLIP_MODE="" # "copy" or "cut" 83 INFO_MSG="" # ephemeral message shown in botbar 84 SHOW_HIDDEN=0 # 1 = show dotfiles 85 SORT_MODE="name" # name | size | date 86 SHOW_DETAILS=0 # 1 = show size+date column 87 SHOW_PREVIEW=0 # 1 = show preview pane on right 88 TRASH_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/sfm-trash" 89 mkdir -p "$TRASH_DIR" 90 PREV_CWD="" # last visited directory, for jumping back 91 92 # normalise CWD: resolve to absolute canonical path, no double slashes 93 norm_cwd() { 94 CWD=$(cd "$CWD" 2>/dev/null && pwd) || CWD="/" 95 # squeeze any double slashes (some systems return // for //<path>) 96 CWD=$(printf '%s' "$CWD" | tr -s '/') 97 } 98 99 # safely join CWD with a name, avoiding double slash at root 100 joinpath() { [ "$CWD" = "/" ] && printf '/%s' "$1" || printf '%s/%s' "$CWD" "$1"; } 101 BOOKMARK_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/sfm/bookmarks" 102 mkdir -p "$(dirname "$BOOKMARK_FILE")" 103 [ -f "$BOOKMARK_FILE" ] || touch "$BOOKMARK_FILE" 104 105 # --- load directory --- 106 load_entries() { 107 ENTRIES="" 108 COUNT=0 109 110 # build list of dir names then file names, including dotfiles if enabled 111 for d in "$CWD"/*/; do 112 [ -d "$d" ] || continue 113 [ -L "${d%/}" ] && continue # symlinks handled separately 114 name="${d%/}"; name="${name##*/}" 115 [ "$name" = "*" ] && continue 116 case "$name" in .*) continue ;; esac 117 ENTRIES="$ENTRIES 118 ${name}/" 119 COUNT=$((COUNT + 1)) 120 done 121 if [ "$SHOW_HIDDEN" = "1" ]; then 122 for d in "$CWD"/./; do : ; done 123 for d in "$CWD"/.*/; do 124 [ -d "$d" ] || continue 125 [ -L "${d%/}" ] && continue # symlinks handled separately 126 name="${d%/}"; name="${name##*/}" 127 [ "$name" = "." ] || [ "$name" = ".." ] && continue 128 [ "$name" = ".*" ] && continue 129 ENTRIES="$ENTRIES 130 ${name}/" 131 COUNT=$((COUNT + 1)) 132 done 133 fi 134 135 for f in "$CWD"/*; do 136 [ -e "$f" ] || [ -L "$f" ] || continue 137 [ -d "$f" ] && ! [ -L "$f" ] && continue 138 name="${f##*/}" 139 [ "$name" = "*" ] && continue 140 case "$name" in .*) continue ;; esac 141 if [ -L "$f" ]; then 142 ENTRIES="$ENTRIES 143 ${name}@" 144 else 145 ENTRIES="$ENTRIES 146 ${name}" 147 fi 148 COUNT=$((COUNT + 1)) 149 done 150 if [ "$SHOW_HIDDEN" = "1" ]; then 151 for f in "$CWD"/.*; do 152 [ -e "$f" ] || [ -L "$f" ] || continue 153 [ -d "$f" ] && ! [ -L "$f" ] && continue 154 name="${f##*/}" 155 [ "$name" = ".*" ] && continue 156 if [ -L "$f" ]; then 157 ENTRIES="$ENTRIES 158 ${name}@" 159 else 160 ENTRIES="$ENTRIES 161 ${name}" 162 fi 163 COUNT=$((COUNT + 1)) 164 done 165 fi 166 167 ENTRIES="${ENTRIES# 168 }" 169 170 # --- sort files only (dirs always stay first) --- 171 if [ "$SORT_MODE" != "name" ]; then 172 # separate dirs and files 173 _dirs=""; _files="" 174 _rest="$ENTRIES" 175 while [ -n "$_rest" ]; do 176 _line="${_rest%% 177 *}"; _next="${_rest#* 178 }" 179 [ "$_next" = "$_rest" ] && _next="" 180 _rest="$_next" 181 [ -z "$_line" ] && continue 182 case "$_line" in 183 */) if [ -z "$_dirs" ]; then _dirs="$_line" 184 else _dirs="$_dirs 185 $_line"; fi ;; 186 *) if [ -z "$_files" ]; then _files="$_line" 187 else _files="$_files 188 $_line"; fi ;; 189 esac 190 done 191 192 # sort files using ls into a tmp file 193 if [ -n "$_files" ]; then 194 case "$SORT_MODE" in 195 size) ls -1Sp "$CWD" 2>/dev/null | grep -v '/' > /tmp/_fm_sorted ;; 196 date) ls -1tp "$CWD" 2>/dev/null | grep -v '/' > /tmp/_fm_sorted ;; 197 esac 198 # only keep files that were already in our list (respects hidden filter) 199 _sorted="" 200 while IFS= read -r _n; do 201 [ -z "$_n" ] && continue 202 # check if _n is in _files 203 case " 204 ${_files} 205 " in *" 206 ${_n} 207 "*) if [ -z "$_sorted" ]; then _sorted="$_n" 208 else _sorted="$_sorted 209 $_n"; fi ;; 210 esac 211 done < /tmp/_fm_sorted 212 _files="$_sorted" 213 fi 214 215 # reassemble: dirs + sorted files 216 ENTRIES="" 217 _rest="$_dirs" 218 while [ -n "$_rest" ]; do 219 _line="${_rest%% 220 *}"; _next="${_rest#* 221 }" 222 [ "$_next" = "$_rest" ] && _next="" 223 _rest="$_next" 224 [ -z "$_line" ] && continue 225 if [ -z "$ENTRIES" ]; then ENTRIES="$_line" 226 else ENTRIES="$ENTRIES 227 $_line"; fi 228 done 229 _rest="$_files" 230 while [ -n "$_rest" ]; do 231 _line="${_rest%% 232 *}"; _next="${_rest#* 233 }" 234 [ "$_next" = "$_rest" ] && _next="" 235 _rest="$_next" 236 [ -z "$_line" ] && continue 237 if [ -z "$ENTRIES" ]; then ENTRIES="$_line" 238 else ENTRIES="$ENTRIES 239 $_line"; fi 240 done 241 # recount 242 COUNT=0 243 _rest="$ENTRIES" 244 while [ -n "$_rest" ]; do 245 _line="${_rest%% 246 *}"; _next="${_rest#* 247 }" 248 [ "$_next" = "$_rest" ] && _next="" 249 _rest="$_next" 250 [ -n "$_line" ] && COUNT=$((COUNT + 1)) 251 done 252 fi 253 254 ALL_ENTRIES="$ENTRIES" 255 ALL_COUNT="$COUNT" 256 apply_filter 257 NEED_FULL_REDRAW=1 258 } 259 260 # filter ALL_ENTRIES by FILTER string, update ENTRIES/COUNT 261 apply_filter() { 262 if [ -z "$FILTER" ]; then 263 ENTRIES="$ALL_ENTRIES" 264 COUNT="$ALL_COUNT" 265 return 266 fi 267 ENTRIES="" 268 COUNT=0 269 printf '%s\n' "$ALL_ENTRIES" | while IFS= read -r line; do 270 case "$line" in 271 *"$FILTER"*) printf '%s\n' "$line" ;; 272 esac 273 done | { 274 while IFS= read -r line; do 275 ENTRIES="$ENTRIES 276 $line" 277 COUNT=$((COUNT + 1)) 278 done 279 # write back via temp — subshell can't modify parent vars directly 280 printf '%s\n' "$COUNT" > /tmp/_fm_count 281 printf '%s' "$ENTRIES" > /tmp/_fm_entries 282 } 283 COUNT=$(cat /tmp/_fm_count 2>/dev/null || echo 0) 284 ENTRIES=$(cat /tmp/_fm_entries 2>/dev/null || echo "") 285 ENTRIES="${ENTRIES# 286 }" 287 } 288 289 get_entry() { 290 printf '%s\n' "$ENTRIES" | awk -v n="$(($1 + 1))" 'NR==n{print; exit}' 291 } 292 293 # find index of entry by name (0-based), returns -1 if not found 294 find_entry() { 295 printf '%s\n' "$ENTRIES" | awk -v name="$1" ' 296 { if ($0 == name) { print NR-1; found=1; exit } } 297 END { if (!found) print -1 } 298 ' 299 } 300 301 # is entry name in SELECTED list? 302 is_selected() { 303 case " 304 ${SELECTED} 305 " in *" 306 $1 307 "*) return 0 ;; esac 308 return 1 309 } 310 311 count_selected() { 312 _cnt=0 313 _rest="$SELECTED" 314 while [ -n "$_rest" ]; do 315 _line="${_rest%% 316 *}" 317 _rest="${_rest#* 318 }" 319 [ "$_rest" = "$_line" ] && _rest="" # no more newlines 320 [ -n "$_line" ] && _cnt=$((_cnt + 1)) 321 done 322 printf '%d' "$_cnt" 323 } 324 325 326 toggle_selected() { 327 if is_selected "$1"; then 328 _new="" 329 _rest="$SELECTED" 330 while [ -n "$_rest" ]; do 331 _line="${_rest%% 332 *}" 333 _next="${_rest#* 334 }" 335 [ "$_next" = "$_rest" ] && _next="" 336 _rest="$_next" 337 [ "$_line" = "$1" ] && continue 338 [ -z "$_line" ] && continue 339 if [ -z "$_new" ]; then _new="$_line" 340 else _new="$_new 341 $_line" 342 fi 343 done 344 SELECTED="$_new" 345 else 346 if [ -z "$SELECTED" ]; then 347 SELECTED="$1" 348 else 349 SELECTED="$SELECTED 350 $1" 351 fi 352 fi 353 } 354 355 # render one entry row in-place (no newline, no cursor movement after) 356 # args: row idx is_selected cols 357 render_row() { 358 _row=$1; _idx=$2; _selected=$3; _cols=$4 359 entry=$(get_entry "$_idx") 360 361 case "$entry" in 362 */) colour="${GREEN}${BOLD}" ;; 363 *@) 364 _name="${entry%@}" 365 # broken symlink if target doesn't exist 366 if [ -e "${CWD}/${_name}" ]; then 367 colour="${MAGENTA}${BOLD}" 368 else 369 colour="${RED}${BOLD}" 370 fi ;; 371 *) colour=$(file_colour "$entry") ;; 372 esac 373 374 # multi-select marker 375 if is_selected "$entry"; then 376 _marker="${YELLOW}*${colour}" 377 else 378 _marker=" " 379 fi 380 381 # build display string 382 case "$entry" in 383 *@) 384 _name="${entry%@}" 385 _target=$(readlink "${CWD}/${_name}" 2>/dev/null || echo "?") 386 # mark broken symlinks clearly 387 if [ ! -e "${CWD}/${_name}" ]; then 388 display="${_name} -> ${_target} [broken]" 389 else 390 display="${_name} -> ${_target}" 391 fi 392 ;; 393 *) 394 display="${entry}" 395 ;; 396 esac 397 398 # build detail string (size + date) if enabled — fixed width for alignment 399 _detail="" 400 if [ "$SHOW_DETAILS" = "1" ]; then 401 _path="${CWD}/${entry%/}"; _path="${_path%@}" 402 _info=$(ls -ldh "$_path" 2>/dev/null) 403 _sz=$(printf '%s' "$_info" | awk '{print $5}') 404 _dt=$(printf '%s' "$_info" | awk '{print $6, $7}') 405 # fixed width: size=6, date=6 → total detail = 14 chars 406 _detail=$(printf ' %6s %-6s' "$_sz" "$_dt") 407 fi 408 409 # layout: marker(1) + name + spaces + detail 410 _dlen=${#_detail} 411 maxw=$((_cols - 1 - _dlen - 1)) 412 if [ "${#display}" -gt "$maxw" ]; then 413 display=$(printf '%s' "$display" | cut -c1-$((_cols - _dlen - 4))) 414 display="${display}..." 415 fi 416 _namew=$((${#display} + 1)) # 1 for marker 417 padlen=$((_cols - _namew - _dlen)) 418 [ "$padlen" -lt 0 ] && padlen=0 419 spaces=$(printf '%*s' "$padlen" '') 420 421 goto "$_row" 1 422 if [ "$_selected" = "1" ]; then 423 printf '%s%s%s%s%s%s%s' \ 424 "${REV}${colour}" "${_marker}" "${display}" \ 425 "${spaces}" \ 426 "${colour}${_detail}" \ 427 "${RESET}" "" 428 else 429 printf '%s%s%s%s%s%s%s' \ 430 "${colour}" "${_marker}" "${display}" \ 431 "${spaces}" \ 432 "${colour}${_detail}" \ 433 "${RESET}" "" 434 fi 435 } 436 437 draw_preview() { 438 _rows=$1; _cols=$2; _px=$3; _pw=$4 439 entry=$(get_entry "$SEL") 440 # clear preview area first 441 _r=2 442 while [ "$_r" -le $((_rows - 1)) ]; do 443 goto "$_r" "$_px" 444 printf '\033[K' 445 _r=$((_r + 1)) 446 done 447 [ -z "$entry" ] && return 448 _path=$(joinpath "${entry%/}"); _path="${_path%@}" 449 450 # draw vertical divider 451 _r=2 452 while [ "$_r" -le $((_rows - 1)) ]; do 453 goto "$_r" $((_px - 1)) 454 printf '%s|%s' "${CYAN}" "${RESET}" 455 _r=$((_r + 1)) 456 done 457 458 case "$entry" in 459 */) 460 # directory: list contents 461 _max_lines=$((_rows - 3)) 462 _pr=2 463 for _de in "$_path"/*/; do 464 [ "$_pr" -gt $((_rows - 1)) ] && break 465 [ -d "$_de" ] || continue 466 _dn="${_de%/}"; _dn="${_dn##*/}" 467 [ "$_dn" = "*" ] && continue 468 _dl=" ${_dn}/" 469 [ "${#_dl}" -gt "$((_pw - 1))" ] && _dl="$(printf '%s' "$_dl" | cut -c1-$((_pw-2)))~" 470 goto "$_pr" "$_px" 471 printf '%s%s%s' "${GREEN}${BOLD}" "$_dl" "${RESET}" 472 _pr=$((_pr + 1)) 473 done 474 for _fe in "$_path"/*; do 475 [ "$_pr" -gt $((_rows - 1)) ] && break 476 [ -e "$_fe" ] || continue 477 [ -d "$_fe" ] && continue 478 _fn="${_fe##*/}" 479 [ "$_fn" = "*" ] && continue 480 _fl=" ${_fn}" 481 [ "${#_fl}" -gt "$((_pw - 1))" ] && _fl="$(printf '%s' "$_fl" | cut -c1-$((_pw-2)))~" 482 goto "$_pr" "$_px" 483 printf '%s%s%s' "${WHITE}" "$_fl" "${RESET}" 484 _pr=$((_pr + 1)) 485 done 486 if [ "$_pr" -eq 2 ]; then 487 goto 2 "$_px" 488 printf '%s(empty)%s' "${WHITE}" "${RESET}" 489 fi 490 ;; 491 *@) 492 # symlink 493 _tgt=$(readlink "$_path" 2>/dev/null || echo "?") 494 goto 2 "$_px" 495 printf '%s[symlink]%s' "${MAGENTA}${BOLD}" "${RESET}" 496 goto 3 "$_px" 497 printf '%s-> %s%s' "${WHITE}" "${_tgt}" "${RESET}" 498 ;; 499 *) 500 # detect if text via extension or mime 501 _ext="${entry##*.}" 502 _ext=$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]') 503 _is_text=0 504 case "$_ext" in 505 txt|md|markdown|rst|log|conf|cfg|ini|toml|yaml|yml|\ 506 sh|bash|zsh|py|rb|pl|lua|js|ts|json|xml|html|htm|\ 507 css|c|h|cpp|rs|go|java|php|r|sql|vim|env|gitignore|\ 508 diff|patch|csv|tsv|lock|mod|sum) _is_text=1 ;; 509 esac 510 if [ "$_is_text" = "0" ] && command -v file >/dev/null 2>&1; then 511 case "$(file --mime-type -b "$_path" 2>/dev/null)" in 512 text/*) _is_text=1 ;; 513 esac 514 fi 515 if [ "$_is_text" = "1" ]; then 516 _max_lines=$((_rows - 3)) 517 _line_n=0 518 while IFS= read -r _line && [ "$_line_n" -lt "$_max_lines" ]; do 519 # truncate to preview width 520 if [ "${#_line}" -gt "$((_pw - 1))" ]; then 521 _line=$(printf '%s' "$_line" | cut -c1-$((_pw - 2))) 522 _line="${_line}~" 523 fi 524 goto "$((_line_n + 2))" "$_px" 525 printf '%s%s%s' "${WHITE}" "$_line" "${RESET}" 526 _line_n=$((_line_n + 1)) 527 done < "$_path" 528 if [ "$_line_n" -eq 0 ]; then 529 goto 2 "$_px" 530 printf '%s(empty file)%s' "${WHITE}" "${RESET}" 531 fi 532 else 533 # binary / unknown 534 goto 2 "$_px" 535 _sz=$(ls -lh "$_path" 2>/dev/null | awk '{print $5}') 536 printf '%s[binary] %s%s' "${YELLOW}" "${_sz}" "${RESET}" 537 fi 538 ;; 539 esac 540 } 541 542 draw_topbar() { 543 _cols=$1 544 spaces=$(printf '%*s' "$_cols" '') 545 goto 1 1 546 printf '%s%s%s' "${CYAN}${BOLD}" "${spaces}" "${RESET}" 547 } 548 549 draw_botbar() { 550 _rows=$1; _cols=$2 551 if [ "$SEARCHING" = "1" ]; then 552 info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}" 553 keys=" search: ${FILTER} " 554 maxk=$((_cols - ${#info} - 1)) 555 [ "${#keys}" -gt "$maxk" ] && keys=$(printf '%s' "$keys" | cut -c1-"$maxk") 556 padlen=$((_cols - ${#info} - ${#keys})) 557 [ "$padlen" -lt 0 ] && padlen=0 558 pad=$(printf '%*s' "$padlen" '') 559 goto "$_rows" 1 560 printf '%s%s%s%s%s%s' \ 561 "${BOLD}${CYAN}" "${info}" \ 562 "${RESET}${pad}" \ 563 "${YELLOW}${keys}" \ 564 "${RESET}" 565 elif [ -n "$INFO_MSG" ]; then 566 info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}" 567 msg=" ${INFO_MSG} " padlen=$((_cols - ${#info} - ${#msg})) 568 [ "$padlen" -lt 0 ] && padlen=0 569 pad=$(printf '%*s' "$padlen" '') 570 goto "$_rows" 1 571 printf '%s%s%s%s%s%s' \ 572 "${BOLD}${CYAN}" "${info}" \ 573 "${RESET}${pad}" \ 574 "${YELLOW}${msg}" \ 575 "${RESET}" 576 INFO_MSG="" 577 else 578 _ind="" 579 [ -n "$CLIPBOARD" ] && _ind=" [${CLIP_MODE}]" 580 _sc=$(count_selected) 581 [ "$_sc" -gt 0 ] && _ind="${_ind} [sel:${_sc}]" 582 [ "$SHOW_HIDDEN" = "1" ] && _ind="${_ind} [hidden]" 583 [ "$SORT_MODE" != "name" ] && _ind="${_ind} [sort:${SORT_MODE}]" 584 [ "$SHOW_DETAILS" = "1" ] && _ind="${_ind} [details]" 585 hint=" press ? for help " 586 info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}${_ind}" 587 padlen=$((_cols - ${#info} - ${#hint})) 588 [ "$padlen" -lt 0 ] && padlen=0 589 pad=$(printf '%*s' "$padlen" '') 590 goto "$_rows" 1 591 printf '%s%s%s%s%s%s' \ 592 "${BOLD}${CYAN}" "${info}" \ 593 "${RESET}${pad}" \ 594 "${YELLOW}${hint}" \ 595 "${RESET}" 596 fi 597 } 598 599 draw() { 600 ROWS=$(term_rows) 601 COLS=$(term_cols) 602 LIST_ROWS=$((ROWS - 2)) # row 1 = topbar, row ROWS = botbar 603 604 # split layout when preview enabled 605 if [ "$SHOW_PREVIEW" = "1" ]; then 606 LIST_COLS=$((COLS / 2)) 607 PREV_COL=$((LIST_COLS + 2)) 608 PREV_WIDTH=$((COLS - LIST_COLS - 2)) 609 else 610 LIST_COLS=$COLS 611 fi 612 613 # clamp selection 614 [ "$SEL" -lt 0 ] && SEL=0 615 [ "$COUNT" -gt 0 ] && [ "$SEL" -ge "$COUNT" ] && SEL=$((COUNT - 1)) 616 617 # update scroll offset 618 if [ "$SEL" -lt "$OFFSET" ]; then 619 OFFSET=$SEL 620 elif [ "$SEL" -ge $((OFFSET + LIST_ROWS)) ]; then 621 OFFSET=$((SEL - LIST_ROWS + 1)) 622 fi 623 624 # viewport shifted → must redraw all rows 625 [ "$OFFSET" != "$PREV_OFFSET" ] && NEED_FULL_REDRAW=1 626 627 if [ "$NEED_FULL_REDRAW" = "1" ]; then 628 printf '\033[H' 629 draw_topbar "$COLS" 630 631 row=2 632 idx=$OFFSET 633 while [ "$row" -le $((LIST_ROWS + 1)) ] && [ "$idx" -lt "$COUNT" ]; do 634 sel=0; [ "$idx" -eq "$SEL" ] && sel=1 635 render_row "$row" "$idx" "$sel" "$LIST_COLS" 636 row=$((row + 1)) 637 idx=$((idx + 1)) 638 done 639 # show (empty) if directory has no entries 640 if [ "$COUNT" -eq 0 ]; then 641 goto 2 1 642 printf '%s (empty)%s%s' "${WHITE}" "${ERASE_LINE}" "${RESET}" 643 row=3 644 fi 645 # blank leftover rows 646 while [ "$row" -le $((LIST_ROWS + 1)) ]; do 647 goto "$row" 1 648 printf '%s' "${ERASE_LINE}" 649 row=$((row + 1)) 650 done 651 652 [ "$SHOW_PREVIEW" = "1" ] && draw_preview "$ROWS" "$COLS" "$PREV_COL" "$PREV_WIDTH" 653 654 draw_botbar "$ROWS" "$COLS" 655 NEED_FULL_REDRAW=0 656 else 657 # --- fast path --- 658 if [ "$SEL" = "$PREV_SEL" ] && [ "$OFFSET" = "$PREV_OFFSET" ]; then 659 return 2>/dev/null || true 660 fi 661 prev_row=$(( (PREV_SEL - OFFSET) + 2 )) 662 new_row=$(( (SEL - OFFSET) + 2 )) 663 664 if [ "$prev_row" -ge 2 ] && [ "$prev_row" -le $((LIST_ROWS + 1)) ]; then 665 render_row "$prev_row" "$PREV_SEL" 0 "$LIST_COLS" 666 fi 667 if [ "$new_row" -ge 2 ] && [ "$new_row" -le $((LIST_ROWS + 1)) ]; then 668 render_row "$new_row" "$SEL" 1 "$LIST_COLS" 669 fi 670 671 [ "$SHOW_PREVIEW" = "1" ] && draw_preview "$ROWS" "$COLS" "$PREV_COL" "$PREV_WIDTH" 672 673 draw_botbar "$ROWS" "$COLS" 674 fi 675 676 PREV_SEL=$SEL 677 PREV_OFFSET=$OFFSET 678 } 679 680 # --- input --- 681 read_key() { 682 IFS= read -r -n1 key 2>/dev/null || IFS= read -r key 683 if [ "$key" = "$(printf '\033')" ]; then 684 # peek at next char with a very short timeout 685 IFS= read -r -n1 -t 0.1 _k2 2>/dev/null || _k2="" 686 if [ -z "$_k2" ]; then 687 # nothing followed — bare esc 688 printf '\033'; return 689 fi 690 # got a second char — read one more for full sequence (e.g. [A [B [C [D) 691 IFS= read -r -n1 -t 0.1 _k3 2>/dev/null || _k3="" 692 key="${key}${_k2}${_k3}" 693 fi 694 printf '%s' "$key" 695 } 696 697 setup_term() { old_stty=$(stty -g); stty -echo -icanon min 1 time 0; } 698 restore_term() { stty "$old_stty"; } 699 700 # --- actions --- 701 # --- smart file opener --- 702 # Detects file type by extension then mime, picks the right program. 703 # Terminal programs run in the foreground; GUI programs are backgrounded. 704 _run_tty() { "$@"; } 705 _run_gui() { "$@" >/dev/null 2>&1 & } 706 707 open_file() { 708 _f="$1" 709 710 # use user opener script if it exists and is executable 711 _opener="${XDG_CONFIG_HOME:-$HOME/.config}/sfm/opener" 712 if [ -x "$_opener" ]; then 713 "$_opener" "$_f" 714 return 715 fi 716 717 _ext="${_f##*.}" 718 _ext=$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]') 719 720 # --- by extension --- 721 case "$_ext" in 722 # text / code → editor 723 txt|md|markdown|rst|csv|tsv|log|conf|cfg|ini|toml|yaml|yml|\ 724 sh|bash|zsh|fish|py|rb|pl|lua|js|ts|jsx|tsx|json|xml|html|\ 725 htm|css|scss|sass|c|h|cpp|cc|cxx|hpp|rs|go|java|kt|swift|\ 726 cs|php|r|sql|vim|diff|patch|makefile|dockerfile|gitignore|\ 727 env|lock|mod|sum) 728 _run_tty ${EDITOR:-vi} "$_f" 729 return ;; 730 # images → GUI viewer 731 jpg|jpeg|png|gif|bmp|tiff|tif|webp|svg|ico|heic|heif|avif) 732 for _v in imv imvr feh sxiv nsxiv eog eom viewnior shotwell gimp; do 733 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } 734 done ;; 735 # video → GUI player 736 mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpeg|mpg|3gp|ogv) 737 for _v in mpv vlc mplayer totem celluloid haruna; do 738 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } 739 done ;; 740 # audio → player (mpv/vlc run fine headless too) 741 mp3|flac|ogg|wav|aac|m4a|opus|wma|aiff) 742 for _v in mpv vlc mplayer cmus mocp; do 743 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } 744 done ;; 745 # PDF / documents 746 pdf) 747 for _v in zathura evince okular mupdf atril xreader; do 748 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } 749 done ;; 750 # office docs 751 odt|ods|odp|doc|docx|xls|xlsx|ppt|pptx) 752 for _v in libreoffice soffice; do 753 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } 754 done ;; 755 # archives → list contents in pager 756 zip|tar|gz|bz2|xz|zst|7z|rar) 757 if command -v atool >/dev/null 2>&1; then 758 atool -l "$_f" 2>&1 | ${PAGER:-less}; return 759 elif command -v bsdtar >/dev/null 2>&1; then 760 bsdtar -tf "$_f" 2>&1 | ${PAGER:-less}; return 761 fi ;; 762 esac 763 764 # --- fallback: try mime type via `file` --- 765 if command -v file >/dev/null 2>&1; then 766 _mime=$(file --mime-type -b "$_f" 2>/dev/null) 767 case "$_mime" in 768 text/*|application/json|application/xml|application/javascript) 769 _run_tty ${EDITOR:-vi} "$_f"; return ;; 770 image/*) 771 for _v in imv feh sxiv nsxiv eog gimp; do 772 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } 773 done ;; 774 video/*) 775 for _v in mpv vlc mplayer; do 776 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } 777 done ;; 778 audio/*) 779 for _v in mpv vlc mplayer; do 780 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } 781 done ;; 782 application/pdf) 783 for _v in zathura evince okular mupdf; do 784 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } 785 done ;; 786 esac 787 fi 788 789 # --- last resort: xdg-open / open, else editor --- 790 if command -v xdg-open >/dev/null 2>&1; then 791 _run_gui xdg-open "$_f" 792 elif command -v open >/dev/null 2>&1; then 793 _run_gui open "$_f" 794 else 795 _run_tty ${EDITOR:-vi} "$_f" 796 fi 797 } 798 799 # read a line of input in raw mode, returns result in READ_LINE 800 # returns 1 if user pressed Esc (cancel), 0 on Enter 801 read_line() { 802 READ_LINE="" 803 show_cursor 804 while true; do 805 IFS= read -r -n1 _ch 2>/dev/null || IFS= read -r _ch 806 case "$_ch" in 807 "$(printf '\033')") 808 # read with timeout to distinguish bare esc from sequences 809 IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq="" 810 hide_cursor 811 READ_LINE=""; return 1 ;; 812 "$(printf '\n')"|\ 813 "$(printf '\r')") 814 hide_cursor 815 return 0 ;; 816 "$(printf '\177')"|\ 817 "$(printf '\010')") 818 if [ -n "$READ_LINE" ]; then 819 READ_LINE="${READ_LINE%?}" 820 printf '\b \b' 821 fi ;; 822 *) 823 READ_LINE="${READ_LINE}${_ch}" 824 printf '%s' "$_ch" ;; 825 esac 826 done 827 } 828 829 do_open_with() { 830 entry=$(get_entry "$SEL") 831 [ -z "$entry" ] && return 832 case "$entry" in 833 */) INFO_MSG="cannot open-with a directory"; NEED_FULL_REDRAW=1; return ;; 834 esac 835 _target="${CWD}/${entry%@}" 836 goto "$(term_rows)" 1 837 printf '%s%s Open "%s" with (esc=cancel): %s' \ 838 "${ERASE_LINE}" "${CYAN}${BOLD}" "$entry" "${RESET}" 839 if read_line && [ -n "$READ_LINE" ]; then 840 # check the program exists 841 if ! command -v "$READ_LINE" >/dev/null 2>&1; then 842 INFO_MSG="not found: ${READ_LINE}" 843 NEED_FULL_REDRAW=1 844 return 845 fi 846 restore_term; show_cursor 847 printf '\033[2J\033[H' 848 "$READ_LINE" "$_target" 849 setup_term; hide_cursor 850 NEED_FULL_REDRAW=1 851 else 852 NEED_FULL_REDRAW=1; draw 853 fi 854 } 855 856 do_newfile() { 857 goto "$(term_rows)" 1 858 printf '%s%s New file name (esc=cancel): %s' "${ERASE_LINE}" "${WHITE}${BOLD}" "${RESET}" 859 if read_line && [ -n "$READ_LINE" ]; then 860 touch "${CWD}/${READ_LINE}" 861 load_entries 862 else 863 NEED_FULL_REDRAW=1; draw 864 fi 865 } 866 867 # --- help overlay helpers (must be top-level for dash compatibility) --- 868 _help_bx=0; _help_by=0; _help_iw=0; _help_hl="" 869 870 help_row() { 871 _r=$1; _text=$2 872 goto "$(( _help_by + _r ))" "$_help_bx" 873 if [ "${#_text}" -gt "$_help_iw" ]; then 874 _text=$(printf '%s' "$_text" | cut -c1-"$_help_iw") 875 fi 876 _pl=$(( _help_iw - ${#_text} )) 877 _p=$(printf '%*s' "$_pl" '') 878 printf '%s|%s%s%s%s|%s' \ 879 "${BOLD}${CYAN}" "${RESET}" "${_text}" "${_p}" \ 880 "${BOLD}${CYAN}" "${RESET}" 881 } 882 883 help_sep() { 884 goto "$(( _help_by + $1 ))" "$_help_bx" 885 printf '%s|%s|%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}" 886 } 887 888 do_help() { 889 ROWS=$(term_rows) 890 COLS=$(term_cols) 891 bw=$((COLS - 4)) 892 [ "$bw" -gt 52 ] && bw=52 893 [ "$bw" -lt 36 ] && bw=36 894 bh=42 895 _help_bx=$(( (COLS - bw) / 2 )); [ "$_help_bx" -lt 1 ] && _help_bx=1 896 _help_by=$(( (ROWS - bh) / 2 )); [ "$_help_by" -lt 1 ] && _help_by=1 897 _help_iw=$((bw - 2)) 898 _help_hl=$(printf '%*s' "$_help_iw" '' | tr ' ' '-') 899 900 goto "$_help_by" "$_help_bx" 901 printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}" 902 903 help_row 1 "" 904 help_row 2 " KEYBOARD SHORTCUTS" 905 help_sep 3 906 help_row 4 " j/k up/down g/G top/bottom" 907 help_row 5 " h/left go back" 908 help_row 6 " l/right/enter open / enter dir" 909 help_sep 7 910 help_row 8 " / search filter" 911 help_row 9 " esc clear filter" 912 help_row 10 " . toggle hidden files" 913 help_row 11 " i file info in status bar" 914 help_row 12 " s cycle sort: name/size/date" 915 help_row 13 " T toggle size/date details" 916 help_row 14 " P toggle preview pane" 917 help_sep 15 918 help_row 16 " space toggle multi-select" 919 help_row 17 " a select all / deselect all" 920 help_row 18 " y yank/copy (works on selection)" 921 help_row 19 " x cut (works on selection)" 922 help_row 20 " p paste" 923 help_row 21 " d delete (works on selection)" 924 help_sep 22 925 help_row 23 " r rename R refresh" 926 help_row 24 " m make directory" 927 help_row 25 " n new file" 928 help_row 26 " u trash file (safe delete)" 929 help_row 27 " U open trash directory" 930 help_row 28 " ! drop to shell in CWD" 931 help_row 29 " o open with custom program" 932 help_sep 30 933 help_row 31 " b bookmark current dir" 934 help_row 32 " B open bookmark picker" 935 help_row 33 " c copy path to clipboard" 936 help_row 34 " ~ go to home directory" 937 help_row 35 " \` jump to previous directory" 938 help_sep 36 939 help_row 37 " q quit" 940 help_row 38 " ? this help" 941 help_row 39 "" 942 help_row 40 " press any key to close..." 943 944 goto "$(( _help_by + bh ))" "$_help_bx" 945 printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}" 946 947 read_key > /dev/null 948 NEED_FULL_REDRAW=1 949 } 950 951 do_search() { 952 FILTER="" 953 SEARCHING=1 954 show_cursor 955 while true; do 956 apply_filter 957 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 958 NEED_FULL_REDRAW=1 959 draw 960 IFS= read -r -n1 _ch 2>/dev/null || IFS= read -r _ch 961 case "$_ch" in 962 "$(printf '\033')") 963 # read with short timeout to catch escape sequences 964 IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq="" 965 if [ -z "$_seq" ]; then 966 # bare esc — clear filter and exit search 967 FILTER=""; SEARCHING=0 968 apply_filter 969 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 970 hide_cursor; NEED_FULL_REDRAW=1; draw; return 971 fi 972 # else it was an arrow key or other sequence — ignore it 973 ;; 974 "$(printf '\n')"|\ 975 "$(printf '\r')") 976 SEARCHING=0; hide_cursor; NEED_FULL_REDRAW=1; return ;; 977 "$(printf '\177')"|\ 978 "$(printf '\010')") 979 [ -n "$FILTER" ] && FILTER="${FILTER%?}" ;; 980 *) 981 FILTER="${FILTER}${_ch}" ;; 982 esac 983 done 984 } 985 do_clear_filter() { 986 FILTER="" 987 SEARCHING=0 988 apply_filter 989 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 990 NEED_FULL_REDRAW=1 991 } 992 993 do_toggle_hidden() { 994 if [ "$SHOW_HIDDEN" = "0" ]; then 995 SHOW_HIDDEN=1 996 INFO_MSG="hidden files shown" 997 else 998 SHOW_HIDDEN=0 999 INFO_MSG="hidden files hidden" 1000 fi 1001 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 1002 load_entries 1003 } 1004 1005 do_toggle_details() { 1006 if [ "$SHOW_DETAILS" = "0" ]; then 1007 SHOW_DETAILS=1; INFO_MSG="details on" 1008 else 1009 SHOW_DETAILS=0; INFO_MSG="details off" 1010 fi 1011 NEED_FULL_REDRAW=1 1012 } 1013 1014 do_toggle_preview() { 1015 if [ "$SHOW_PREVIEW" = "0" ]; then 1016 SHOW_PREVIEW=1; INFO_MSG="preview on" 1017 else 1018 SHOW_PREVIEW=0; INFO_MSG="preview off" 1019 fi 1020 NEED_FULL_REDRAW=1 1021 } 1022 1023 do_go_back() { 1024 [ "$CWD" = "/" ] && return 1025 FILTER="" 1026 SELECTED="" 1027 PREV_CWD="$CWD" 1028 LAST_CHILD="${CWD##*/}/" # name of current dir with trailing slash 1029 CWD=$(cd "$(dirname "$CWD")" && pwd) 1030 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 1031 load_entries 1032 # restore selection to the directory we just came from 1033 if [ -n "$LAST_CHILD" ]; then 1034 idx=$(find_entry "$LAST_CHILD") 1035 [ "$idx" -ge 0 ] 2>/dev/null && SEL=$idx 1036 fi 1037 LAST_CHILD="" 1038 } 1039 1040 do_open() { 1041 entry=$(get_entry "$SEL") 1042 case "$entry" in 1043 */) 1044 FILTER="" 1045 SELECTED="" 1046 _target=$(joinpath "${entry%/}") 1047 if [ ! -x "$_target" ] || [ ! -r "$_target" ]; then 1048 INFO_MSG="permission denied: ${entry%/}" 1049 NEED_FULL_REDRAW=1 1050 return 1051 fi 1052 PREV_CWD="$CWD" 1053 CWD=$(cd "$_target" && pwd); norm_cwd 1054 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 1055 load_entries 1056 ;; 1057 *@) 1058 _name="${entry%@}" 1059 _target=$(joinpath "$_name") 1060 if [ -d "$_target" ]; then 1061 if [ ! -x "$_target" ] || [ ! -r "$_target" ]; then 1062 INFO_MSG="permission denied: ${_name}" 1063 NEED_FULL_REDRAW=1 1064 return 1065 fi 1066 FILTER=""; SELECTED="" 1067 PREV_CWD="$CWD" 1068 CWD=$(cd "$_target" && pwd); norm_cwd 1069 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 1070 load_entries 1071 else 1072 if [ ! -r "$_target" ]; then 1073 INFO_MSG="permission denied: ${_name}" 1074 NEED_FULL_REDRAW=1 1075 return 1076 fi 1077 restore_term; show_cursor 1078 printf '\033[2J\033[H' 1079 open_file "$_target" 1080 setup_term; hide_cursor 1081 NEED_FULL_REDRAW=1 1082 fi 1083 ;; 1084 *) 1085 _target="${CWD}/${entry}" 1086 if [ ! -r "$_target" ]; then 1087 INFO_MSG="permission denied: ${entry}" 1088 NEED_FULL_REDRAW=1 1089 return 1090 fi 1091 restore_term; show_cursor 1092 printf '\033[2J\033[H' 1093 open_file "$_target" 1094 setup_term; hide_cursor 1095 NEED_FULL_REDRAW=1 1096 ;; 1097 esac 1098 } 1099 1100 do_delete() { 1101 INFO_MSG="" 1102 _c=$(count_selected) 1103 if [ "$_c" -gt 0 ]; then 1104 # --- bulk delete selected --- 1105 restore_term; show_cursor 1106 goto "$(term_rows)" 1 1107 printf '%s%s Delete %d selected items? [y/N] %s' \ 1108 "${ERASE_LINE}" "${RED}${BOLD}" "$_c" "${RESET}" 1109 IFS= read -r ans 1110 setup_term; hide_cursor 1111 case "$ans" in 1112 y|Y) 1113 _rest="$SELECTED" 1114 while [ -n "$_rest" ]; do 1115 _line="${_rest%% 1116 *}" 1117 _next="${_rest#* 1118 }" 1119 [ "$_next" = "$_rest" ] && _next="" 1120 _rest="$_next" 1121 [ -z "$_line" ] && continue 1122 _t="${CWD}/${_line%/}" 1123 if [ -d "$_t" ]; then rm -rf "$_t"; else rm -f "$_t"; fi 1124 done 1125 SELECTED="" 1126 [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) 1127 [ "$SEL" -lt 0 ] && SEL=0 1128 load_entries 1129 ;; 1130 *) NEED_FULL_REDRAW=1 ;; 1131 esac 1132 else 1133 # --- single delete --- 1134 entry=$(get_entry "$SEL") 1135 target="${CWD}/${entry%/}" 1136 restore_term; show_cursor 1137 goto "$(term_rows)" 1 1138 printf '%s%s Delete "%s"? [y/N] %s' "${ERASE_LINE}" "${RED}${BOLD}" "$entry" "${RESET}" 1139 IFS= read -r ans 1140 setup_term; hide_cursor 1141 case "$ans" in 1142 y|Y) 1143 if [ -d "$target" ]; then 1144 if [ -n "$(ls -A "$target" 2>/dev/null)" ]; then 1145 restore_term; show_cursor 1146 goto "$(term_rows)" 1 1147 printf '%s%s "%s" not empty. Delete ALL? [y/N] %s' \ 1148 "${ERASE_LINE}" "${RED}${BOLD}" "$entry" "${RESET}" 1149 IFS= read -r ans2 1150 setup_term; hide_cursor 1151 case "$ans2" in 1152 y|Y) rm -rf "$target" ;; 1153 *) NEED_FULL_REDRAW=1; return ;; 1154 esac 1155 else 1156 rm -rf "$target" 1157 fi 1158 else 1159 rm -f "$target" 1160 fi 1161 [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) 1162 [ "$SEL" -lt 0 ] && SEL=0 1163 load_entries 1164 ;; 1165 *) NEED_FULL_REDRAW=1 ;; 1166 esac 1167 fi 1168 } 1169 1170 do_rename() { 1171 entry=$(get_entry "$SEL") 1172 goto "$(term_rows)" 1 1173 printf '%s%s Rename "%s" to (esc=cancel): %s' "${ERASE_LINE}" "${YELLOW}${BOLD}" "$entry" "${RESET}" 1174 if read_line && [ -n "$READ_LINE" ] && [ "$READ_LINE" != "$entry" ]; then 1175 mv "${CWD}/${entry%/}" "${CWD}/${READ_LINE}" 1176 load_entries 1177 else 1178 NEED_FULL_REDRAW=1; draw 1179 fi 1180 } 1181 1182 do_mkdir() { 1183 goto "$(term_rows)" 1 1184 printf '%s%s New dir name (esc=cancel): %s' "${ERASE_LINE}" "${CYAN}${BOLD}" "${RESET}" 1185 if read_line && [ -n "$READ_LINE" ]; then 1186 mkdir -p "${CWD}/${READ_LINE}" 1187 load_entries 1188 else 1189 NEED_FULL_REDRAW=1; draw 1190 fi 1191 } 1192 1193 do_info() { 1194 entry=$(get_entry "$SEL") 1195 [ -z "$entry" ] && return 1196 target="${CWD}/${entry%/}" 1197 # permissions + type 1198 _perm=$(ls -ld "$target" 2>/dev/null | awk '{print $1}') 1199 # size (human readable via du, fallback to ls) 1200 _size=$(du -sh "$target" 2>/dev/null | cut -f1) 1201 [ -z "$_size" ] && _size=$(ls -lh "$target" 2>/dev/null | awk '{print $5}') 1202 # modification date 1203 _date=$(ls -ld "$target" 2>/dev/null | awk '{print $6, $7, $8}') 1204 INFO_MSG="${_perm} ${_size} ${_date}" 1205 NEED_FULL_REDRAW=1 1206 } 1207 1208 do_shell() { 1209 restore_term; show_cursor 1210 printf '\033[2J\033[H' 1211 cd "$CWD" || true 1212 printf '%s(type "exit" to return to fm)%s\n' "${YELLOW}" "${RESET}" 1213 ${SHELL:-sh} 1214 # restore CWD in case user cd'd around 1215 CWD="${PWD}"; norm_cwd 1216 setup_term; hide_cursor 1217 NEED_FULL_REDRAW=1 1218 } 1219 1220 do_sort() { 1221 case "$SORT_MODE" in 1222 name) SORT_MODE="size" ;; 1223 size) SORT_MODE="date" ;; 1224 date) SORT_MODE="name" ;; 1225 esac 1226 INFO_MSG="sort: ${SORT_MODE}" 1227 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 1228 load_entries 1229 } 1230 1231 do_trash() { 1232 entry=$(get_entry "$SEL") 1233 [ -z "$entry" ] && return 1234 target="${CWD}/${entry%/}" 1235 _ts=$(date '+%Y%m%d_%H%M%S' 2>/dev/null || date '+%s') 1236 _dest="${TRASH_DIR}/${_ts}_${entry%/}" 1237 if mv "$target" "$_dest"; then 1238 INFO_MSG="trashed: ${entry}" 1239 [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) 1240 [ "$SEL" -lt 0 ] && SEL=0 1241 load_entries 1242 else 1243 INFO_MSG="trash failed" 1244 NEED_FULL_REDRAW=1 1245 fi 1246 } 1247 1248 do_open_trash() { 1249 PREV_CWD="$CWD" 1250 FILTER=""; SELECTED="" 1251 CWD="$TRASH_DIR"; norm_cwd 1252 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 1253 load_entries 1254 } 1255 1256 do_copy_path() { 1257 entry=$(get_entry "$SEL") 1258 [ -z "$entry" ] && return 1259 _path="${CWD}/${entry%/}" 1260 if command -v wl-copy >/dev/null 2>&1; then 1261 printf '%s' "$_path" | wl-copy 1262 elif command -v xclip >/dev/null 2>&1; then 1263 printf '%s' "$_path" | xclip -selection clipboard 1264 elif command -v xsel >/dev/null 2>&1; then 1265 printf '%s' "$_path" | xsel --clipboard --input 1266 elif command -v pbcopy >/dev/null 2>&1; then 1267 printf '%s' "$_path" | pbcopy 1268 else 1269 INFO_MSG="no clipboard tool found" 1270 NEED_FULL_REDRAW=1 1271 return 1272 fi 1273 INFO_MSG="path copied: ${_path}" 1274 NEED_FULL_REDRAW=1 1275 } 1276 1277 do_jump_back() { 1278 [ -z "$PREV_CWD" ] && { INFO_MSG="no previous directory"; NEED_FULL_REDRAW=1; return; } 1279 _tmp="$CWD" 1280 CWD="$PREV_CWD"; norm_cwd 1281 PREV_CWD="$_tmp" 1282 FILTER=""; SELECTED="" 1283 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 1284 load_entries 1285 } 1286 1287 do_bookmark_add() { 1288 # toggle: if CWD already bookmarked, remove it; else add it 1289 if grep -qx "$CWD" "$BOOKMARK_FILE" 2>/dev/null; then 1290 # remove 1291 _tmp=$(grep -vx "$CWD" "$BOOKMARK_FILE") 1292 printf '%s\n' "$_tmp" > "$BOOKMARK_FILE" 1293 INFO_MSG="bookmark removed: ${CWD}" 1294 else 1295 printf '%s\n' "$CWD" >> "$BOOKMARK_FILE" 1296 INFO_MSG="bookmarked: ${CWD}" 1297 fi 1298 NEED_FULL_REDRAW=1 1299 } 1300 1301 do_bookmark_jump() { 1302 # count bookmarks 1303 _bc=0 1304 while IFS= read -r _l; do [ -n "$_l" ] && _bc=$((_bc+1)); done < "$BOOKMARK_FILE" 1305 [ "$_bc" -eq 0 ] && { INFO_MSG="no bookmarks saved"; NEED_FULL_REDRAW=1; return; } 1306 1307 # draw a small picker overlay 1308 ROWS=$(term_rows); COLS=$(term_cols) 1309 _pw=$(( COLS * 2 / 3 )); [ "$_pw" -gt 70 ] && _pw=70; [ "$_pw" -lt 30 ] && _pw=30 1310 _ph=$(( _bc + 4 )); [ "$_ph" -gt $((ROWS - 4)) ] && _ph=$((ROWS - 4)) 1311 _px=$(( (COLS - _pw) / 2 )); [ "$_px" -lt 1 ] && _px=1 1312 _py=$(( (ROWS - _ph) / 2 )); [ "$_py" -lt 1 ] && _py=1 1313 _iw=$((_pw - 2)) 1314 _hl=$(printf '%*s' "$_iw" '' | tr ' ' '-') 1315 1316 _bsel=1 1317 while true; do 1318 # draw picker inline 1319 goto "$_py" "$_px" 1320 printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" 1321 goto "$((_py+1))" "$_px" 1322 _t=" BOOKMARKS"; _pl=$((_iw - ${#_t})); _p=$(printf '%*s' "$_pl" '') 1323 printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_t" "$_p" "${BOLD}${CYAN}" "${RESET}" 1324 goto "$((_py+2))" "$_px" 1325 printf '%s|%s|%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" 1326 _bi=0 1327 while IFS= read -r _bm; do 1328 [ -z "$_bm" ] && continue 1329 _bi=$((_bi+1)) 1330 goto "$((_py+2+_bi))" "$_px" 1331 _bt="${_bi} ${_bm}" 1332 if [ "${#_bt}" -gt "$_iw" ]; then _bt=$(printf '%s' "$_bt" | cut -c1-$((_iw-1))); _bt="${_bt}~"; fi 1333 _bpl=$((_iw - ${#_bt})); _bp=$(printf '%*s' "$_bpl" '') 1334 if [ "$_bi" -eq "$_bsel" ]; then 1335 printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${REV}${YELLOW}" "$_bt" "$_bp" "${RESET}${BOLD}${CYAN}" "${RESET}" 1336 else 1337 printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_bt" "$_bp" "${BOLD}${CYAN}" "${RESET}" 1338 fi 1339 done < "$BOOKMARK_FILE" 1340 goto "$((_py+_ph))" "$_px" 1341 printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" 1342 IFS= read -r -n1 _bk 2>/dev/null || IFS= read -r _bk 1343 case "$_bk" in 1344 j) _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc ;; 1345 k) _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1 ;; 1346 "$(printf '\033')") 1347 # use timeout to distinguish bare esc from arrow sequences 1348 IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq="" 1349 case "$_seq" in 1350 '[A') _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1 ;; 1351 '[B') _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc ;; 1352 '[C') # right — open 1353 _chosen=$(awk -v n="$_bsel" 'NR==n&&NF{print;exit}' "$BOOKMARK_FILE") 1354 if [ -n "$_chosen" ] && [ -d "$_chosen" ]; then 1355 PREV_CWD="$CWD"; CWD="$_chosen"; norm_cwd 1356 FILTER=""; SELECTED="" 1357 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 1358 load_entries 1359 else 1360 INFO_MSG="not found: ${_chosen}"; NEED_FULL_REDRAW=1 1361 fi 1362 return ;; 1363 '[D') NEED_FULL_REDRAW=1; return ;; # left — close 1364 *) NEED_FULL_REDRAW=1; return ;; # bare esc — close 1365 esac ;; 1366 "$(printf '\n')"|\ 1367 "$(printf '\r')"|\ 1368 l) 1369 _chosen=$(awk -v n="$_bsel" 'NR==n&&NF{print;exit}' "$BOOKMARK_FILE") 1370 if [ -n "$_chosen" ] && [ -d "$_chosen" ]; then 1371 PREV_CWD="$CWD"; CWD="$_chosen"; norm_cwd 1372 FILTER=""; SELECTED="" 1373 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 1374 load_entries 1375 else 1376 INFO_MSG="not found: ${_chosen}"; NEED_FULL_REDRAW=1 1377 fi 1378 return ;; 1379 q|Q|h) NEED_FULL_REDRAW=1; return ;; 1380 esac 1381 done 1382 } 1383 1384 do_toggle_select() { 1385 entry=$(get_entry "$SEL") 1386 [ -z "$entry" ] && return 1387 toggle_selected "$entry" 1388 _c=$(count_selected) 1389 [ "$_c" -eq 0 ] && INFO_MSG="selection cleared" || INFO_MSG="${_c} selected" 1390 SEL=$((SEL + 1)) 1391 NEED_FULL_REDRAW=1 1392 } 1393 1394 do_select_all() { 1395 _c=$(count_selected) 1396 if [ "$_c" -gt 0 ]; then 1397 # deselect all 1398 SELECTED="" 1399 INFO_MSG="selection cleared" 1400 else 1401 # select all visible entries (skip ../) 1402 SELECTED="" 1403 _rest="$ENTRIES" 1404 while [ -n "$_rest" ]; do 1405 _line="${_rest%% 1406 *}"; _next="${_rest#* 1407 }" 1408 [ "$_next" = "$_rest" ] && _next="" 1409 _rest="$_next" 1410 [ -z "$_line" ] && continue 1411 [ "$_line" = "../" ] && continue 1412 if [ -z "$SELECTED" ]; then SELECTED="$_line" 1413 else SELECTED="$SELECTED 1414 $_line"; fi 1415 done 1416 _c=$(count_selected) 1417 INFO_MSG="selected all: ${_c} items" 1418 fi 1419 NEED_FULL_REDRAW=1 1420 } 1421 1422 do_yank() { 1423 _c=$(count_selected) 1424 if [ "$_c" -gt 0 ]; then 1425 CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do 1426 [ -z "$_e" ] && continue 1427 printf '%s\n' "${CWD}/${_e%/}" 1428 done) 1429 CLIP_MODE="copy" 1430 INFO_MSG="yanked ${_c} items" 1431 SELECTED="" 1432 else 1433 entry=$(get_entry "$SEL") 1434 [ -z "$entry" ] && return 1435 CLIPBOARD="${CWD}/${entry%/}" 1436 CLIP_MODE="copy" 1437 INFO_MSG="yanked: ${entry}" 1438 fi 1439 NEED_FULL_REDRAW=1 1440 } 1441 1442 do_cut() { 1443 _c=$(count_selected) 1444 if [ "$_c" -gt 0 ]; then 1445 CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do 1446 [ -z "$_e" ] && continue 1447 printf '%s\n' "${CWD}/${_e%/}" 1448 done) 1449 CLIP_MODE="cut" 1450 INFO_MSG="cut ${_c} items" 1451 SELECTED="" 1452 else 1453 entry=$(get_entry "$SEL") 1454 [ -z "$entry" ] && return 1455 CLIPBOARD="${CWD}/${entry%/}" 1456 CLIP_MODE="cut" 1457 INFO_MSG="cut: ${entry}" 1458 fi 1459 NEED_FULL_REDRAW=1 1460 } 1461 1462 do_paste() { 1463 if [ -z "$CLIPBOARD" ]; then 1464 INFO_MSG="nothing to paste" 1465 NEED_FULL_REDRAW=1 1466 return 1467 fi 1468 _ok=0; _fail=0 1469 printf '%s\n' "$CLIPBOARD" | while IFS= read -r _src; do 1470 [ -z "$_src" ] && continue 1471 _name="${_src##*/}" 1472 _dest="${CWD}/${_name}" 1473 if [ -e "$_dest" ]; then 1474 _base="${_name%.*}"; _ext="${_name##*.}" 1475 [ "$_ext" = "$_name" ] && _ext="" || _ext=".${_ext}" 1476 _dest="${CWD}/${_base}_copy${_ext}" 1477 fi 1478 case "$CLIP_MODE" in 1479 copy) cp -r "$_src" "$_dest" && _ok=$((_ok+1)) || _fail=$((_fail+1)) ;; 1480 cut) mv "$_src" "$_dest" && _ok=$((_ok+1)) || _fail=$((_fail+1)) ;; 1481 esac 1482 done 1483 CLIPBOARD=""; CLIP_MODE="" 1484 INFO_MSG="pasted" 1485 load_entries 1486 } 1487 1488 # --- main --- 1489 trap 'restore_term; show_cursor; printf "\033[2J\033[H"; exit 0' INT TERM EXIT 1490 1491 setup_term 1492 hide_cursor 1493 printf '\033[2J' # clear screen exactly once at startup 1494 load_entries 1495 1496 while true; do 1497 draw 1498 key=$(read_key) 1499 1500 case "$key" in 1501 j|"$(printf '\033[B')") 1502 [ "$SEL" -lt $((COUNT - 1)) ] && SEL=$((SEL + 1)) ;; 1503 k|"$(printf '\033[A')") 1504 [ "$SEL" -gt 0 ] && SEL=$((SEL - 1)) ;; 1505 g) SEL=0 ;; 1506 G) SEL=$((COUNT - 1)) ;; 1507 "$(printf '\033[6~')") SEL=$((SEL + $(term_rows) / 2)) ;; 1508 "$(printf '\033[5~')") SEL=$((SEL - $(term_rows) / 2)) ;; 1509 "$(printf '\n')"|\ 1510 "$(printf '\r')"|\ 1511 "$(printf '\033[C')"|\ 1512 l) do_open ;; 1513 "$(printf '\033[D')"|\ 1514 h) do_go_back ;; 1515 b) do_bookmark_add ;; 1516 B) do_bookmark_jump ;; 1517 '?') do_help ;; 1518 R) load_entries; INFO_MSG="refreshed" ;; 1519 /) do_search ;; 1520 '.') do_toggle_hidden ;; 1521 T) do_toggle_details ;; 1522 P) do_toggle_preview ;; 1523 i) do_info ;; 1524 '!') do_shell ;; 1525 o) do_open_with ;; 1526 s) do_sort ;; 1527 u) do_trash ;; 1528 U) do_open_trash ;; 1529 '`') do_jump_back ;; 1530 '~') PREV_CWD="$CWD"; CWD=$(cd "$HOME" && pwd); FILTER=""; SELECTED="" 1531 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0; load_entries ;; 1532 c) do_copy_path ;; 1533 "$(printf '\033')") do_clear_filter ;; 1534 ' ') do_toggle_select ;; 1535 a) do_select_all ;; 1536 y) do_yank ;; 1537 x) do_cut ;; 1538 p) do_paste ;; 1539 d) do_delete ;; 1540 r) do_rename ;; 1541 m) do_mkdir ;; 1542 n) do_newfile ;; 1543 q|Q) break ;; 1544 esac 1545 done 1546 1547 restore_term 1548 show_cursor 1549 printf '\033[2J\033[H' 1550 printf '%s\n' "$CWD"