-
Notifications
You must be signed in to change notification settings - Fork 0
/
fp
executable file
·671 lines (602 loc) · 22.1 KB
/
fp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
#!/bin/env bash
# Abort on unbound variable, also known as "set -o nounset".
set -u
# Default settings. These are active until the corresponding commandline
# options overwrites them. Lookup in the below show_help section to see their
# purpose and which option belongs to what variable.
#
# 'opt_menucmd' Must be a program reading stdin as list and output to stdout.
# Following construct with 'read' makes it easy to break the command into
# multiple lines.
IFS='' read -r -d '' opt_menucmd <<"EOF"
fzf --reverse --multi --cycle --scheme=path
--bind change:first
--bind esc:cancel+clear-selection
--height=~80%
--border=none
--prompt=$_
--marker=*
--no-separator
EOF
# Empty 'opt_changedir' defaults to current working dir.
opt_changedir=''
# Below default settings shouldn't be touched by the end user.
# Empty 'opt_maxdepth' is automatic detection.
opt_maxdepth=''
opt_type=''
opt_filter=''
opt_extended=''
opt_grep=''
opt_ignorecase=false
opt_nomenu=false
opt_stdin=false
opt_all=false
opt_symlinks=false
opt_xdev=false
opt_kinpath=false
opt_name=false
opt_preview=false
opt_run=false
opt_background=false
# Empty 'opt_output' defaults to '/dev/null' if run options are enabled.
opt_output=''
show_version () {
cat << EOF
findpick v0.6
EOF
}
error () {
declare -A error_types
error_types=( ['Error']=1
['ValueError']=2
['OSError']=3
['AbortError']=4 )
local type="${1}"
local code="${error_types["${type}"]}"
if [[ "${#}" -gt 1 ]]
then
shift
printf '%s: %s\n' "${type}" "${*}" >&2
fi
exit "${code}"
}
show_help_notes () {
local wrap_menucmd
wrap_menucmd="$(printf '%s' "${opt_menucmd}" \
| fold -sw 80 \
| sed -e 's/^/ /')"
cat << EOF
[1] -p will add several "-preview" related options to the -m command. This is
a feature of "fzf". Don't use this option when -m is set to any other program.
[2] -r -b -o are related. -r runs selection as a command if it's an
executable, otherwise opens file with xdg-open; and waits to finish. -b runs
too, but instead as a background process detached from terminal and does not
wait. -o will write output of process into specifid file in realtime. If
multiple processes run, then each output is written at once with a delay after
process finishes.
[3] -m can be any shell command or program with arguments. It should read
newline separated list from stdin and output selected file to stdout. An empty
menu command as '-m ""' or option -M as a shortcut will just output everything
without user interaction. Current default command:
${wrap_menucmd}
[4] -g search and limit results to files, whose content matches the pattern.
Pattern is an 'extended-regexp' regular expression for standalone grep command
(also known as "grep -E"). As a side-effect all directories and binary files
are excluded; only text files are processed and listed. Symbolic links are not
resolved for this particular search, even if option -l is in effect.
Case-sensitivity is affected by and can be turned off with option -i.
[5] -d will default to '1' for listing current working directory or starting
point. If anything is given at FILES, then this will default to '0' if not
explicitly set. This option controls how many levels deep of subfolders 'find'
should traverse and list files from.
[6] -t to list files with matching types only. List can be any combination of
supported flags: b=block, c=character special, d=directory, p=named pipe,
f=regular file, l=symbolic link, s=socket, x=executable (directories are also
executable), t=text file (uses grep to determine format). Comma for separation
is optional, such as "-t fx" is equivalent to "-t f,x".
[7] -e a "posix-extended" regular expression to filter out files similar to
-f. But regex matches whole known path body, including it's folder parts with
slashes too. Known path depends on what was given as input. If path consists
of "./file", then regex cannot match root "/", but it would at "/bin/grep".
EOF
}
show_usage () {
local pspac
local pname
pname="${0##*/}"
pspac="$(printf '%s' "${pname}" | sed 's/./ /g')"
cat << EOF
usage:
${pname} [OPTIONS] [FILES...]
${pname} [-h | -H | -V]
${pspac} [-s] [-a] [-l] [-x] [-k | -n] [-p] [-i] [-r] [-b] [-M]
${pspac} [-o FILE] [-m CMD] [-g PATT] [-d NUM] [-t TYPE] [-f PATT] [-e PATT]
${pspac} [-c DIR]
${pspac} [--] [FILES...]
EOF
}
show_help () {
local pname
pname="${0##*/}"
usage=$(show_usage)
cat << EOF
${usage}
General purpose file picker combining "find" command with a fuzzy finder.
positional arguments:
FILES path to list files and folders
options:
-h help: print this help and exit
-H notes: print this help, additional notes and exit
-V version: print name, version and exit
-s stdin: read each line from stdin stream as a FILES path
-a all: do not hide dotfiles starting with "." in basename
-l symlinks: resolve symlinks, expand and test for existing target
-x xdev: stay on one filesystem and skip other mounted devices
-k kinpath: output relative path from starting point to selection
-n name: output basename of file without folder parts
-p preview: show box with extra infos in "fzf" menu [1]
-i ignorecase: modifies -f, -e and -g to be case-insensitive
-r run: selection as executable or open with default program [2]
-b background: runs like -r but as a nohup background process [2]
-o FILE output: pipe standard stream from -r or -b process to file [2]
-m CMD menu: command for selection, "fzf", "rofi -dmenu", "head" [3]
-M nomenu: disable menu command -m and output everything [3]
-g PATT grep: extended-regexp filter to match text file content [4]
-d NUM maxdepth: number of subfolder levels to dig into [5]
-t TYPE type: limit to d=dir, f=file, t=text, e=executable [6]
-f PATT filter: show only files which shell pattern matches basename
-e PATT extended: posix-extended regex match at entire known path [7]
-c DIR change: directory of starting point to search files from
-- stop: parsing options and interpret everything after as FILES
Important: Any option should be listed before positional arguments at FILES.
error code:
0 success: selected path is printed to stdout
1 failure: aborted, file not found or any other error
examples:
\$ ${pname} -l
\$ ${pname} -d0 -ap -t f -- .vim*
\$ ${pname} -d2 -b -c ~/bin -m 'rofi -dmenu'
Copyright © 2023 Tuncay D. <https://github.com/thingsiplay/findpick>'
EOF
}
# OPTIND needs to be reset only, if getopts was called before. The reset here
# is just out of good habit.
OPTIND=1
# After parsing commandline options, the global opt_ variables are updated.
# Anything remaining in "$@" is not an option and can be used otherwise (such
# as positional arguments).
while getopts 'HhVsalxknpiMrbo:m:d:t:f:e:g:c:' OPTION
do
case "${OPTION}" in
H) show_help
show_help_notes
exit 0
;;
h) show_help
exit 0
;;
V) show_version
exit 0
;;
s) opt_stdin=true ;;
a) opt_all=true ;;
l) opt_symlinks=true ;;
x) opt_xdev=true ;;
k) opt_kinpath=true ;;
n) opt_name=true ;;
p) opt_preview=true ;;
i) opt_ignorecase=true ;;
M) opt_nomenu=true ;;
r) opt_run=true
if [ "${opt_output}" == "" ]
then
opt_output='/dev/null'
fi
;;
b) opt_background=true
opt_run=true
if [ "${opt_output}" == "" ]
then
opt_output='/dev/null'
fi
;;
o) opt_output="${OPTARG}" ;;
m) opt_menucmd="${OPTARG}" ;;
d) opt_maxdepth="${OPTARG}" ;;
t) opt_type="${OPTARG}" ;;
f) opt_filter="${OPTARG}" ;;
e) opt_extended="${OPTARG}" ;;
g) opt_grep="${OPTARG}" ;;
c) opt_changedir="${OPTARG}" ;;
*) show_usage >&2
error 'ValueError'
;;
esac
done
# Discard the options and sentinel --
shift "$((OPTIND-1))"
# Read each line from stdin stream into an array, to be combined with
# positional arguments at later point.
declare -a stdin=()
if [[ "${opt_stdin}" = 'true' ]]
then
mapfile -t stdin
fi
if test -z "${opt_changedir}"
then
opt_changedir="."
else
opt_changedir="${opt_changedir/#\~/${HOME}}"
fi
# Normally this "opt_output" path variable is empty. Either it is set directly
# or is set automatically when run options '-r' or '-b' are set.
if ! test -z "${opt_output}" && ! [ "${opt_output}" = '/dev/null' ]
then
opt_output="$(readlink --canonicalize-missing --no-newline --quiet \
-- "${opt_output//\~/${HOME}}")"
# Don't allow any wildcard in output name, to minimize the risk of
# accidents with later "rm" command.
if [[ ${opt_output} =~ [][*?] ]]
then
msg='Globbing "[, ], *, ?" unsupported in output filename:'
error 'ValueError' "${msg}" "\"${opt_output}\""
fi
# touch to check permissions. Delete file for fresh start.
touch -- "${opt_output}" || exit 1 && rm -- "${opt_output}"
fi
cd -- "${opt_changedir}" || exit 1
# 'find' option '-L' follows and checks destination of symbolic links, while
# '-P' never follows.
if [ "${opt_symlinks}" = 'true' ]
then
symlinks='-L'
else
symlinks='-P'
fi
# 'find' option '-xdev' to not descend into directories of other filesystems.
# '-mount' is a synonym for '-xdev'.
if [ "${opt_xdev}" = 'true' ]
then
xdev='-xdev'
else
xdev=''
fi
# 'find' option '-name' to simulate hidden dot files like 'ls' at default.
if [[ "${opt_all}" = 'true' ]]
then
all_pattern='*'
else
all_pattern='[^.]*'
fi
# case-sensitivity mode for 'filter_pattern' and 'extended_pattern'.
if [[ "${opt_ignorecase}" = 'true' ]]
then
filter_mode='-iname'
extended_mode='-iregex'
else
filter_mode='-name'
extended_mode='-regex'
fi
# 'find' option '-name' or '-iname' to filter out files with shell pattern,
# depending on scripts 'filter_mode' variable.
if [[ "${opt_filter}" = '' ]]
then
filter_pattern='*'
else
filter_pattern="${opt_filter}"
fi
# 'find' option '-regex' or '-iregex' to filter out files with regular
# expression, depending on scripts 'extended_mode' variable.
if [[ "${opt_extended}" = '' ]]
then
extended_pattern='.*'
else
extended_pattern="${opt_extended}"
fi
# 'grep' option to ignore case for '--extended-regexp' pattern matching.
if [[ "${opt_ignorecase}" = 'true' ]]
then
grep_ignorecase='--ignore-case'
else
grep_ignorecase='--no-ignore-case'
fi
# 'find' option '-maxdepth' to limit levels of folder structure to access.
# Default is '1' if no positional arguments or stdin is in use. '0' means
# to list only what is given as input, otherwise '1' is intended to list all
# files of current active start directory.
if [[ "${opt_maxdepth}" = '' ]]
then
# We could test the stdin part with '${#stdin[@]} -eq 0' instead, like
# positional arguments. But then empty input stream such as output from
# `echo /x=mc2` would default to maxdepth=1, which is not what we want.
if [[ ${#} -eq 0 ]] && [[ "${opt_stdin}" = 'false' ]]
then
opt_maxdepth=1
else
opt_maxdepth=0
fi
fi
# 'find' option '-type' and '-xtype' to list specified filetypes only.
executable_type=""
if ! [[ "${opt_type}" = '' ]]
then
opt_type="${opt_type//,/}"
# List of allowed flags (minus the comma, which was just removed prior and
# will be added later).
if ! [[ ${opt_type} =~ ^[bcdpflsxt]+$ ]]
then
msg='Unsupported flag in type:'
error 'ValueError' "${msg}" "${opt_type}"
fi
if [[ ${opt_type} =~ x ]]
then
# The flag 'x' in 'find' option '-type' is not supported and requires a
# completley different option instead. Remove it from list and set the
# other appropriate option instead.
opt_type="${opt_type/x/}"
executable_type='-executable'
else
executable_type=''
fi
if [[ ${opt_type} =~ t ]]
then
# The flag 't' in 'find' option '-type' is not supported and requires a
# completley different option instead. Remove it from list and set the
# other appropriate option instead.
opt_type="${opt_type/t/}"
if [[ "${opt_grep}" = '' ]]
then
opt_grep='.'
fi
fi
# Any remaining character is a valid flag for '-type' or '-xtype' option at
# 'find' command.
if ! [[ "${opt_type}" = '' ]]
then
# These options for 'find' command requires a comma for each flag.
# This puts a comma between each flag.
opt_type="$(printf '%s' "${opt_type}" | sed 's/./&,/g')"
opt_type=${opt_type%,}
# '-type' option from 'find' does not check target of symbolic link,
# while '-xtype' follow and resolve to destination.
if [ "${opt_symlinks}" = 'false' ]
then
opt_type='-type '"${opt_type}"
else
opt_type='-xtype '"${opt_type}"
fi
fi
fi
if ! [[ ${opt_maxdepth} =~ ^[0-9]+$ ]]
then
exit 1
fi
# Apply search and generate a newline separated and sorted list of files.
# Strip out needless front "./" and last slash for directories. Do not quote
# the free standing variables such as 'opt_type' in the command chain below,
# but make sure they are valid options and don't interfere with the commandline
# arguments to 'find'.
#
# The positional arguments and stdin array with filenames starting with a dash
# will confuse 'find'. Therefore any leading filename starting with a "-" is a
# relative path and a "./" can be added safely to it's front. Also replace "~"
# with users home directory, in case path was passed without shell
# interpretation.
argv=( "${@/#\~/$HOME}" "${stdin[@]/#\~/${HOME}}" )
files="$(find "${symlinks}" \
-O3 \
"${argv[@]/#-/.\/-}" \
-readable \
-nowarn \
-maxdepth "${opt_maxdepth}" \
${xdev} \
${opt_type} \
${executable_type} \
-name "${all_pattern}" \
"${filter_mode}" "${filter_pattern}" \
-regextype posix-extended \
"${extended_mode}" "${extended_pattern}" \
-print \
2>/dev/null)"
# Quit early if nothing is found.
if [[ "${files}" =~ \\w ]]
then
# No error message if nothing is found.
error 'OSError'
fi
files=$(printf '%s' "${files}" \
| sed 's+^./++' \
| sed 's+/$++' \
| sort)
if ! [[ "${opt_grep}" = '' ]]
then
mapfile -t array_files <<<"${files}"
files=$(grep --color=never --no-messages --files-with-matches \
--directories=skip --binary-files=without-match --max-count=1 \
${grep_ignorecase} \
--extended-regexp "${opt_grep}" \
-- "${array_files[@]}")
fi
# Now open menu (or any other streaming command set with 'opt_menucmd') with
# all list of files from previous 'find' search as input. The result should be
# one or more selected files separated by newline. The preview branch is build
# specifically for 'fzf' command and should not be enabled with any other
# command.
selected=""
if [[ "${opt_menucmd}" = '' ]] || [[ "${opt_nomenu}" = 'true' ]]
then
selected="${files}"
elif [[ "${opt_preview}" = 'true' ]]
then
preview_file () {
local path
local ftype
local links
# Function argument 2 should match scripts option 'opt_symlinks' for
# consistency.
links="${2}"
if [[ "${links}" = 'true' ]]
then
path="$(readlink --canonicalize --no-newline --quiet -- "${1}")"
else
path="$(realpath --no-symlinks --quiet -- "${1}")"
fi
ftype="$(file -b --mime -- "${path}")"
printf '%s:\n%s\n\n' "${path}" "${ftype}"
if [[ ${ftype} =~ text/ || ${ftype} =~ charset=us-ascii ]]
then
cat --number -- "${path}"
elif [ "${ftype}" == 'inode/directory; charset=binary' ]
then
# C=columns, F=classify
ls --almost-all --ignore-backups -C -F -- "${path}"
fi
}
export -f preview_file
selected="$(printf '%s' "${files}" \
| ${opt_menucmd} \
--preview-label="${opt_changedir}" \
--preview-window='down:40%,wrap' \
--preview="preview_file {} \"${opt_symlinks}\"")"
else
selected="$(printf '%s' "${files}" | ${opt_menucmd})"
fi
if [ "${selected}" = '' ]
then
# No need for an error message if nothing is selected.
error "AbortError"
fi
# Usually the user selection consists of single entry. But it is possible to
# have multiple selections. Therefore each of the newline separated entries
# must be handled individually. Any error of the commands should exit script
# immadiately.
#
# In case multiple selections are made and options '-r' or '-b' will run
# multiple programs, then the resulting file will be written with delay only
# when the programs finish. We need 'echo' here, each entry has its own
# newline and 'printf' would result in '0' for one entry.
num_selections=$(echo "${selected}" | wc -l)
while IFS= read -r path
do
original_path="${path}"
if [[ "${opt_symlinks}" = 'true' ]]
then
# /absolute/path/Filename.txt
path=$(readlink --canonicalize-existing --no-newline --quiet \
-- "${path}")
if [ "${path}" = '' ]
then
msg='No access or cannot find file:'
error 'OSError' "${msg}" "${original_path}"
fi
fi
if [[ "${opt_name}" = 'true' ]]
then
# Filename.txt
# Do not save the output to 'path' yet, as the potential directory parts
# of the path are needed.
msg='Cannot output name of file from path:'
printf '%s\n' "${path##*/}" \
|| error 'OSError' "${msg}" "${original_path}"
elif [[ "${opt_kinpath}" = 'true' ]]
then
# ../path/Filename.txt
msg='Cannot build relative path from file:'
change=$(readlink --canonicalize-existing --no-newline --quiet \
-- "${opt_changedir}") \
|| error 'OSError' "${msg}" "${original_path}"
path=$(realpath --relative-to="${change}" --no-symlinks --quiet \
-- "${path}") \
|| error 'OSError' "${msg}" "${original_path}"
elif [[ "${opt_symlinks}" = 'false' ]]
then
msg='Cannot build absolute path from file'
# /absolute/path/Filename.txt
path=$(realpath --canonicalize-missing --no-symlinks --quiet \
-- "${path}") \
|| error 'OSError' "${msg}" "${original_path}"
fi
if [[ "${opt_name}" = 'false' ]]
then
# ../path/Filename.txt
# or
# /absolute/path/Filename.txt
msg='Output of path failed:'
printf '%s\n' "${path}" \
|| error 'OSError' "${msg}" "${original_path}"
fi
# Depending on the file type, either open with default application or
# execute the selection as a command. As a background process, nohup will
# detach it from the current terminal and write output to a file instead.
# Don't create a new file each time output is written, as multiple
# applications can be selected with script.
if [[ "${opt_run}" = 'true' ]]
then
if ! [[ "${path}" =~ ^/ ]]
then
msg='Cannot build absolute path from file'
# /absolute/path/Filename.txt
path=$(realpath --canonicalize-missing --no-symlinks --quiet \
-- "${path}") \
|| error 'OSError' "${msg}" "${original_path}"
fi
# Executable file.
if [[ -f "${path}" && -x "${path}" ]]
then
if [[ "${opt_background}" = 'true' ]]
then
if [[ ${num_selections} -eq 1 ]]
then
nohup "${path}" &>> "${opt_output}" &
else
tempfile=$(mktemp -p '/dev/shm/')
nohup "${path}" &> "${tempfile}" \
&& cat "${tempfile}" >> "${opt_output}" \
&& rm -f -- "${tempfile}" &
fi
elif ! [ "${opt_output}" = '/dev/null' ]
then
if [[ ${num_selections} -eq 1 ]]
then
"${path}" &>> "${opt_output}"
else
tempfile=$(mktemp -p '/dev/shm/')
trap "rm -f -- \"${tempfile}\"" EXIT
"${path}" &> "${tempfile}" \
&& cat "${tempfile}" >> "${opt_output}" \
&& rm -f -- "${tempfile}"
fi
else
"${path}"
fi
# Any other filetype.
else
if [[ "${opt_background}" = 'true' ]]
then
if [[ ${num_selections} -eq 1 ]]
then
nohup xdg-open "${path}" &>> "${opt_output}" &
else
tempfile=$(mktemp -p '/dev/shm/')
nohup xdg-open "${path}" &> "${tempfile}" \
&& cat "${tempfile}" >> "${opt_output}" \
&& rm -f -- "${tempfile}" &
fi
elif ! [ "${opt_output}" = '/dev/null' ]
then
if [[ ${num_selections} -eq 1 ]]
then
xdg-open "${path}" &>> "${opt_output}"
else
tempfile=$(mktemp -p '/dev/shm/')
trap "rm -f -- \"${tempfile}\"" EXIT
xdg-open "${path}" &> "${tempfile}" \
&& cat "${tempfile}" >> "${opt_output}" \
&& rm -f -- "${tempfile}"
fi
else
xdg-open "${path}"
fi
fi
fi
done <<< "${selected}"