diff --git a/gh-find-code b/gh-find-code index 67d3d4d..dd787ec 100755 --- a/gh-find-code +++ b/gh-find-code @@ -14,8 +14,6 @@ set -o allexport -o errexit -o nounset -o pipefail # TODO: add tests -# TODO: Fix display the wrong file in preview when switching between listed items quickly - # ====================== set variables ======================= debug_mode=false @@ -27,7 +25,7 @@ BAT_THEME=${BAT_THEME:-Monokai Extended} SHELL=bash # a cached version will be used and only then a new one will be pulled gh_default_cache_time="30m" -gh_default_limit=30 +gh_default_limit=20 gh_user_limit=${gh_user_limit:-$gh_default_limit} gh_accept_json="Accept: application/vnd.github+json" gh_accept_raw="Accept: application/vnd.github.raw" @@ -41,6 +39,10 @@ min_gh_version="2.31.0" # define colors COLOR_RESET='\033[0m' RED_NORMAL='\033[0;31m' +CYAN_NORMAL="\033[0;36m" +CYAN_BOLD="\033[1;36m" +MAGENTA_NORMAL="\033[0;35m" + GREEN_NORMAL='\033[0;32m' YELLOW_NORMAL='\033[0;33m' WHITE_BOLD='\033[1;97m' @@ -177,7 +179,7 @@ check_version gh "$min_gh_version" # ===================== helper functions ========================== sanitize_input() { - if [[ -n $2 ]]; then + if [[ -n ${2-} ]]; then # replace spaces with '+' and special characters with percent-encoded values python -c "import urllib.parse; print(urllib.parse.quote_plus('''$1'''))" else @@ -191,39 +193,114 @@ play_notification_sound() { afplay /System/Library/Sounds/Basso.aiff 2>/dev/null || echo -e "\a" } -view_contents() { - declare -a line_numbers bat_args editor_args less_args - local file_name owner_repo file_path patterns - local file_extension tempfile_with_ext matched_line less_move_to_line - local sanitized_patterns sanitized_owner_repo sanitized_file_path - IFS=$'\t' read -r file_name _ owner_repo file_path patterns < <(sed -E $'s/[[:space:]]{2,}/\t/g' <<<"$@") - - # check if the file does have a file extension and assign it - [[ $file_name =~ \.[[:alnum:]]+$ ]] && file_extension="${file_name##*.}" - # covers cases where the filename starts with a dot, for example '.zshrc' - [[ $file_name == ".${file_extension}" ]] && file_extension="$file_name" - - # strings containing operators like '*' or '+' need to be escaped, but keep the "|" - # character unescaped - sanitized_patterns=$(printf '%q\n' "$patterns" | sed 's/\\\|/|/g') - - # these sanitizations are necessary because it is possible that file paths can contain - # special characters, e.g. hashtags (#) - sanitized_owner_repo=$(sanitize_input "$owner_repo") - sanitized_file_path=$(sanitize_input "$file_path") - - gh api "repos/${sanitized_owner_repo}/contents/${sanitized_file_path}" \ +open_query_in_browser() { + local sanitized_query + sanitized_query=$(sanitize_input "$1" true) + if [ -n "$sanitized_query" ]; then + python -m webbrowser "https://github.com/search?q=${sanitized_query}&type=code" + else + play_notification_sound + fi +} + +gh_query() { + local data input="$*" + local items total_count + local index owner_repo_name file_name file_path patterns + local file_extension sanitized_patterns sanitized_owner_repo_name sanitized_file_path + local matched_line + declare -a line_numbers + if data=$(gh api search/code \ + --method GET \ --cache "$gh_default_cache_time" \ - --header "$gh_accept_raw" \ + --header "$gh_accept_json" \ + --header "$gh_accept_text_match" \ --header "$gh_rest_api_version" \ - >"$store_file_contents" \ - 2> >(tee "$store_gh_content_debug" >&2) || - die "API failed for repos/${owner_repo}/contents/${file_path}" + --field "per_page=$gh_user_limit" \ + --raw-field q="${input}" \ + --jq \ + $'"\(.items|length) \(.total_count)", + (.items | to_entries[] | { + owner_repo_name: .value.repository.full_name, + file_name: .value.name, + file_path: .value.path, + index: (.key + 1), + # create a unique list of patterns separated by a vertical line to use in + # extended grep + patterns: ([.value.text_matches[] | .. | .text? | select(type=="string")] as $patterns_array | + if $patterns_array == [] then "null" else $patterns_array | unique | join("|") end) + } | [.index, .owner_repo_name, .file_name, .file_path, .patterns] | @tsv)' 2>"$store_gh_search_debug"); then + { + # first line + IFS=' ' read -r items total_count + + # listed items + while IFS=$'\t' read -r index owner_repo_name file_name file_path patterns; do + # check if the file does have a file extension and assign it + [[ $file_name =~ \.[[:alnum:]]+$ ]] && file_extension="${file_name##*.}" + # covers cases where the filename starts with a dot, for example '.zshrc' + [[ $file_name == ".${file_extension}" ]] && file_extension="$file_name" + + # strings containing operators like '*' or '+' need to be escaped, but keep the "|" + # character unescaped + sanitized_patterns=$(printf '%q\n' "$patterns" | sed 's/\\\|/|/g') + + # these sanitizations are necessary because it is possible that file paths can contain + # special characters, e.g. hashtags (#) + sanitized_owner_repo_name=$(sanitize_input "$owner_repo_name") + sanitized_file_path=$(sanitize_input "$file_path") + cp "$store_file_contents" "${store_file_contents}_${index}" + cp "$store_file_contents" "${store_file_contents}_${index}_line_numbers" + + curl --silent \ + --request POST "localhost:$(cat "$store_fzf_port")" \ + --data "transform-header:printf '%b%s of %s cached...%b' '$DARK_GRAY' '$index' '$gh_user_limit' '$COLOR_RESET'" + + gh api "repos/${sanitized_owner_repo_name}/contents/${sanitized_file_path}" \ + --cache "$gh_default_cache_time" \ + --header "$gh_accept_raw" \ + --header "$gh_rest_api_version" \ + >"${store_file_contents}_${index}" \ + 2> >(tee "$store_gh_content_debug" >&2) || + die "API failed for repos/${owner_repo_name}/contents/${file_path}" + line_numbers=() + [[ $patterns != "null" ]] && while IFS='' read -r matched_line; do + line_numbers+=("$matched_line") + done < <(grep --extended-regexp --line-number "$sanitized_patterns" "${store_file_contents}_${index}" | cut -d: -f1) + + echo "${line_numbers[*]}" >"${store_file_contents}_${index}_line_numbers" + printf "%s\t%s\t%s\t%b%s%b/%b%s%b\t%b%s%b\n" \ + "${line_numbers:-1}" "$file_extension" "$index" "$CYAN_BOLD" "${owner_repo_name%/*}" "$COLOR_RESET" "$CYAN_NORMAL" "${owner_repo_name#*/}" "$COLOR_RESET" "$MAGENTA_NORMAL" "$file_path" "$COLOR_RESET" + done | column -ts $'\t' + } < <(echo "$data") + curl --silent \ + --request POST "localhost:$(cat "$store_fzf_port")" \ + --data "transform-header:printf '%b%s of โˆ‘ %s%b | ? help ยท esc quit%b\n' '$YELLOW_NORMAL' '$items' '$total_count' '$DARK_GRAY' '$COLOR_RESET'" + else + if [ -z "$input" ]; then + curl --silent \ + --request POST "localhost:$(cat "$store_fzf_port")" \ + --data "transform-header:printf '%bPlease enter a search query.%b' '$DARK_GRAY' '$COLOR_RESET'" + else + curl --silent \ + --request POST "localhost:$(cat "$store_fzf_port")" \ + --data "transform-header:printf '%bFailed to get the search results, check the query syntax.%b' '$RED_NORMAL' '$COLOR_RESET'" + return 0 + fi + fi + +} + +view_contents() { + declare -a line_numbers bat_args editor_args less_args + local file_extension index _ file_path + local file_name + IFS=$'\t' read -r _ file_extension index _ file_path < <(sed -E $'s/[[:space:]]{2,}/\t/g' <<<"$@") # '--wrap never' seems to be needed without it the fzf preview occasionally would move # to the wrong line; '--theme' not needed because of 'BAT_THEME' set at the top bat_args=( - "--terminal-width ${FZF_PREVIEW_COLUMNS:-$COLUMNS}" + $'--terminal-width ${FZF_PREVIEW_COLUMNS:-$COLUMNS}' "--wrap never" "--style numbers,header-filename,grid" "--color always" @@ -232,22 +309,23 @@ view_contents() { [[ -n $file_extension ]] && if bat --language "$file_extension" <<<"test" >/dev/null 2>&1; then bat_args+=("--language $file_extension") fi + IFS=' ' read -ra line_numbers <"${store_file_contents}_${index}_line_numbers" # NOTE: in 'bat', '--line-range' overwrites prior flags, thus makes it inadequate for # our use case but '--highlight-line' can be used multiple times, would be handy if # the preview would only show relevant code with a bit of context around each match # https://github.com/sharkdp/bat/pull/162#pullrequestreview-125072252 - [[ $patterns != "null" ]] && while IFS='' read -r matched_line; do - bat_args+=("--highlight-line $matched_line") - line_numbers+=("$matched_line") - done < <(grep --extended-regexp --line-number "$sanitized_patterns" "$store_file_contents" | cut -d: -f1) + for number in "${line_numbers[@]}"; do + bat_args+=("--highlight-line $number") + done + file_name=$(basename "$file_path") # replace single quotes with escaped back ticks bat_args+=("--file-name '${file_name//"'"/\`} โ”‚ ๐Ÿ…ป ${line_numbers[*]:-}'") if $open_in_editor && [[ -n $EDITOR ]]; then - tempfile_with_ext="${store_file_contents}_${file_name}" - cp "$store_file_contents" "$tempfile_with_ext" + tempfile_with_ext="${store_file_contents}_${index}_${file_name}" + cp "${store_file_contents}_${index}" "$tempfile_with_ext" case $(basename "$EDITOR") in code | codium) editor_args=(--reuse-window --goto "${tempfile_with_ext}:${line_numbers:-1}") @@ -307,94 +385,27 @@ view_contents() { # https://github.com/sharkdp/bat#using-a-different-pager bat_args+=("--pager 'less ${less_args[*]}'") fi - - eval bat "${bat_args[*]}" "$store_file_contents" - return 0 fi - # sending the change to 'fzf' via curl; 'fzf' FTW - curl --silent \ - --request POST "localhost:$(cat "$store_fzf_port")" \ - --data "change-preview-window(+${line_numbers:-1}+3/3)+change-preview:bat ${bat_args[*]} '$store_file_contents'" + eval bat "${bat_args[*]}" "${store_file_contents}_${index}" } - -gh_query() { - local data input="$*" - if [ -z "$input" ]; then - printf "%bfield_1_hidden Please enter a search query.%b" "$DARK_GRAY" "$COLOR_RESET" - return 0 - fi - if data=$(gh api search/code \ - --method GET \ - --cache "$gh_default_cache_time" \ - --header "$gh_accept_json" \ - --header "$gh_accept_text_match" \ - --header "$gh_rest_api_version" \ - --field "per_page=$gh_user_limit" \ - --raw-field q="${input}" \ - --jq \ - $'def colors: - { - "gray_normal":"\u001b[90m", - "cyan_normal": "\u001b[36m", - "cyan_bold": "\u001b[1;36m", - "magenta_normal": "\u001b[35m", - "yellow_normal": "\u001b[33m", - "reset": "\u001b[0m" - }; - def colored(text; color): - colors[color] + text + colors.reset; - "field_1_hidden " + - (colored("\(.items|length) of โˆ‘ \(.total_count)"; "yellow_normal")) + - (colored(" | ? help ยท esc quit"; "gray_normal")), - (.items | to_entries[] | { - name: colored(.value.repository.name; "cyan_bold"), - owner: colored(.value.repository.owner.login; "cyan_normal"), - file_name: .value.name, - file_path: colored(.value.path; "magenta_normal"), - index: (.key + 1), - # create a unique list of patterns separated by a vertical line to use in - # extended grep - patterns: ([.value.text_matches[] | .. | .text? | select(type=="string")] as $patterns_array | - if $patterns_array == [] then "null" else $patterns_array | unique | join("|") end) - # ".file_name" is placed at 1st place to allow the "โˆ‘" results line to be - # left-aligned (stupid workaround) - } | [ .file_name, .index, "\(.owner)/\(.name)", .file_path, .patterns] | @tsv)' 2>"$store_gh_search_debug"); then - column -ts $'\t' <<<"$data" - else - printf "%bfield_1_hidden Failed to get the search results, check the query syntax.%b" "$RED_NORMAL" "$COLOR_RESET" - return 0 - fi -} - -open_query_in_browser() { - local sanitized_query - sanitized_query=$(sanitize_input "$1" true) - if [ -n "$sanitized_query" ]; then - python -m webbrowser "https://github.com/search?q=${sanitized_query}&type=code" - else - play_notification_sound - fi -} - # ===================== lets begin ======================== gh_query "$*" | fzf \ --ansi \ --bind $'start:execute-silent:echo $FZF_PORT > $store_fzf_port' \ - --bind "change:first+reload-sync:sleep 0.5; gh_query {q}" \ - --bind 'ctrl-b:execute-silent:gh browse --repo {3} {4}' \ + --bind "change:first+reload-sync:sleep 0.75; gh_query {q}" \ + --bind 'ctrl-b:execute-silent:gh browse --repo {4} {5}' \ --bind 'ctrl-e:execute:[[ -n {q} && -n {} ]] && open_in_editor=true view_contents {}' \ --bind 'ctrl-o:execute-silent:open_query_in_browser {q}' \ - --bind 'ctrl-r:reload-sync:gh_query {q}' \ + --bind 'ctrl-r:reload:gh_query {q}' \ --bind 'ctrl-u:clear-query' \ --bind 'enter:execute:[[ -n {q} && -n {} ]] && open_in_pager=true view_contents {}' \ --bind "esc:become:" \ - --bind "focus:change-preview:sleep 0.5; [[ -n {q} && -n {} ]] && view_contents {}" \ - --bind "load:change-preview:[[ -n {q} && -n {} ]] && view_contents {}" \ --bind '?:change-preview(print_help_text)+change-preview-window:+1' \ --bind ';:jump' \ - --bind 'tab:toggle-preview+change-preview:[[ -n {q} && -n {} ]] && view_contents {}' \ + --bind 'load:change-preview([[ -n {q} && -n {} ]] && view_contents {})+change-preview-window:+{1}+3/3' \ + --bind 'tab:toggle-preview+change-preview([[ -n {q} && -n {} ]] && view_contents {})+change-preview-window:+{1}+3/3' \ --border block \ --color 'bg+:233,bg:235,gutter:235,border:238,scrollbar:235' \ --color 'preview-bg:234,preview-border:236,preview-scrollbar:237' \ @@ -405,7 +416,7 @@ gh_query "$*" | fzf \ --disabled \ --ellipsis '' \ --height=100% \ - --header-lines 1 \ + --header-lines 0 \ --info hidden \ --jump-labels 'abcdefghijklmnopqrstuvwxyz' \ --layout reverse \ @@ -413,9 +424,9 @@ gh_query "$*" | fzf \ --no-multi \ --pointer 'โ–ถ' \ --preview "[[ -n {q} && -n {} ]] && view_contents {}" \ - --preview-window 'border-block:~3:nohidden:right:nowrap:65%:<70(bottom:75%)' \ + --preview-window 'border-block:~3:+{1}+3/3:nohidden:right:nowrap:65%:<70(bottom:75%)' \ --prompt "$fzf_prompt" \ --query "$*" \ --scrollbar 'โ”‚โ–' \ --separator '' \ - --with-nth=2..4 + --with-nth=3.. diff --git a/readme.md b/readme.md index cb1f58c..cac9a76 100644 --- a/readme.md +++ b/readme.md @@ -35,7 +35,7 @@ supporting regex. | Flags | Description | | ----- | -------------------------------------------------------- | | `-d` | debug mode, temporary files are not deleted on exit | -| `-l` | limit the number of listed results (default 30, max 100) | +| `-l` | limit the number of listed results (default 20, max 100) | | `-h` | help | | Key Bindings fzf | Description | @@ -133,9 +133,8 @@ export FZF_DEFAULT_OPTS=" will automatically scroll to the matching line found. ### Miscellaneous -- Fast movements between list items can cause the wrong file to be displayed in the - preview. - Be careful which files you open in your editor to avoid triggering something unintended. + --- ## ๐Ÿ’ช Contributing