diff options
-rw-r--r-- | .clang-format | 14 | ||||
-rw-r--r-- | CMakeLists.txt | 51 | ||||
-rw-r--r-- | LICENSE | 2 | ||||
-rw-r--r-- | Makefile | 23 | ||||
-rw-r--r-- | NEWS | 20 | ||||
-rw-r--r-- | README.adoc | 117 | ||||
-rwxr-xr-x | sdn-install | 182 | ||||
-rw-r--r-- | sdn-install.1 | 35 | ||||
-rw-r--r-- | sdn-mc-ext.cpp | 222 | ||||
-rwxr-xr-x | sdn-view | 54 | ||||
-rw-r--r-- | sdn-view.1 | 24 | ||||
-rw-r--r-- | sdn.1 | 132 | ||||
-rw-r--r-- | sdn.cpp | 979 |
13 files changed, 1517 insertions, 338 deletions
diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..fa8134e --- /dev/null +++ b/.clang-format @@ -0,0 +1,14 @@ +BasedOnStyle: LLVM +ColumnLimit: 80 +IndentWidth: 4 +TabWidth: 4 +UseTab: ForContinuationAndIndentation +SpaceAfterCStyleCast: true +SpaceBeforeParens: Always +AlignAfterOpenBracket: DontAlign +AlignEscapedNewlines: DontAlign +AlignOperands: DontAlign +AlignConsecutiveMacros: Consecutive +BreakBeforeTernaryOperators: true +SpacesBeforeTrailingComments: 2 +WhitespaceSensitiveMacros: ['XX', 'ACTIONS', 'LS'] diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c6d06b..d0a6042 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,46 +1,55 @@ # target_compile_features has been introduced in that version -cmake_minimum_required (VERSION 3.1.0) - -project (sdn CXX) -set (version 0.1) +cmake_minimum_required (VERSION 3.1...3.27) +project (sdn VERSION 1.0 LANGUAGES CXX) if ("${CMAKE_CXX_COMPILER_ID}" MATCHES "GNU") - set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -pedantic") -endif () - -# Since we use a language with slow compilers, let's at least use a fast linker -execute_process (COMMAND ${CMAKE_CXX_COMPILER} -fuse-ld=gold -Wl,--version - ERROR_QUIET OUTPUT_VARIABLE ld_version) -if ("${ld_version}" MATCHES "GNU gold") - set (CMAKE_EXE_LINKER_FLAGS "-fuse-ld=gold ${CMAKE_EXE_LINKER_FLAGS}") + set (CMAKE_CXX_FLAGS + "${CMAKE_CXX_FLAGS} -Wall -Wextra -Wno-misleading-indentation -pedantic") endif () find_package (PkgConfig REQUIRED) -pkg_check_modules (NCURSESW QUIET ncursesw) +pkg_check_modules (ACL libacl) +pkg_check_modules (NCURSESW ncursesw) +if (NOT NCURSESW_FOUND) + find_library (NCURSESW_LIBRARIES NAMES ncursesw) + find_path (NCURSESW_INCLUDE_DIRS ncurses.h PATH_SUFFIXES ncurses) +endif () add_executable (${PROJECT_NAME} ${PROJECT_NAME}.cpp) -target_include_directories (${PROJECT_NAME} PUBLIC ${NCURSESW_INCLUDE_DIRS}) -target_link_libraries (${PROJECT_NAME} PUBLIC ${NCURSESW_LIBRARIES} acl) +target_include_directories (${PROJECT_NAME} + PUBLIC ${NCURSESW_INCLUDE_DIRS} ${ACL_INCLUDE_DIRS}) +target_link_directories (${PROJECT_NAME} + PUBLIC ${NCURSESW_LIBRARY_DIRS} ${ACL_LIBRARY_DIRS}) +target_link_libraries (${PROJECT_NAME} + PUBLIC ${NCURSESW_LIBRARIES} ${ACL_LIBRARIES}) target_compile_features (${PROJECT_NAME} PUBLIC cxx_std_14) target_compile_definitions (${PROJECT_NAME} PUBLIC - -DPROJECT_NAME=\"${PROJECT_NAME}\" -DPROJECT_VERSION=\"${version}\") + -DPROJECT_NAME=\"${PROJECT_NAME}\" -DPROJECT_VERSION=\"${PROJECT_VERSION}\") + +add_executable (${PROJECT_NAME}-mc-ext ${PROJECT_NAME}-mc-ext.cpp) +target_compile_features (${PROJECT_NAME}-mc-ext PUBLIC cxx_std_17) include (GNUInstallDirs) -install (TARGETS ${PROJECT_NAME} DESTINATION ${CMAKE_INSTALL_BINDIR}) +# sdn-mc-ext should be in libexec, but we prefer it in PATH. +install (TARGETS sdn sdn-mc-ext + DESTINATION ${CMAKE_INSTALL_BINDIR}) +install (PROGRAMS sdn-install sdn-view + DESTINATION ${CMAKE_INSTALL_BINDIR}) +install (FILES sdn.1 sdn-install.1 sdn-view.1 + DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) install (FILES LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR}) set (CPACK_PACKAGE_DESCRIPTION_SUMMARY "Directory navigator") set (CPACK_PACKAGE_VENDOR "Premysl Eric Janouch") set (CPACK_PACKAGE_CONTACT "Přemysl Eric Janouch <p@janouch.name>") set (CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE") -set (CPACK_PACKAGE_VERSION ${version}) set (CPACK_GENERATOR "TGZ;ZIP") set (CPACK_PACKAGE_FILE_NAME - "${PROJECT_NAME}-${version}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}") -set (CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME}-${version}") + "${PROJECT_NAME}-${PROJECT_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}") +set (CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME}-${PROJECT_VERSION}") set (CPACK_SOURCE_GENERATOR "TGZ;ZIP") set (CPACK_SOURCE_IGNORE_FILES "/\\\\.git;/build;/CMakeLists.txt.user") -set (CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${version}") +set (CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}") set (CPACK_SET_DESTDIR TRUE) include (CPack) @@ -1,4 +1,4 @@ -Copyright (c) 2017 - 2018, Přemysl Eric Janouch <p@janouch.name> +Copyright (c) 2017 - 2024, Přemysl Eric Janouch <p@janouch.name> Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. @@ -1,12 +1,19 @@ +.POSIX: SHELL = /bin/sh -CXXFLAGS = -g -std=c++14 -Wall -Wextra -pedantic -static-libstdc++ +CXXFLAGS = -g -std=c++14 -Wall -Wextra -Wno-misleading-indentation -pedantic +CPPFLAGS = `sed -ne '/^project (\([^ )]*\) VERSION \([^ )]*\).*/ \ + s//-DPROJECT_NAME="\1" -DPROJECT_VERSION="\2"/p' CMakeLists.txt` -all: sdn -%: %.cpp CMakeLists.txt - $(CXX) $(CXXFLAGS) $< -o $@ `pkg-config --libs --cflags ncursesw` -lacl \ - `sed -ne 's/^project (\([^ )]*\).*/-DPROJECT_NAME="\1"/p' \ - -e 's/^set (version \([^ )]*\).*/-DPROJECT_VERSION="\1"/p' CMakeLists.txt` +sdn: sdn.cpp CMakeLists.txt + $(CXX) $(CXXFLAGS) $(CPPFLAGS) $< -o $@ \ + -lacl `pkg-config --libs --cflags ncursesw` +sdn-static: sdn.cpp CMakeLists.txt + $(CXX) $(CXXFLAGS) $(CPPFLAGS) $< -o $@ \ + -static-libstdc++ \ + -Wl,--start-group,-Bstatic \ + -lacl `pkg-config --static --libs --cflags ncursesw` \ + -Wl,--end-group,-Bdynamic clean: - rm -f sdn + rm -f sdn sdn-static -.PHONY: all clean +.PHONY: clean @@ -0,0 +1,20 @@ +Unreleased + + * Added selection functionality, and adjusted key bindings: + - C-t or Insert toggle whether the current item is selected; + - + and - adjust the selection using shell globs; + - t and T insert the selection into the external command line + in relative or absolute form, respectively; + - Enter is like t but enters directories, and M-Enter is synonymous to t; + - C-g or Escape clear the selection, similarly to the editor. + + * Added an sdn-view script that can process Midnight Commander mc.ext.ini files + and apply matching filters; this script has been made the default F3 binding, + while the original direct pager invocation has been moved to F13 (which also + reflects Midnight Commander) + + +1.0.0 (2024-12-21) + + * Initial release + diff --git a/README.adoc b/README.adoc index 3e713af..b7aeecf 100644 --- a/README.adoc +++ b/README.adoc @@ -5,27 +5,33 @@ sdn 'sdn' is a simple directory navigator that you can invoke while editing shell commands. It enables you to: - * take a quick peek at directory contents without running `ls` + * take a quick peek at directory contents without running `ls`; + * select files to insert into the command line; * browse the filesystem without all the mess that Midnight Commander does: there's no need to create a subshell in a new pty. The current command line can be simply forwarded if it is to be edited. What's more, it will always be obvious whether the navigator is running. -The only supported platform is Linux. I wanted to try a different, simpler -approach here, and the end result is very friendly to tinkering. +'sdn' runs on Linux and all BSD derivatives. I wanted to try a different, +simpler approach here, and the end result is very friendly to tinkering. image::sdn.png[align="center"] +Packages +-------- +Regular releases are sporadic. git master should be stable enough. +You can get a package with the latest development version using Arch Linux's +https://aur.archlinux.org/packages/sdn-git[AUR], +or as a https://git.janouch.name/p/nixexprs[Nix derivation]. + Building -------- -Build dependencies: CMake and/or make, a C++14 compiler, pkg-config + -Runtime dependencies: ncursesw, libacl +Build dependencies: CMake and/or make, a C++17 compiler, pkg-config + +Runtime dependencies: ncursesw, libacl (on Linux) // Working around libasciidoc's missing support for escaping it like \++ -:doubleplus: ++ - -Unfortunately most LLVM libc++ versions have a bug that crashes 'sdn' on start. -Use GNU libstdc{doubleplus} if you're affected. +Unfortunately most LLVM libc{plus}{plus} versions have a bug that crashes 'sdn' +on start. Use GNU libstdc{plus}{plus} if you're affected. $ git clone https://git.janouch.name/p/sdn.git $ mkdir sdn/build @@ -45,81 +51,68 @@ Or you can try telling CMake to make a package for you. For Debian it is: There is also a Makefile you can use to quickly build a binary to be copied into the PATH of any machine you want to have 'sdn' on. -zsh ---- -To start using this navigator, put the following in your .zshrc: +Configuration +------------- +For a slightly more technical explanation please refer to manual pages. -.... -sdn-navigate () { - # ... possibly zle-line-init - eval "`sdn`" - [ -z "$cd" ] || cd "$cd" - [ -z "$insert" ] || LBUFFER="$LBUFFER$insert " - zle reset-prompt - # ... possibly zle-line-finish -} -zle -N sdn-navigate -bindkey '\eo' sdn-navigate -.... - -bash ----- -Here we can't reset the prompt from within a `bind -x` handler but there is -an acceptable workaround: +Integration +~~~~~~~~~~~ +The package contains an installation script called 'sdn-install' which will bind +'sdn' to Alt-o in your shell's initialisation file. The supported shells are: -.... -sdn-navigate () { - SDN_L=$READLINE_LINE SDN_P=$READLINE_POINT - READLINE_LINE= + - *zsh*: works well + - *bash*: minor issue: exiting the navigator confirms an empty prompt + - *fish*: works well + - *elvish*: version 0.14.1 and above, an unstable API is used, works well ++ +elvish is absolutely perverse. And so is integrating 'sdn' into it because it +already includes a custom file manager, bound to Ctrl-N (though I find the +ranger-like interface confusing and resource-demanding). - eval "`sdn`" - [[ -z "$cd" ]] || cd "$cd" - [[ -z "$insert" ]] || { - SDN_L="${SDN_L:0:$SDN_P}$insert ${SDN_L:$SDN_P}" - ((SDN_P=SDN_P+${#insert}+1)) - } -} -sdn-restore () { - READLINE_LINE=$SDN_L READLINE_POINT=$SDN_P - unset SDN_L SDN_P -} - -bind -x '"\200": sdn-navigate' -bind -x '"\201": sdn-restore' -bind '"\eo":"\200\C-m\201"' -.... - -Colors ------- +Colours +~~~~~~~ Here is an example of a '~/.config/sdn/look' file; the format is similar to -that of git, only named colors aren't supported: +that of git, only named colours aren't supported: .... cursor 231 202 +select 202 bold bar 16 255 ul cwd bold input +cmdline 145 .... -Filename colors are taken from the `LS_COLORS` environment variable. +Filename colours are taken from the `LS_COLORS` environment variable. Run `dircolors` to get some defaults. Bindings --------- +~~~~~~~~ To obtain more vifm-like controls, you may write the following to your '~/.config/sdn/bindings' file: .... normal h parent -normal l choose -normal ? help +normal l enter .... -Similar software ----------------- - * https://elvish.io/ is an entire shell with an integrated ranger-like file - manager on Ctrl-N (I find this confusing and resource-demanding, preferring - to keep closer to "orthodox file managers") +Helper programs +~~~~~~~~~~~~~~~ +The F3, F13 and F4 keys are normally bound to actions 'view', 'view-raw', +and 'edit', similarly to Norton Commander and other orthodox file managers. +The helper programs used here may be changed by setting the PAGER and VISUAL +(or EDITOR) environment variables. + +If 'view' finds Midnight Commander, it will make use of its configuration +to apply any matching filter, such as to produce archive listings, +or it will run the respective command. + +While it is mostly possible to get 'mcview' working using an invocation like +`PAGER='mcview -u' sdn`, beware that this helper cannot read files from its +standard input, nor does it enable overstrike processing by default (F9, could +be hacked around in 'mc.ext' by turning on the `nroff` switch for a custom file +extension, just without actually invoking 'nroff'), and thus it can't show the +program help. 'sdn' is currently optimised for 'less' as the pager. Contributing and Support ------------------------ diff --git a/sdn-install b/sdn-install new file mode 100755 index 0000000..788c8bf --- /dev/null +++ b/sdn-install @@ -0,0 +1,182 @@ +#!/bin/sh -e +# sdn-install: integrate sdn with the shell, binding to M-o +# vim: set sw=2 ts=2 sts=2 et tw=80: + +zsh() { +cat <<'EOF' +sdn-navigate () { + # optionally: zle zle-line-finish + while eval "`SDN=1 sdn "$BUFFER" "$CURSOR"`" + do + [ -z "$cd" ] || cd "$cd" + [ -z "$insert" ] || LBUFFER="$LBUFFER$insert " + [ -z "$helper" ] && break + + # Workaround for "zsh: suspended (tty output)" when invoking + # helpers after the terminal has been resized while running sdn + command true + + # Add to history, see https://www.zsh.org/mla/workers/2020/msg00633.html + fc -R =(print -- "$helper") + + /bin/sh -c "$helper" </dev/tty || break + done + # optionally: zle zle-line-init + zle reset-prompt +} + +zle -N sdn-navigate +bindkey '\eo' sdn-navigate +EOF +} + +bash() { +cat <<'EOF' +# We can't make the shell update the prompt on directory changes +# since there's no way to invoke `prompt_again()` from a `bind -x` +# handler but we can work around it by submitting a blank line. +sdn-cursor () { + if [[ $BASH_VERSINFO -lt 5 ]] + then echo -n "$SDN_L" | wc -m + else echo "$SDN_P" + fi +} + +sdn-navigate () { + SDN_L=$READLINE_LINE SDN_P=$READLINE_POINT + READLINE_LINE= + + while eval "`SDN=1 sdn "$SDN_L" "$(sdn-cursor)"`" + do + [[ -z $cd ]] || cd "$cd" + [[ -z $insert ]] || { + SDN_L="${SDN_L:0:$SDN_P}$insert ${SDN_L:$SDN_P}" + ((SDN_P=SDN_P+${#insert}+1)) + } + [[ -z $helper ]] && break + history -s -- "$helper" + /bin/sh -c "$helper" || break + done +} + +sdn-restore () { + READLINE_LINE=$SDN_L READLINE_POINT=$SDN_P + unset SDN_L SDN_P +} + +# These never occur in UTF-8: \300-\301 \365-\367 \370-\377 +bind -x '"\300": sdn-navigate' +bind -x '"\301": sdn-restore' +bind '"\eo": "\300\C-m\301"' +EOF +} + +fish() { +cat <<'EOF' +function sdn-navigate + set --local IFS + set --local buffer (commandline) + set --local cursor (commandline --cursor) + while eval (SDN=1 sdn $buffer $cursor | \ + string replace -ar '^(.*?)=' 'set --$1 ') + test -z "$cd" || cd "$cd" + test -z "$insert" || commandline --insert "$insert " + test -z "$helper" && break + /bin/sh -c "$helper" || break + end + commandline --function repaint +end +bind \eo sdn-navigate +EOF +} + +elvish() { +cat <<'EOF' +edit:insert:binding[Alt-o] = { + use str + local:reesc = [posix]{ str:replace "'\\''" "''" $posix } + local:posix = [cmd]{ /bin/sh -c $cmd </dev/tty >/dev/tty 2>&1 } + + # XXX: the -dot is not a stable API, and may hence break soon + # https://elv.sh/ref/builtin.html#do-not-use-functions-and-variables + local:buffer = $edit:current-command + local:cursor = (str:to-codepoints $buffer[0..$edit:-dot] | count) + local:ns = (ns [&]) + while ?(eval ($reesc (E:SDN=1 sdn $buffer $cursor | + sed 's/^local //' | slurp)) &ns=$ns) { + if (not-eq $ns[cd] "") { cd $ns[cd] } + if (not-eq $ns[insert] "") { edit:insert-at-dot $ns[insert]" " } + if (or (eq $ns[helper] "") (not ?($posix $ns[helper]))) { break } + } + edit:redraw &full=$true +} +EOF +} + +shell= path= +while getopts s:f:h opt +do + case $opt in + s) shell=$OPTARG;; + f) path=$OPTARG;; + *) echo "Usage: $0 [-s SHELL] [-f RCPATH | -]"; exit 2 + esac +done + +# Figure out the shell to integrate with +login=$(basename "$SHELL") +actual=$(ps -p $$ -o ppid= | xargs ps -o comm= -p | sed 's/^-//') +if [ -z "$shell" ] +then + if [ "$login" != "$actual" ] + then + echo "Conflict between login ($login) and invoking ($actual) shell." + echo "Specify the shell with the -s option." + exit 1 + fi + shell=$actual +fi + +# Figure out the default initialisation file +case "$shell" in +zsh|bash) + rc=~/.${shell}rc;; +fish) + rc=${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/sdn.fish;; +elvish) + rc=~/.elvish/rc.elv;; +*) + echo "$shell is not supported." + exit 1 +esac + +# Just print out the snippet if requested +if [ "$path" = "-" ] +then + $shell + exit 0 +fi + +# Finally append to or update the appropriate file +b="# sdn-install begin" +e="# sdn-install end" +[ -z "$path" ] && path=$rc +mkdir -p "$(dirname "$path")" +touch "$path" + +if ! grep -q "^$b" "$path" 2>/dev/null || ! grep -q "^$e" "$path" 2>/dev/null +then + printf "\n$b\n%s\n$e\n" "$($shell)" >> "$path" + echo "The snippet has been added to $path" + exit 0 +fi + +# POSIX-compliant in-place sed, trying to retain permissions here +temp=$path.sdn.new +cp -p -- "$path" "$temp" +sed < "$path" > "$temp" "/^$b/,/^$e/c\\ +$b\\ +$($shell | sed 's/\\/&&/g; s/$/\\/') +$e" +mv -- "$temp" "$path" +echo "The snippet in $path has been updated." diff --git a/sdn-install.1 b/sdn-install.1 new file mode 100644 index 0000000..a6972c8 --- /dev/null +++ b/sdn-install.1 @@ -0,0 +1,35 @@ +.Dd October 27, 2020 +.Dt SDN-INSTALL 1 +.Os +.Sh NAME +.Nm sdn-install +.Nd integrate sdn with the shell +.Sh SYNOPSIS +.Nm sdn-install +.Op Fl s Ar shell +.Op Fl p Ar - | rcpath +.Sh DESCRIPTION +.Nm +integrates +.Xr sdn 1 +with your shell, binding it to M-o. If the navigator has already been +integrated, it updates the snippet in-place. +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl p Ar - +Merely print the integration snippet for the appropriate shell to the standard +output, not changing anything. +.It Fl p Ar rcpath +Install the integration snippet into a different shell initialization file than +the default one for your user. +.It Fl s Ar shell +If you want to integrate +.Xr sdn 1 +with a different shell than the one you're running, use this option to specify +it. +.El +.Sh REPORTING BUGS +Use +.Lk https://git.janouch.name/p/sdn +to report bugs, request features, or submit pull requests. diff --git a/sdn-mc-ext.cpp b/sdn-mc-ext.cpp new file mode 100644 index 0000000..9e06760 --- /dev/null +++ b/sdn-mc-ext.cpp @@ -0,0 +1,222 @@ +// +// sdn-mc-ext: Midnight Commander extension file processor +// +// Copyright (c) 2024, Přemysl Eric Janouch <p@janouch.name> +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +#include <cstdlib> +#include <cctype> +#include <iostream> +#include <regex> +#include <string> +#include <unordered_map> +#include <vector> + +// Trailing return types make C++ syntax suck considerably less +#define fun static auto + +using namespace std; + +// It is completely fine if this only modifies ASCII letters. +fun tolower (const string &s) -> string { + string result; + for (auto c : s) result += tolower (c); + return result; +} + +fun shell_escape (const string &v) -> string { + return "'" + regex_replace (v, regex {"'"}, "'\\''") + "'"; +} + +string arg_type, arg_path, arg_basename, arg_dirname, arg_verb; +unordered_map<string, unordered_map<string, string>> sections; + +fun expand_command (string command) -> pair<string, string> { + regex re_sequence {R"(%(%|[[:alpha:]]*\{([^}]*)\}|[[:alpha:]]+))"}; + regex re_name {R"([^{}]*)"}; + regex re_parameter {R"([^,]+")"}; + string kind, out, pipe; smatch m; + while (regex_search (command, m, re_sequence)) { + out.append (m.prefix ()); + auto seq = m.str (1); + command = m.suffix (); + + string argument = m.str (2); + if (regex_search (seq, m, re_name)) + seq = m.str (); + + if (seq == "%") { + out += "%"; + } else if (seq == "p") { + out += shell_escape (arg_basename); + } else if (seq == "f") { + out += shell_escape (arg_path); + } else if (seq == "d") { + out += shell_escape (arg_dirname); + } else if (seq == "var") { + string value; + if (auto colon = argument.find (':'); colon == argument.npos) { + if (auto v = getenv (argument.c_str ())) + value = v; + } else { + value = argument.substr (colon + 1); + if (auto v = getenv (argument.substr (0, colon).c_str ())) + value = v; + } + out += shell_escape (value); + } else if (seq == "cd") { + kind = seq; + command = regex_replace (command, regex {"^ +"}, ""); + } else if (seq == "view") { + kind = seq; + command = regex_replace (command, regex {"^ +"}, ""); + + sregex_token_iterator it (argument.begin (), argument.end (), + re_parameter, 0), end; + for (; it != end; it++) { + if (*it == "hex") + pipe.append (" | od -t x1"); + + // more(1) and less(1) either ignore or display this: + //if (*it == "nroff") + // pipe.append (" | col -b"); + } + } else if (seq == "") { + cerr << "sdn-mc-ext: prompting not supported" << endl; + return {}; + } else { + cerr << "sdn-mc-ext: unsupported: %" << seq << endl; + return {}; + } + } + return {kind, + pipe.empty () ? out.append (command) : "(" + out + ")" + pipe}; +} + +fun print_command (string cmd) { + auto command = expand_command (cmd); + cout << get<0> (command) << endl << get<1> (command) << endl; +} + +fun section_matches (const unordered_map<string, string> §ion) -> bool { + if (section.count ("Directory")) + return false; + + // The configuration went through some funky changes; + // unescape \\ but leave other escapes alone. + auto filter_re = [](const string &s) { + string result; + for (size_t i = 0; i < s.length (); ) { + auto c = s[i++]; + if (c == '\\' && i < s.length ()) + if (c = s[i++]; c != '\\') + result += '\\'; + result += c; + } + return result; + }; + auto is_true = [&](const string &name) { + auto value = section.find (name); + return value != section.end () && value->second == "true"; + }; + if (auto kv = section.find ("Type"); kv != section.end ()) { + auto flags = std::regex::ECMAScript; + if (is_true ("TypeIgnoreCase")) + flags |= regex_constants::icase; + if (!regex_search (arg_type, regex {filter_re (kv->second), flags})) + return false; + } + auto basename = arg_basename; + if (auto kv = section.find ("Regex"); kv != section.end ()) { + auto flags = std::regex::ECMAScript; + if (is_true ("RegexIgnoreCase")) + flags |= regex_constants::icase; + return regex_search (basename, regex {filter_re (kv->second), flags}); + } + if (auto kv = section.find ("Shell"); kv != section.end ()) { + auto value = kv->second; + if (is_true ("ShellIgnoreCase")) { + value = tolower (value); + basename = tolower (arg_basename); + } + if (value.empty () || value[0] != '.') + return value == basename; + return basename.length () >= value.length () && + basename.substr (basename.length () - value.length ()) == value; + } + return !arg_type.empty (); +} + +fun process (const string §ion) -> bool { + auto full = sections.at (section); + if (auto include = full.find ("Include"); include != full.end ()) { + full.erase ("Open"); + full.erase ("View"); + full.erase ("Edit"); + + if (auto included = sections.find ("Include/" + include->second); + included != sections.end ()) { + for (const auto &kv : included->second) + full[kv.first] = kv.second; + } + } + if (getenv ("SDN_MC_EXT_DEBUG")) { + cerr << "[" << section << "]" << endl; + for (const auto &kv : full) + cerr << " " << kv.first << ": " << kv.second << endl; + } + if (full.count (arg_verb) && section_matches (full)) { + print_command (full[arg_verb]); + return true; + } + return false; +} + +int main (int argc, char *argv[]) { + if (argc != 6) { + cerr << "Usage: " << argv[0] + << " TYPE PATH BASENAME DIRNAME VERB < mc.ext.ini" << endl; + return 2; + } + + arg_type = argv[1]; + arg_path = argv[2], arg_basename = argv[3], arg_dirname = argv[4]; + arg_verb = argv[5]; + + string line, section; + vector<string> order; + regex re_entry {R"(^([-\w]+) *= *(.*)$)"}; + smatch m; + while (getline (cin, line)) { + if (line.empty () || line[0] == '#') { + continue; + } else if (auto length = line.length(); + line.find_last_of ('[') == 0 && + line.find_first_of (']') == length - 1) { + order.push_back ((section = line.substr (1, length - 2))); + } else if (regex_match (line, m, re_entry)) { + sections[section][m[1]] = m[2]; + } + } + for (const auto §ion : order) { + if (section == "mc.ext.ini" || + section == "Default" || + section.substr (0, 8) == "Include/") + continue; + if (process (section)) + return 0; + } + print_command (sections["Default"][arg_verb]); + return 0; +} diff --git a/sdn-view b/sdn-view new file mode 100755 index 0000000..d698e53 --- /dev/null +++ b/sdn-view @@ -0,0 +1,54 @@ +#!/bin/sh -e +# sdn-view: a viewer for sdn that makes use of Midnight Commander configuration +# to make more kinds of files directly viewable + +if [ "$#" -ne 1 ] +then + echo "Usage: $0 FILE" >&2 + exit 2 +fi + +# This handles both MC_DATADIR and odd installation locations. +datadir= +if command -v mc >/dev/null +then datadir=$(mc --datadir | sed 's/ (.*)$//') +fi + +config= +for dir in "$HOME"/.config/mc "$datadir" /etc/mc +do + if [ -n "$dir" -a -f "$dir/mc.ext.ini" ] + then + config=$dir/mc.ext.ini + break + fi +done + +# This is often used in %env{} expansion, so let's be on the same page. +export PAGER=${PAGER:-less} + +export MC_EXT_FILENAME=$(realpath "$1") +export MC_EXT_BASENAME=$(basename "$1") +export MC_EXT_CURRENTDIR=$(dirname "$MC_EXT_FILENAME") +output=$(sdn-mc-ext <"$config" "$(file -Lbz "$1")" \ + "$MC_EXT_FILENAME" "$MC_EXT_BASENAME" "$MC_EXT_CURRENTDIR" View || :) +kind=$(echo "$output" | sed -n 1p) +command=$(echo "$output" | sed -n 2p) + +case "$kind" in +view) + if [ -n "$command" ] + then eval "$command" | "$PAGER" + else "$PAGER" -- "$MC_EXT_FILENAME" + fi + ;; +'') + if [ -n "$command" ] + then eval "$command" + else "$PAGER" -- "$MC_EXT_FILENAME" + fi + ;; +*) + echo "Unsupported: $kind" >&2 + exit 1 +esac diff --git a/sdn-view.1 b/sdn-view.1 new file mode 100644 index 0000000..b8c26c9 --- /dev/null +++ b/sdn-view.1 @@ -0,0 +1,24 @@ +.Dd December 30, 2024 +.Dt SDN-VIEW 1 +.Os +.Sh NAME +.Nm sdn-view +.Nd run Midnight Commander view configuration externally +.Sh SYNOPSIS +.Nm sdn-view +.Ar path +.Sh DESCRIPTION +.Nm +invokes +.Ev PAGER +or a fallback pager on the passed filename. +.Pp +If it succeeds in finding a +.Xr mc 1 +.Pa mc.ext.ini +file, it will first process it, and apply any matching filter, +or run the respective command. +.Sh REPORTING BUGS +Use +.Lk https://git.janouch.name/p/sdn +to report bugs, request features, or submit pull requests. @@ -0,0 +1,132 @@ +\" https://mandoc.bsd.lv/man/roff.7.html#Sentence_Spacing +.Dd December 30, 2024 +.Dt SDN 1 +.Os +.Sh NAME +.Nm sdn +.Nd directory navigator +.Sh SYNOPSIS +.Nm sdn +.Op Ar line Ar point +.Nm sdn +.Cm --version +.Sh DESCRIPTION +.Nm +is a simple directory navigator that you can launch while editing shell +commands. +Use the +.Xr sdn-install 1 +script to integrate it with your shell, then invoke it at any time with M-o. +.Pp +Press F1 to get a list of active key bindings and their assigned actions, +grouped by their contexts. +.Pp +Program arguments are only used by integration snippets to forward the parent +shell's command line. +The +.Ar point +is given in terms of characters. +.Sh OPTIONS +While some behaviour can be toggled from within the program, some can only be +changed by modifying configuration files manually. +.Pp +The files follow a simple syntax derived from the Bourne shell: each option is +on its own line, with words separated by linear whitespace. +Comments start with a hash (#) and continue until the end of the line. +All special characters may be quoted using either a backslash or single-quoted +strings. +.Pp +The options and the default key bindings controlling them are as follows: +.Bl -tag +.It full-view Em bool No (t) +If non-zero, the equivalent format to +.Ql ls -l +is used to display directory contents rather than simply listing the filenames. +.It gravity Em bool +If non-zero, all entries stick to the bottom of the screen, i.e., all empty +space is at the top. +.It reverse-sort Em bool No (R) +If non-zero, the order of entries is reversed. +.It show-hidden Em bool No (M-.) +If non-zero, filenames beginning with a full stop are shown. +.It ext-helpers Em bool +If non-zero, viewers and editors are launched from the parent shell. +This way you can suspend them and use job control features of the shell. +However it also enforces any pending change to the shell's working directory. +.It sort-column Em number No (< >) +The zero-based index of the +.Ql full-view +column that entries are ordered by. +.El +.Sh ENVIRONMENT +.Bl -tag -width 15n +.It Ev LS_COLORS +Used to retrieve filename colours. +The format is described in +.Xr dir_colors 5 +and you can use the +.Xr dircolors 1 +utility to initialize this variable. +.It Ev PAGER +The viewer program to be launched by the F3 and F13 key bindings as well as +to show the internal help message. +If none is set, it defaults to +.Xr less 1 . +.It Ev VISUAL , Ev EDITOR +The editor program to be launched by the F4 key binding. +If neither variable is set, it defaults to +.Xr vi 1 . +.El +.Sh FILES +.Bl -tag -width 25n -compact +.It Pa ~/.config/sdn/config +Program configuration and navigation state, initialized or overwritten on exit. +.It Pa ~/.config/sdn/bindings +Custom key binding overrides. +.It Pa ~/.config/sdn/look +Redefine terminal attributes for UI elements. +.El +.Sh EXAMPLES +.Ss Pa bindings +Key names or combinations follow the Emacs syntax for Control and Meta prefixes +and +.Xr terminfo 5 +names are used for special keys. +To obtain more vifm-like controls and Windows-like quit abilities: +.Bd -literal -offset indent +normal h parent +normal l enter +normal M-f4 quit +.Ed +.Pp +Midnight Commander binds the same traversal actions to sequences normally +unknown to ncurses, due to them being missing from terminfo. +You'll need to define them manually to match your terminal. +For rxvt, that would be: +.Bd -literal -offset indent +define C-ppage ^[[5^ +define C-npage ^[[6^ +normal C-ppage parent +normal C-npage enter +.Ed +.Pp +Escape characters must be inserted verbatim, e.g., by pressing C-v ESC in vi, +or C-q ESC in Emacs. +.Ss Pa look +Terminal attributes are accepted in a format similar to that of +.Xr git-config 1 , +only named colours aren't supported. +For a black-on-white terminal supporting 256 colours, a theme such as the +following may work: +.Bd -literal -offset indent +cursor 231 202 +select 202 bold +bar 16 255 ul +cwd bold +input +cmdline 145 +.Ed +.Sh REPORTING BUGS +Use +.Lk https://git.janouch.name/p/sdn +to report bugs, request features, or submit pull requests. @@ -1,7 +1,7 @@ // // sdn: simple directory navigator // -// Copyright (c) 2017 - 2018, Přemysl Eric Janouch <p@janouch.name> +// Copyright (c) 2017 - 2024, Přemysl Eric Janouch <p@janouch.name> // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted. @@ -18,41 +18,55 @@ // May be required for ncursesw and we generally want it all anyway #define _XOPEN_SOURCE_EXTENDED -#include <string> -#include <vector> -#include <locale> -#include <iostream> #include <algorithm> -#include <cwchar> #include <climits> #include <cstdlib> #include <cstring> +#include <cwchar> #include <fstream> +#include <iostream> +#include <locale> #include <map> -#include <tuple> #include <memory> +#include <set> +#include <sstream> +#include <string> +#include <tuple> +#include <vector> -#include <unistd.h> #include <dirent.h> -#include <sys/stat.h> -#include <sys/types.h> -#include <sys/acl.h> #include <fcntl.h> -#include <pwd.h> +#include <fnmatch.h> #include <grp.h> #include <libgen.h> +#include <pwd.h> +#include <signal.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <sys/wait.h> #include <time.h> +#include <unistd.h> +#ifdef __linux__ #include <sys/inotify.h> -#include <sys/xattr.h> -#include <sys/types.h> -#include <sys/wait.h> +// ACL information is not important enough to be ported #include <acl/libacl.h> +#include <sys/acl.h> +#include <sys/xattr.h> +#else +#include <sys/event.h> +#endif #include <ncurses.h> -// Unicode is complex enough already and we might make assumptions +// To implement cbreak() with disabled ^S that gets reënabled on endwin() +#define NCURSES_INTERNALS +#include <term.h> +#undef CTRL // term.h -> termios.h -> sys/ttydefaults.h, too simplistic + #ifndef __STDC_ISO_10646__ -#error Unicode required for wchar_t +// Unicode is complex enough already and we might make assumptions, +// though macOS doesn't define this despite using UCS-4, +// and we won't build on Windows that seems to be the only one to use UTF-16. #endif // Trailing return types make C++ syntax suck considerably less @@ -92,8 +106,8 @@ fun to_mb (const wstring &wide) -> string { return mb; } -fun prefix_length (const wstring &in, const wstring &of) -> int { - int score = 0; +fun prefix_length (const wstring &in, const wstring &of) -> size_t { + size_t score = 0; for (size_t i = 0; i < of.size () && in.size () >= i && in[i] == of[i]; i++) score++; return score; @@ -113,6 +127,28 @@ fun split (const string &s, const string &sep) -> vector<string> { vector<string> result; split (s, sep, result); return result; } +fun untilde (const string &path) -> string { + if (path.empty ()) + return path; + + string tail = path.substr (1); + if (path[0] == '\\') + return tail; + if (path[0] != '~') + return path; + + // If there is something between the ~ and the first / (or the EOS) + if (size_t until_slash = strcspn (tail.c_str (), "/")) { + if (const auto *pw = getpwnam (tail.substr (0, until_slash).c_str ())) + return pw->pw_dir + tail.substr (until_slash); + } else if (const auto *home = getenv ("HOME")) { + return home + tail; + } else if (const auto *pw = getpwuid (getuid ())) { + return pw->pw_dir + tail; + } + return path; +} + fun needs_shell_quoting (const string &v) -> bool { // IEEE Std 1003.1 sh + the exclamation mark because of csh/bash // history expansion, implicitly also the NUL character @@ -136,11 +172,11 @@ fun shell_escape (const string &v) -> string { } fun parse_line (istream &is, vector<string> &out) -> bool { - enum {STA, DEF, COM, ESC, WOR, QUO, STATES}; - enum {TAKE = 1 << 3, PUSH = 1 << 4, STOP = 1 << 5, ERROR = 1 << 6}; - enum {TWOR = TAKE | WOR}; + enum { STA, DEF, COM, ESC, WOR, QUO, STATES }; + enum { TAKE = 1 << 3, PUSH = 1 << 4, STOP = 1 << 5, ERROR = 1 << 6 }; + enum { TWOR = TAKE | WOR }; - // We never transition back to the start state, so it can stay as a noop + // We never transition back to the start state, so it can stay as a no-op static char table[STATES][7] = { // state EOF SP, TAB ' # \ LF default /* STA */ {ERROR, DEF, QUO, COM, ESC, STOP, TWOR}, @@ -221,8 +257,9 @@ fun capitalize (const string &s) -> string { return result; } -/// Underlining for teletypes, also imitated in more(1) and less(1) -fun underline (const string& s) -> string { +/// Underlining for teletypes (also called overstriking), +/// also imitated in more(1) and less(1) +fun underline (const string &s) -> string { string result; for (auto c : s) result.append ({c, 8, '_'}); @@ -244,7 +281,7 @@ fun xdg_config_home () -> string { fun xdg_config_find (const string &suffix) -> unique_ptr<ifstream> { vector<string> dirs {xdg_config_home ()}; const char *system_dirs = getenv ("XDG_CONFIG_DIRS"); - split (system_dirs ? system_dirs : "/etc/xdg", ":", dirs); + split ((system_dirs && *system_dirs) ? system_dirs : "/etc/xdg", ":", dirs); for (const auto &dir : dirs) { if (dir[0] != '/') continue; @@ -273,7 +310,21 @@ fun xdg_config_write (const string &suffix) -> unique_ptr<fstream> { // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -using ncstring = basic_string<cchar_t>; +// This should be basic_string, however that crashes on macOS +using ncstring = vector<cchar_t>; + +fun operator+ (const ncstring &lhs, const ncstring &rhs) -> ncstring { + ncstring result; + result.reserve (lhs.size () + rhs.size ()); + result.insert (result.end (), lhs.begin (), lhs.end ()); + result.insert (result.end (), rhs.begin (), rhs.end ()); + return result; +} + +fun operator+= (ncstring &lhs, const ncstring &rhs) -> ncstring & { + lhs.insert (lhs.end (), rhs.begin (), rhs.end ()); + return lhs; +} fun cchar (chtype attrs, wchar_t c) -> cchar_t { cchar_t ch {}; wchar_t ws[] = {c, 0}; @@ -294,16 +345,16 @@ fun invert (cchar_t &ch) { } fun apply_attrs (const wstring &w, attr_t attrs) -> ncstring { - ncstring res; - for (auto c : w) - res += cchar (attrs, c); + ncstring res (w.size (), cchar_t {}); + for (size_t i = 0; i < w.size (); i++) + res[i] = cchar (attrs, w[i]); return res; } fun sanitize_char (chtype attrs, wchar_t c) -> ncstring { - if (c < 32) + if (c < 32 || c == 0x7f) return {cchar (attrs | A_REVERSE, L'^'), - cchar (attrs | A_REVERSE, c + 64)}; + cchar (attrs | A_REVERSE, (c + 64) & 0x7f)}; if (!iswprint (c)) return {cchar (attrs | A_REVERSE, L'?')}; return {cchar (attrs, c)}; @@ -330,13 +381,6 @@ fun print (const ncstring &nc, int limit) -> int { return total_width; } -fun compute_width (const wstring &w) -> int { - int total = 0; - for (const auto &c : w) - total += wcwidth (c); - return total; -} - fun compute_width (const ncstring &nc) -> int { int total = 0; for (const auto &c : nc) @@ -383,16 +427,21 @@ fun decode_attrs (const vector<string> &attrs) -> chtype { enum { ALT = 1 << 24, SYM = 1 << 25 }; // Outside the range of Unicode #define KEY(name) (SYM | KEY_ ## name) -#define CTRL 31 & +#define CTRL(char) ((char) == '?' ? 0x7f : (char) & 0x1f) #define ACTIONS(XX) XX(NONE) XX(HELP) XX(QUIT) XX(QUIT_NO_CHDIR) \ - XX(CHOOSE) XX(CHOOSE_FULL) XX(VIEW) XX(EDIT) XX(SORT_LEFT) XX(SORT_RIGHT) \ + XX(ENTER) XX(CHOOSE) XX(CHOOSE_FULL) XX(VIEW_RAW) XX(VIEW) XX(EDIT) \ + XX(SORT_LEFT) XX(SORT_RIGHT) \ + XX(SELECT) XX(DESELECT) XX(SELECT_TOGGLE) XX(SELECT_ABORT) \ XX(UP) XX(DOWN) XX(TOP) XX(BOTTOM) XX(HIGH) XX(MIDDLE) XX(LOW) \ - XX(PAGE_PREVIOUS) XX(PAGE_NEXT) XX(SCROLL_UP) XX(SCROLL_DOWN) \ + XX(PAGE_PREVIOUS) XX(PAGE_NEXT) XX(SCROLL_UP) XX(SCROLL_DOWN) XX(CENTER) \ XX(CHDIR) XX(PARENT) XX(GO_START) XX(GO_HOME) \ - XX(SEARCH) XX(RENAME) XX(RENAME_PREFILL) \ + XX(SEARCH) XX(RENAME) XX(RENAME_PREFILL) XX(MKDIR) \ XX(TOGGLE_FULL) XX(REVERSE_SORT) XX(SHOW_HIDDEN) XX(REDRAW) XX(RELOAD) \ - XX(INPUT_ABORT) XX(INPUT_CONFIRM) XX(INPUT_B_DELETE) + XX(INPUT_ABORT) XX(INPUT_CONFIRM) XX(INPUT_B_DELETE) XX(INPUT_DELETE) \ + XX(INPUT_B_KILL_WORD) XX(INPUT_B_KILL_LINE) XX(INPUT_KILL_LINE) \ + XX(INPUT_QUOTED_INSERT) \ + XX(INPUT_BACKWARD) XX(INPUT_FORWARD) XX(INPUT_BEGINNING) XX(INPUT_END) #define XX(name) ACTION_ ## name, enum action { ACTIONS(XX) ACTION_COUNT }; @@ -403,34 +452,58 @@ static const char *g_action_names[] = {ACTIONS(XX)}; #undef XX static map<wint_t, action> g_normal_actions { - {ALT | '\r', ACTION_CHOOSE_FULL}, {ALT | KEY (ENTER), ACTION_CHOOSE_FULL}, - {'\r', ACTION_CHOOSE}, {KEY (ENTER), ACTION_CHOOSE}, - {KEY (F (3)), ACTION_VIEW}, {KEY (F (4)), ACTION_EDIT}, {'h', ACTION_HELP}, + {'\r', ACTION_ENTER}, {KEY (ENTER), ACTION_ENTER}, + {ALT | '\r', ACTION_CHOOSE}, {ALT | KEY (ENTER), ACTION_CHOOSE}, + {'t', ACTION_CHOOSE}, {'T', ACTION_CHOOSE_FULL}, + {KEY (F (1)), ACTION_HELP}, {'h', ACTION_HELP}, + {KEY (F (3)), ACTION_VIEW}, {KEY (F (13)), ACTION_VIEW_RAW}, + {KEY (F (4)), ACTION_EDIT}, {'q', ACTION_QUIT}, {ALT | 'q', ACTION_QUIT_NO_CHDIR}, // M-o ought to be the same shortcut the navigator is launched with - {ALT | 'o', ACTION_QUIT}, - {'<', ACTION_SORT_LEFT}, {'>', ACTION_SORT_RIGHT}, - {'k', ACTION_UP}, {CTRL 'p', ACTION_UP}, {KEY (UP), ACTION_UP}, - {'j', ACTION_DOWN}, {CTRL 'n', ACTION_DOWN}, {KEY (DOWN), ACTION_DOWN}, + {ALT | 'o', ACTION_QUIT}, {'<', ACTION_SORT_LEFT}, {'>', ACTION_SORT_RIGHT}, + {'+', ACTION_SELECT}, {'-', ACTION_DESELECT}, + {CTRL ('T'), ACTION_SELECT_TOGGLE}, {KEY (IC), ACTION_SELECT_TOGGLE}, + {27, ACTION_SELECT_ABORT}, {CTRL ('G'), ACTION_SELECT_ABORT}, + {'k', ACTION_UP}, {CTRL ('P'), ACTION_UP}, {KEY (UP), ACTION_UP}, + {'j', ACTION_DOWN}, {CTRL ('N'), ACTION_DOWN}, {KEY (DOWN), ACTION_DOWN}, {'g', ACTION_TOP}, {ALT | '<', ACTION_TOP}, {KEY (HOME), ACTION_TOP}, {'G', ACTION_BOTTOM}, {ALT | '>', ACTION_BOTTOM}, {KEY(END), ACTION_BOTTOM}, {'H', ACTION_HIGH}, {'M', ACTION_MIDDLE}, {'L', ACTION_LOW}, {KEY (PPAGE), ACTION_PAGE_PREVIOUS}, {KEY (NPAGE), ACTION_PAGE_NEXT}, - {CTRL 'y', ACTION_SCROLL_UP}, {CTRL 'e', ACTION_SCROLL_DOWN}, - {'c', ACTION_CHDIR}, {'&', ACTION_GO_START}, {'~', ACTION_GO_HOME}, - {'/', ACTION_SEARCH}, {'s', ACTION_SEARCH}, + {CTRL ('Y'), ACTION_SCROLL_UP}, {CTRL ('E'), ACTION_SCROLL_DOWN}, + {'z', ACTION_CENTER}, + {'c', ACTION_CHDIR}, {ALT | KEY (UP), ACTION_PARENT}, + {'&', ACTION_GO_START}, {'~', ACTION_GO_HOME}, + {'/', ACTION_SEARCH}, {'s', ACTION_SEARCH}, {CTRL ('S'), ACTION_SEARCH}, {ALT | 'e', ACTION_RENAME_PREFILL}, {'e', ACTION_RENAME}, - {'t', ACTION_TOGGLE_FULL}, {ALT | 't', ACTION_TOGGLE_FULL}, + {KEY (F (6)), ACTION_RENAME_PREFILL}, {KEY (F (7)), ACTION_MKDIR}, + {ALT | 't', ACTION_TOGGLE_FULL}, {'R', ACTION_REVERSE_SORT}, {ALT | '.', ACTION_SHOW_HIDDEN}, - {CTRL 'L', ACTION_REDRAW}, {'r', ACTION_RELOAD}, + {CTRL ('L'), ACTION_REDRAW}, {'r', ACTION_RELOAD}, }; static map<wint_t, action> g_input_actions { - {27, ACTION_INPUT_ABORT}, {CTRL 'g', ACTION_INPUT_ABORT}, + {27, ACTION_INPUT_ABORT}, {CTRL ('G'), ACTION_INPUT_ABORT}, {L'\r', ACTION_INPUT_CONFIRM}, {KEY (ENTER), ACTION_INPUT_CONFIRM}, - {KEY (BACKSPACE), ACTION_INPUT_B_DELETE}, + // Sometimes terminfo is wrong, we need to accept both of these + {L'\b', ACTION_INPUT_B_DELETE}, {CTRL ('?'), ACTION_INPUT_B_DELETE}, + {KEY (BACKSPACE), ACTION_INPUT_B_DELETE}, {KEY (DC), ACTION_INPUT_DELETE}, + {CTRL ('W'), ACTION_INPUT_B_KILL_WORD}, {CTRL ('D'), ACTION_INPUT_DELETE}, + {CTRL ('U'), ACTION_INPUT_B_KILL_LINE}, + {CTRL ('K'), ACTION_INPUT_KILL_LINE}, + {CTRL ('V'), ACTION_INPUT_QUOTED_INSERT}, + {CTRL ('B'), ACTION_INPUT_BACKWARD}, {KEY (LEFT), ACTION_INPUT_BACKWARD}, + {CTRL ('F'), ACTION_INPUT_FORWARD}, {KEY (RIGHT), ACTION_INPUT_FORWARD}, + {CTRL ('A'), ACTION_INPUT_BEGINNING}, {KEY (HOME), ACTION_INPUT_BEGINNING}, + {CTRL ('E'), ACTION_INPUT_END}, {KEY (END), ACTION_INPUT_END}, +}; +static map<wint_t, action> g_search_actions { + {CTRL ('P'), ACTION_UP}, {KEY (UP), ACTION_UP}, + {CTRL ('N'), ACTION_DOWN}, {KEY (DOWN), ACTION_DOWN}, + {'/', ACTION_ENTER}, }; static const map<string, map<wint_t, action>*> g_binding_contexts { {"normal", &g_normal_actions}, {"input", &g_input_actions}, + {"search", &g_search_actions}, }; #define LS(XX) XX(NORMAL, "no") XX(FILE, "fi") XX(RESET, "rs") \ @@ -450,7 +523,7 @@ static const char *g_ls_colors[] = {LS(XX)}; struct stringcaseless { bool operator () (const string &a, const string &b) const { - const auto &c = locale::classic(); + const auto &c = locale::classic (); return lexicographical_compare (begin (a), end (a), begin (b), end (b), [&](char m, char n) { return tolower (m, c) < tolower (n, c); }); } @@ -467,18 +540,22 @@ struct entry { struct level { int offset, cursor; ///< Scroll offset and cursor position string path, filename; ///< Level path and filename at cursor + set<string> selection; ///< Filenames of selected entries }; static struct { + ncstring cmdline; ///< Outer command line string cwd; ///< Current working directory string start_dir; ///< Starting directory vector<entry> entries; ///< Current directory entries + set<string> selection; ///< Filenames of selected entries vector<level> levels; ///< Upper directory levels int offset, cursor; ///< Scroll offset and cursor position bool full_view; ///< Show extended information bool gravity; ///< Entries are shoved to the bottom bool reverse_sort; ///< Reverse sort bool show_hidden; ///< Show hidden files + bool ext_helpers; ///< Launch helpers externally int max_widths[entry::COLUMNS]; ///< Column widths int sort_column = entry::FILENAME; ///< Sorting column int sort_flash_ttl; ///< Sorting column flash TTL @@ -486,21 +563,27 @@ static struct { wstring message; ///< Message for the user int message_ttl; ///< Time to live for the message - string chosen; ///< Chosen item for the command line + vector<string> chosen; ///< Chosen items for the command line + string ext_helper; ///< External helper to run bool no_chdir; ///< Do not tell the shell to chdir bool quitting; ///< Whether we should quit already - int inotify_fd, inotify_wd = -1; ///< File watch + int watch_fd, watch_wd = -1; ///< File watch (inotify/kqueue) bool out_of_date; ///< Entries may be out of date const wchar_t *editor; ///< Prompt string for editing + wstring editor_info; ///< Right-side prompt while editing wstring editor_line; ///< Current user input + int editor_cursor = 0; ///< Cursor position + bool editor_inserting; ///< Inserting a literal character void (*editor_on_change) (); ///< Callback on editor change - void (*editor_on_confirm) (); ///< Callback on editor confirmation + map<action, void (*) ()> editor_on; ///< Handlers for custom actions - enum { AT_CURSOR, AT_BAR, AT_CWD, AT_INPUT, AT_COUNT }; - chtype attrs[AT_COUNT] = {A_REVERSE, 0, A_BOLD, 0}; - const char *attr_names[AT_COUNT] = {"cursor", "bar", "cwd", "input"}; + enum { AT_CURSOR, AT_SELECT, AT_BAR, AT_CWD, AT_INPUT, AT_INFO, AT_CMDLINE, + AT_COUNT }; + chtype attrs[AT_COUNT] = {A_REVERSE, A_BOLD, 0, A_BOLD, 0, A_ITALIC, 0}; + const char *attr_names[AT_COUNT] = + {"cursor", "select", "bar", "cwd", "input", "info", "cmdline"}; map<int, chtype> ls_colors; ///< LS_COLORS decoded map<string, chtype> ls_exts; ///< LS_COLORS file extensions @@ -508,12 +591,13 @@ static struct { map<string, wint_t, stringcaseless> name_to_key; map<wint_t, string> key_to_name; + map<string, wint_t> custom_keys; string action_names[ACTION_COUNT]; ///< Stylized action names // Refreshed by reload(): - map<uid_t, string> unames; ///< User names by UID - map<gid_t, string> gnames; ///< Group names by GID + map<uid_t, wstring> unames; ///< User names by UID + map<gid_t, wstring> gnames; ///< Group names by GID struct tm now; ///< Current local time for display } g; @@ -544,8 +628,10 @@ fun ls_format (const entry &e, bool for_target) -> chtype { set (LS_MULTIHARDLINK); if ((info.st_mode & (S_IXUSR | S_IXGRP | S_IXOTH))) set (LS_EXECUTABLE); +#ifdef __linux__ if (lgetxattr (name.c_str (), "security.capability", NULL, 0) >= 0) set (LS_CAPABILITY); +#endif if ((info.st_mode & S_ISGID)) set (LS_SETGID); if ((info.st_mode & S_ISUID)) @@ -560,8 +646,8 @@ fun ls_format (const entry &e, bool for_target) -> chtype { set (LS_STICKY_OTHER_WRITABLE); } else if (S_ISLNK (info.st_mode)) { type = LS_SYMLINK; - if (!e.target_info.st_mode - && (ls_is_colored (LS_ORPHAN) || g.ls_symlink_as_target)) + if (!e.target_info.st_mode && + (ls_is_colored (LS_ORPHAN) || g.ls_symlink_as_target)) type = LS_ORPHAN; } else if (S_ISFIFO (info.st_mode)) { type = LS_FIFO; @@ -587,15 +673,33 @@ fun ls_format (const entry &e, bool for_target) -> chtype { return format; } +fun suffixize (off_t size, unsigned shift, wchar_t suffix, std::wstring &out) + -> bool { + // Prevent implementation-defined and undefined behaviour + if (size < 0 || shift >= sizeof size * 8) + return false; + + off_t divided = size >> shift; + if (divided >= 10) { + out.assign (std::to_wstring (divided)).append (1, suffix); + return true; + } else if (divided > 0) { + unsigned times_ten = size / double (off_t (1) << shift) * 10.0; + out.assign ({L'0' + wchar_t (times_ten / 10), L'.', + L'0' + wchar_t (times_ten % 10), suffix}); + return true; + } + return false; +} + fun make_entry (const struct dirent *f) -> entry { entry e; e.filename = f->d_name; e.info.st_mode = DTTOIF (f->d_type); auto &info = e.info; - // TODO: benchmark just readdir() vs. lstat(), also on dead mounts; - // it might make sense to stat asynchronously in threads - // http://lkml.iu.edu/hypermail//linux/kernel/0804.3/1616.html + // io_uring is only at most about 50% faster, though it might help with + // slowly statting devices, at a major complexity cost. if (lstat (f->d_name, &info)) { e.cols[entry::MODES] = apply_attrs ({ decode_type (info.st_mode), L'?', L'?', L'?', L'?', L'?', L'?', L'?', L'?', L'?' }, 0); @@ -621,39 +725,44 @@ fun make_entry (const struct dirent *f) -> entry { } auto mode = decode_mode (info.st_mode); - // This is a Linux-only extension +#ifdef __linux__ + // We're using a laughably small subset of libacl: this translates to + // two lgetxattr() calls, the results of which are compared with + // specific architecture-dependent constants. Linux-only. if (acl_extended_file_nofollow (f->d_name) > 0) mode += L"+"; +#endif e.cols[entry::MODES] = apply_attrs (mode, 0); auto usr = g.unames.find (info.st_uid); e.cols[entry::USER] = (usr != g.unames.end ()) - ? apply_attrs (to_wide (usr->second), 0) + ? apply_attrs (usr->second, 0) : apply_attrs (to_wstring (info.st_uid), 0); auto grp = g.gnames.find (info.st_gid); e.cols[entry::GROUP] = (grp != g.gnames.end ()) - ? apply_attrs (to_wide (grp->second), 0) + ? apply_attrs (grp->second, 0) : apply_attrs (to_wstring (info.st_gid), 0); - auto size = to_wstring (info.st_size); - if (info.st_size >> 40) size = to_wstring (info.st_size >> 40) + L"T"; - else if (info.st_size >> 30) size = to_wstring (info.st_size >> 30) + L"G"; - else if (info.st_size >> 20) size = to_wstring (info.st_size >> 20) + L"M"; - else if (info.st_size >> 10) size = to_wstring (info.st_size >> 10) + L"K"; + std::wstring size; + if (!suffixize (info.st_size, 40, L'T', size) && + !suffixize (info.st_size, 30, L'G', size) && + !suffixize (info.st_size, 20, L'M', size) && + !suffixize (info.st_size, 10, L'K', size)) + size = to_wstring (info.st_size); e.cols[entry::SIZE] = apply_attrs (size, 0); - char buf[32] = ""; + wchar_t buf[32] = L""; auto tm = localtime (&info.st_mtime); - strftime (buf, sizeof buf, - (tm->tm_year == g.now.tm_year) ? "%b %e %H:%M" : "%b %e %Y", tm); - e.cols[entry::MTIME] = apply_attrs (to_wide (buf), 0); + wcsftime (buf, sizeof buf / sizeof *buf, + (tm->tm_year == g.now.tm_year) ? L"%b %e %H:%M" : L"%b %e %Y", tm); + e.cols[entry::MTIME] = apply_attrs (buf, 0); auto &fn = e.cols[entry::FILENAME] = apply_attrs (to_wide (e.filename), ls_format (e, false)); if (!e.target_path.empty ()) { - fn.append (apply_attrs (to_wide (" -> "), 0)); - fn.append (apply_attrs (to_wide (e.target_path), ls_format (e, true))); + fn += apply_attrs (L" -> ", 0); + fn += apply_attrs (to_wide (e.target_path), ls_format (e, true)); } return e; } @@ -662,7 +771,7 @@ fun inline visible_lines () -> int { return max (0, LINES - 2); } fun update () { int start_column = g.full_view ? 0 : entry::FILENAME; - static int alignment[entry::COLUMNS] = { -1, -1, -1, 1, 1, -1 }; + static int alignment[entry::COLUMNS] = {-1, -1, -1, 1, 1, -1}; erase (); int available = visible_lines (); @@ -670,18 +779,25 @@ fun update () { int used = min (available, all - g.offset); for (int i = 0; i < used; i++) { auto index = g.offset + i; - bool selected = index == g.cursor; - attrset (selected ? g.attrs[g.AT_CURSOR] : 0); + bool cursored = index == g.cursor; + bool selected = g.selection.count (g.entries[index].filename); + chtype attrs {}; + if (selected) + attrs = g.attrs[g.AT_SELECT]; + if (cursored) + attrs = g.attrs[g.AT_CURSOR] | (attrs & ~A_COLOR); + attrset (attrs); + move (g.gravity ? (available - used + i) : i, 0); auto used = 0; for (int col = start_column; col < entry::COLUMNS; col++) { const auto &field = g.entries[index].cols[col]; auto aligned = align (field, alignment[col] * g.max_widths[col]); + if (cursored || selected) + for_each (begin (aligned), end (aligned), decolor); if (g.sort_flash_ttl && col == g.sort_column) for_each (begin (aligned), end (aligned), invert); - if (selected) - for_each (begin (aligned), end (aligned), decolor); used += print (aligned + apply_attrs (L" ", 0), COLS - used); } hline (' ', COLS - used); @@ -713,21 +829,46 @@ fun update () { curs_set (0); if (g.editor) { move (LINES - 1, 0); - auto p = apply_attrs (wstring (g.editor) + L": ", 0); - move (LINES - 1, print (p + apply_attrs (g.editor_line, 0), COLS - 1)); + auto prompt = apply_attrs (wstring (g.editor) + L": ", 0), + line = apply_attrs (g.editor_line, 0), + info = apply_attrs (g.editor_info, g.attrs[g.AT_INFO]); + + auto info_width = compute_width (info); + if (print (prompt + line, COLS - 1) < COLS - info_width) { + move (LINES - 1, COLS - info_width); + print (info, info_width); + } + + line.resize (g.editor_cursor); + move (LINES - 1, compute_width (sanitize (prompt + line))); curs_set (1); } else if (!g.message.empty ()) { move (LINES - 1, 0); print (apply_attrs (g.message, 0), COLS); + } else if (!g.selection.empty ()) { + uint64_t size = 0; + for (const auto &e : g.entries) + if (g.selection.count (e.filename) + && S_ISREG (e.info.st_mode) && e.info.st_size > 0) + size += e.info.st_size; + + wostringstream status; + status << size << L" bytes in " << g.selection.size () << L" items"; + move (LINES - 1, 0); + print (apply_attrs (status.str (), g.attrs[g.AT_SELECT]), COLS); + } else if (!g.cmdline.empty ()) { + move (LINES - 1, 0); + print (g.cmdline, COLS); } refresh (); } fun operator< (const entry &e1, const entry &e2) -> bool { - auto t1 = make_tuple (e1.filename != "..", + static string dotdot {".."}; + auto t1 = make_tuple (e1.filename != dotdot, !S_ISDIR (e1.info.st_mode) && !S_ISDIR (e1.target_info.st_mode)); - auto t2 = make_tuple (e2.filename != "..", + auto t2 = make_tuple (e2.filename != dotdot, !S_ISDIR (e2.info.st_mode) && !S_ISDIR (e2.target_info.st_mode)); if (t1 != t2) return t1 < t2; @@ -759,24 +900,66 @@ fun operator< (const entry &e1, const entry &e2) -> bool { return a.filename < b.filename; } -fun reload (const string &old_cwd) { - g.unames.clear(); +fun at_cursor () -> const entry & { + static entry invalid; + return g.cursor >= int (g.entries.size ()) ? invalid : g.entries[g.cursor]; +} + +fun focus (const string &anchor) { + if (!anchor.empty ()) { + for (size_t i = 0; i < g.entries.size (); i++) + if (g.entries[i].filename == anchor) + g.cursor = i; + } +} + +fun resort (const string anchor = at_cursor ().filename) { + sort (begin (g.entries), end (g.entries)); + focus (anchor); +} + +fun show_message (const string &message, int ttl = 30) { + g.message = to_wide (message); + g.message_ttl = ttl; +} + +fun filter_selection (const set<string> &selection) { + set<string> reselection; + if (!selection.empty ()) + for (const auto &e : g.entries) + if (selection.count (e.filename)) + reselection.insert (e.filename); + return reselection; +} + +fun reload (bool keep_anchor) { + g.unames.clear (); while (auto *ent = getpwent ()) - g.unames.emplace (ent->pw_uid, ent->pw_name); - endpwent(); + g.unames.emplace (ent->pw_uid, to_wide (ent->pw_name)); + endpwent (); - g.gnames.clear(); + g.gnames.clear (); while (auto *ent = getgrent ()) - g.gnames.emplace (ent->gr_gid, ent->gr_name); - endgrent(); + g.gnames.emplace (ent->gr_gid, to_wide (ent->gr_name)); + endgrent (); string anchor; - if (!g.entries.empty ()) - anchor = g.entries[g.cursor].filename; + if (keep_anchor) + anchor = at_cursor ().filename; auto now = time (NULL); g.now = *localtime (&now); auto dir = opendir ("."); g.entries.clear (); + if (!dir) { + show_message (strerror (errno)); + if (g.cwd != "/") { + struct dirent f = {}; + strncpy (f.d_name, "..", sizeof f.d_name); + f.d_type = DT_DIR; + g.entries.push_back (make_entry (&f)); + } + goto readfail; + } while (auto f = readdir (dir)) { string name = f->d_name; // Two dots are for navigation but this ain't as useful @@ -786,39 +969,59 @@ fun reload (const string &old_cwd) { g.entries.push_back (make_entry (f)); } closedir (dir); - sort (begin (g.entries), end (g.entries)); - g.out_of_date = false; - if (g.cwd == old_cwd && !anchor.empty ()) { - for (size_t i = 0; i < g.entries.size (); i++) - if (g.entries[i].filename == anchor) - g.cursor = i; - } + g.selection = filter_selection (g.selection); + +readfail: + g.out_of_date = false; for (int col = 0; col < entry::COLUMNS; col++) { auto &longest = g.max_widths[col] = 0; for (const auto &entry : g.entries) longest = max (longest, compute_width (entry.cols[col])); } - g.cursor = min (g.cursor, int (g.entries.size ()) - 1); - g.offset = min (g.offset, int (g.entries.size ()) - 1); + resort (anchor); - if (g.inotify_wd != -1) - inotify_rm_watch (g.inotify_fd, g.inotify_wd); + g.cursor = max (0, min (g.cursor, int (g.entries.size ()) - 1)); + g.offset = max (0, min (g.offset, int (g.entries.size ()) - 1)); + +#ifdef __linux__ + if (g.watch_wd != -1) + inotify_rm_watch (g.watch_fd, g.watch_wd); // We don't show atime, so access and open are merely spam - g.inotify_wd = inotify_add_watch (g.inotify_fd, ".", + g.watch_wd = inotify_add_watch (g.watch_fd, ".", (IN_ALL_EVENTS | IN_ONLYDIR | IN_EXCL_UNLINK) & ~(IN_ACCESS | IN_OPEN)); -} +#else + if (g.watch_wd != -1) + close (g.watch_wd); -fun show_message (const string &message, int ttl = 30) { - g.message = to_wide (message); - g.message_ttl = ttl; + if ((g.watch_wd = open (".", O_RDONLY | O_DIRECTORY | O_CLOEXEC)) >= 0) { + // At least the macOS kqueue doesn't report anything too specific + struct kevent ev {}; + EV_SET (&ev, g.watch_wd, EVFILT_VNODE, EV_ADD | EV_CLEAR, + NOTE_WRITE | NOTE_LINK, 0, nullptr); + (void) kevent (g.watch_fd, &ev, 1, nullptr, 0, nullptr); + } +#endif } -fun run_program (initializer_list<const char*> list, const string &filename) { - endwin (); +fun run_program (initializer_list<const char *> list, const string &filename) { + auto args = (!filename.empty () && filename.front () == '-' ? " -- " : " ") + + shell_escape (filename); + if (g.ext_helpers) { + // XXX: this doesn't try them all out, + // though it shouldn't make any noticeable difference + const char *found = nullptr; + for (auto program : list) + if ((found = program)) + break; + g.ext_helper.assign (found).append (args); + g.quitting = true; + return; + } + endwin (); switch (pid_t child = fork ()) { int status; case -1: @@ -828,9 +1031,9 @@ fun run_program (initializer_list<const char*> list, const string &filename) { setpgid (0, 0); tcsetpgrp (STDOUT_FILENO, getpgid (0)); - for (auto pager : list) - if (pager) execl ("/bin/sh", "/bin/sh", "-c", (string (pager) - + " " + shell_escape (filename)).c_str (), NULL); + for (auto program : list) + if (program) execl ("/bin/sh", "/bin/sh", "-c", + (program + args).c_str (), NULL); _exit (EXIT_FAILURE); default: // ...and make sure of it in the parent as well @@ -839,16 +1042,29 @@ fun run_program (initializer_list<const char*> list, const string &filename) { // We don't provide job control--don't let us hang after ^Z while (waitpid (child, &status, WUNTRACED) > -1 && WIFSTOPPED (status)) if (WSTOPSIG (status) == SIGTSTP) - kill (child, SIGCONT); + kill (-child, SIGCONT); tcsetpgrp (STDOUT_FILENO, getpgid (0)); + + if (WIFEXITED (status) && WEXITSTATUS (status)) { + printf ("Helper returned non-zero exit status %d. " + "Press Enter to continue.\n", WEXITSTATUS (status)); + string dummy; getline (cin, dummy); + } } refresh (); update (); } +fun view_raw (const string &filename) { + // XXX: we cannot realistically detect that the pager hasn't made a pause + // at the end of the file, so we can't ensure all contents have been seen + run_program ({(const char *) getenv ("PAGER"), "less", "cat"}, filename); +} + fun view (const string &filename) { - run_program ({(const char *) getenv ("PAGER"), "pager", "cat"}, filename); + run_program ({(const char *) getenv ("SDN_VIEWER"), "sdn-view", + (const char *) getenv ("PAGER"), "less", "cat"}, filename); } fun edit (const string &filename) { @@ -872,7 +1088,7 @@ fun run_pager (FILE *contents) { dup2 (fileno (contents), STDIN_FILENO); // Behaviour copies man-db's man(1), similar to POSIX man(1) - for (auto pager : {(const char *) getenv ("PAGER"), "pager", "cat"}) + for (auto pager : {(const char *) getenv ("PAGER"), "less", "cat"}) if (pager) execl ("/bin/sh", "/bin/sh", "-c", pager, NULL); _exit (EXIT_FAILURE); default: @@ -893,8 +1109,8 @@ fun encode_key (wint_t key) -> string { wchar_t bare = key & ~ALT; if (g.key_to_name.count (bare)) encoded.append (capitalize (g.key_to_name.at (bare))); - else if (bare < 32) - encoded.append ("C-").append ({char (tolower (bare + 64))}); + else if (bare < 32 || bare == 0x7f) + encoded.append ("C-").append ({char (tolower ((bare + 64) & 0x7f))}); else encoded.append (to_mb ({bare})); return encoded; @@ -908,11 +1124,13 @@ fun show_help () { for (const auto &kv : g_binding_contexts) { fprintf (contents, "%s\n", underline (capitalize (kv.first + " key bindings")).c_str ()); - for (const auto &kv : *kv.second) { - auto key = encode_key (kv.first); - key.append (max (0, 10 - compute_width (to_wide (key))), ' '); - fprintf (contents, "%s %s\n", - key.c_str (), g.action_names[kv.second].c_str ()); + map<action, string> agg; + for (const auto &kv : *kv.second) + agg[kv.second] += encode_key (kv.first) + " "; + for (const auto &kv : agg) { + auto action = g.action_names[kv.first]; + action.append (max (0, 20 - int (action.length ())), ' '); + fprintf (contents, "%s %s\n", action.c_str (), kv.second.c_str ()); } fprintf (contents, "\n"); } @@ -920,11 +1138,59 @@ fun show_help () { fclose (contents); } -fun search (const wstring &needle) { - int best = g.cursor, best_n = 0; - for (int i = 0; i < int (g.entries.size ()); i++) { - auto o = (i + g.cursor) % g.entries.size (); - int n = prefix_length (to_wide (g.entries[o].filename), needle); +fun matches_to_editor_info (int matches) { + if (g.editor_line.empty ()) + g.editor_info.clear (); + else if (matches == 0) + g.editor_info = L"(no match)"; + else if (matches == 1) + g.editor_info = L"(1 match)"; + else + g.editor_info = L"(" + to_wstring (matches) + L" matches)"; +} + +fun match (const wstring &needle, int push) -> int { + string pattern = to_mb (needle) + "*"; + bool jump_to_first = push || fnmatch (pattern.c_str (), + g.entries[g.cursor].filename.c_str (), 0) == FNM_NOMATCH; + int best = g.cursor, matches = 0, step = push + !push; + for (int i = 0, count = g.entries.size (); i < count; i++) { + int o = (g.cursor + (count + i * step) + (count + push)) % count; + if (!fnmatch (pattern.c_str (), g.entries[o].filename.c_str (), 0) + && !matches++ && jump_to_first) + best = o; + } + g.cursor = best; + return matches; +} + +fun match_interactive (int push) { + matches_to_editor_info (match (g.editor_line, push)); +} + +fun select_matches (bool dotdot) -> set<string> { + set<string> matches; + for (const auto &e : g.entries) { + if (!dotdot && e.filename == "..") + continue; + if (!fnmatch (to_mb (g.editor_line).c_str (), + e.filename.c_str (), FNM_PATHNAME)) + matches.insert (e.filename); + } + return matches; +} + +fun select_interactive (bool dotdot) { + matches_to_editor_info (select_matches (dotdot).size ()); +} + +/// Stays on the current item unless there are better matches +fun lookup (const wstring &needle) { + int best = g.cursor; + size_t best_n = 0; + for (int i = 0, count = g.entries.size (); i < count; i++) { + int o = (g.cursor + i) % count; + size_t n = prefix_length (to_wide (g.entries[o].filename), needle); if (n > best_n) { best = o; best_n = n; @@ -934,16 +1200,16 @@ fun search (const wstring &needle) { } fun fix_cursor_and_offset () { - g.cursor = max (g.cursor, 0); g.cursor = min (g.cursor, int (g.entries.size ()) - 1); + g.cursor = max (g.cursor, 0); // Decrease the offset when more items can suddenly fit int pushable = visible_lines () - (int (g.entries.size ()) - g.offset); g.offset -= max (pushable, 0); // Make sure the cursor is visible - g.offset = max (g.offset, 0); g.offset = min (g.offset, int (g.entries.size ()) - 1); + g.offset = max (g.offset, 0); if (g.offset > g.cursor) g.offset = g.cursor; @@ -957,20 +1223,40 @@ fun is_ancestor_dir (const string &ancestor, const string &of) -> bool { return of[ancestor.length ()] == '/' || (ancestor == "/" && ancestor != of); } -fun pop_levels () { +/// If `path` is equal to the `current` directory, or lies underneath it, +/// return it as a relative path +fun relativize (string current, const string &path) -> string { + if (current == path) + return "."; + if (current.back () != '/') + current += '/'; + if (!strncmp (current.c_str (), path.c_str (), current.length ())) + return path.substr (current.length ()); + return path; +} + +fun pop_levels (const string &old_cwd) { string anchor; auto i = g.levels.rbegin (); while (i != g.levels.rend () && !is_ancestor_dir (i->path, g.cwd)) { if (i->path == g.cwd) { g.offset = i->offset; g.cursor = i->cursor; anchor = i->filename; + g.selection = filter_selection (i->selection); } i++; g.levels.pop_back (); } + + // Don't pick up bullshit from foreign history entries, especially for / + if (is_ancestor_dir (g.cwd, old_cwd)) { + auto subpath = relativize (g.cwd, old_cwd); + anchor = subpath.substr (0, subpath.find ('/')); + } + fix_cursor_and_offset (); - if (!anchor.empty () && g.entries[g.cursor].filename != anchor) - search (to_wide (anchor)); + if (!anchor.empty () && at_cursor ().filename != anchor) + lookup (to_wide (anchor)); } fun explode_path (const string &path, vector<string> &out) { @@ -1000,18 +1286,6 @@ fun absolutize (const string &abs_base, const string &path) -> string { return abs_base + "/" + path; } -/// If `path` is equal to the `current` directory, or lies underneath it, -/// return it as a relative path -fun relativize (string current, const string &path) -> string { - if (current == path) - return "."; - if (current.back () != '/') - current += '/'; - if (!strncmp (current.c_str (), path.c_str (), current.length ())) - return path.substr (current.length ()); - return path; -} - // Roughly follows the POSIX description of `cd -L` because of symlinks. // HOME and CDPATH handling is ommitted. fun change_dir (const string &path) { @@ -1040,7 +1314,7 @@ fun change_dir (const string &path) { beep (); return; } - if (!out.back().empty ()) + if (!out.back ().empty ()) out.pop_back (); } else if (in[i] != "." && (!in[i].empty () || i < startempty)) { out.push_back (in[i]); @@ -1053,23 +1327,30 @@ fun change_dir (const string &path) { return; } - auto old_cwd = g.cwd; - level last {g.offset, g.cursor, old_cwd, g.entries[g.cursor].filename}; + level last {g.offset, g.cursor, g.cwd, at_cursor ().filename, g.selection}; g.cwd = full_path; - reload (old_cwd); + bool same_path = last.path == g.cwd; + if (!same_path) + g.selection.clear (); + + reload (same_path); - if (is_ancestor_dir (last.path, g.cwd)) { - g.levels.push_back (last); + if (!same_path) { g.offset = g.cursor = 0; - } else { - pop_levels (); + if (is_ancestor_dir (last.path, g.cwd)) + g.levels.push_back (last); + else + pop_levels (last.path); } } // Roughly follows the POSIX description of the PWD environment variable fun initial_cwd () -> string { - char cwd[4096] = ""; getcwd (cwd, sizeof cwd); - const char *pwd = getenv ("PWD"); + char cwd[4096] = ""; const char *pwd = getenv ("PWD"); + if (!getcwd (cwd, sizeof cwd)) { + show_message (strerror (errno)); + return pwd; + } if (!pwd || pwd[0] != '/' || strlen (pwd) >= PATH_MAX) return cwd; @@ -1089,43 +1370,133 @@ fun initial_cwd () -> string { return ok ? pwd : cwd; } -fun choose (const entry &entry) { +fun choose (const entry &entry, bool full) { + if (g.selection.empty ()) + g.selection.insert (entry.filename); + for (const string &item : g.selection) + g.chosen.push_back (full ? absolutize (g.cwd, item) : item); + + g.selection.clear (); + g.no_chdir = full; + g.quitting = true; +} + +fun enter (const entry &entry) { // Dive into directories and accessible symlinks to them if (!S_ISDIR (entry.info.st_mode) && !S_ISDIR (entry.target_info.st_mode)) { - g.chosen = entry.filename; - g.quitting = true; + // This could rather launch ${SDN_OPEN:-xdg-open} or something + choose (entry, false); } else { change_dir (entry.filename); } } +// Move the cursor in `diff` direction and look for non-combining characters +fun move_towards_spacing (int diff) -> bool { + g.editor_cursor += diff; + return g.editor_cursor <= 0 || + g.editor_cursor >= int (g.editor_line.length ()) || + wcwidth (g.editor_line.at (g.editor_cursor)); +} + fun handle_editor (wint_t c) { - auto i = g_input_actions.find (c); - switch (i == g_input_actions.end () ? ACTION_NONE : i->second) { + auto action = ACTION_NONE; + if (g.editor_inserting) { + (void) halfdelay (1); + g.editor_inserting = false; + } else { + auto i = g_input_actions.find (c); + if (i != g_input_actions.end ()) + action = i->second; + + auto m = g_binding_contexts.find (to_mb (g.editor)); + if (m != g_binding_contexts.end () && + (i = m->second->find (c)) != m->second->end ()) + action = i->second; + } + + auto original = g.editor_line; + switch (action) { case ACTION_INPUT_CONFIRM: - if (g.editor_on_confirm) - g.editor_on_confirm (); + if (auto handler = g.editor_on[action]) + handler (); // Fall-through case ACTION_INPUT_ABORT: - g.editor_line.clear (); g.editor = 0; + g.editor_info.clear (); + g.editor_line.clear (); + g.editor_cursor = 0; + g.editor_inserting = false; g.editor_on_change = nullptr; - g.editor_on_confirm = nullptr; + g.editor_on.clear (); + return; + case ACTION_INPUT_BEGINNING: + g.editor_cursor = 0; + break; + case ACTION_INPUT_END: + g.editor_cursor = g.editor_line.length (); + break; + case ACTION_INPUT_BACKWARD: + while (g.editor_cursor > 0 && + !move_towards_spacing (-1)) + ; + break; + case ACTION_INPUT_FORWARD: + while (g.editor_cursor < int (g.editor_line.length ()) && + !move_towards_spacing (+1)) + ; break; case ACTION_INPUT_B_DELETE: - if (!g.editor_line.empty ()) - g.editor_line.erase (g.editor_line.length () - 1); + while (g.editor_cursor > 0) { + auto finished = move_towards_spacing (-1); + g.editor_line.erase (g.editor_cursor, 1); + if (finished) + break; + } + break; + case ACTION_INPUT_DELETE: + while (g.editor_cursor < int (g.editor_line.length ())) { + g.editor_line.erase (g.editor_cursor, 1); + if (move_towards_spacing (0)) + break; + } + break; + case ACTION_INPUT_B_KILL_WORD: + { + int i = g.editor_cursor; + while (i && g.editor_line[--i] == L' '); + while (i-- && g.editor_line[i] != L' '); + i++; + + g.editor_line.erase (i, g.editor_cursor - i); + g.editor_cursor = i; + break; + } + case ACTION_INPUT_B_KILL_LINE: + g.editor_line.erase (0, g.editor_cursor); + g.editor_cursor = 0; + break; + case ACTION_INPUT_KILL_LINE: + g.editor_line.erase (g.editor_cursor); + break; + case ACTION_INPUT_QUOTED_INSERT: + (void) raw (); + g.editor_inserting = true; break; default: - if (c & (ALT | SYM)) { - beep (); + if (auto handler = g.editor_on[action]) { + handler (); + } else if (c & (ALT | SYM)) { + if (c != KEY (RESIZE)) + beep (); } else { - g.editor_line += c; - if (g.editor_on_change) - g.editor_on_change (); + g.editor_line.insert (g.editor_cursor, 1, c); + g.editor_cursor++; } } + if (g.editor_on_change && g.editor_line != original) + g.editor_on_change (); } fun handle (wint_t c) -> bool { @@ -1138,19 +1509,28 @@ fun handle (wint_t c) -> bool { c = WEOF; } - const auto ¤t = g.entries[g.cursor]; + const auto ¤t = at_cursor (); + bool is_directory = + S_ISDIR (current.info.st_mode) || + S_ISDIR (current.target_info.st_mode); + auto i = g_normal_actions.find (c); switch (i == g_normal_actions.end () ? ACTION_NONE : i->second) { case ACTION_CHOOSE_FULL: - g.chosen = g.cwd + "/" + current.filename; - g.no_chdir = true; - g.quitting = true; + choose (current, true); break; case ACTION_CHOOSE: - choose (current); + choose (current, false); + break; + case ACTION_ENTER: + enter (current); + break; + case ACTION_VIEW_RAW: + // Mimic mc, it does not seem sensible to page directories + (is_directory ? change_dir : view_raw) (current.filename); break; case ACTION_VIEW: - view (current.filename); + (is_directory ? change_dir : view) (current.filename); break; case ACTION_EDIT: edit (current.filename); @@ -1168,12 +1548,39 @@ fun handle (wint_t c) -> bool { case ACTION_SORT_LEFT: g.sort_column = (g.sort_column + entry::COLUMNS - 1) % entry::COLUMNS; g.sort_flash_ttl = 2; - reload (g.cwd); + resort (); break; case ACTION_SORT_RIGHT: g.sort_column = (g.sort_column + entry::COLUMNS + 1) % entry::COLUMNS; g.sort_flash_ttl = 2; - reload (g.cwd); + resort (); + break; + + case ACTION_SELECT: + g.editor = L"select"; + g.editor_on_change = [] { select_interactive (false); }; + g.editor_on[ACTION_INPUT_CONFIRM] = [] { + auto matches = select_matches (false); + g.selection.insert (begin (matches), end (matches)); + }; + break; + case ACTION_DESELECT: + g.editor = L"deselect"; + g.editor_on_change = [] { select_interactive (true); }; + g.editor_on[ACTION_INPUT_CONFIRM] = [] { + for (const auto &match : select_matches (true)) + g.selection.erase (match); + }; + break; + case ACTION_SELECT_TOGGLE: + if (g.selection.count (current.filename)) + g.selection.erase (current.filename); + else + g.selection.insert (current.filename); + g.cursor++; + break; + case ACTION_SELECT_ABORT: + g.selection.clear (); break; case ACTION_UP: @@ -1212,11 +1619,14 @@ fun handle (wint_t c) -> bool { case ACTION_SCROLL_UP: g.offset--; break; + case ACTION_CENTER: + g.offset = g.cursor - (visible_lines () - 1) / 2; + break; case ACTION_CHDIR: g.editor = L"chdir"; - g.editor_on_confirm = [] { - change_dir (to_mb (g.editor_line)); + g.editor_on[ACTION_INPUT_CONFIRM] = [] { + change_dir (untilde (to_mb (g.editor_line))); }; break; case ACTION_PARENT: @@ -1226,30 +1636,42 @@ fun handle (wint_t c) -> bool { change_dir (g.start_dir); break; case ACTION_GO_HOME: - if (const auto *home = getenv ("HOME")) - change_dir (home); - else if (const auto *pw = getpwuid (getuid ())) - change_dir (pw->pw_dir); + change_dir (untilde ("~")); break; case ACTION_SEARCH: g.editor = L"search"; - g.editor_on_change = [] { - search (g.editor_line); - }; - g.editor_on_confirm = [] { - choose (g.entries[g.cursor]); + g.editor_on_change = [] { match_interactive (0); }; + g.editor_on[ACTION_UP] = [] { match_interactive (-1); }; + g.editor_on[ACTION_DOWN] = [] { match_interactive (+1); }; + g.editor_on[ACTION_INPUT_CONFIRM] = [] { enter (at_cursor ()); }; + g.editor_on[ACTION_ENTER] = [] { + enter (at_cursor ()); + g.editor_line.clear (); + g.editor_cursor = 0; }; break; case ACTION_RENAME_PREFILL: g.editor_line = to_wide (current.filename); + g.editor_cursor = g.editor_line.length (); // Fall-through case ACTION_RENAME: g.editor = L"rename"; - g.editor_on_confirm = [] { + g.editor_on[ACTION_INPUT_CONFIRM] = [] { auto mb = to_mb (g.editor_line); - rename (g.entries[g.cursor].filename.c_str (), mb.c_str ()); - reload (g.cwd); + if (rename (at_cursor ().filename.c_str (), mb.c_str ())) + show_message (strerror (errno)); + reload (true); + }; + break; + case ACTION_MKDIR: + g.editor = L"mkdir"; + g.editor_on[ACTION_INPUT_CONFIRM] = [] { + auto mb = to_mb (g.editor_line); + if (mkdir (mb.c_str (), 0777)) + show_message (strerror (errno)); + reload (true); + focus (mb); }; break; @@ -1258,17 +1680,17 @@ fun handle (wint_t c) -> bool { break; case ACTION_REVERSE_SORT: g.reverse_sort = !g.reverse_sort; - reload (g.cwd); + resort (); break; case ACTION_SHOW_HIDDEN: g.show_hidden = !g.show_hidden; - reload (g.cwd); + reload (true); break; case ACTION_REDRAW: clear (); break; case ACTION_RELOAD: - reload (g.cwd); + reload (true); break; default: if (c != KEY (RESIZE) && c != WEOF) @@ -1279,22 +1701,44 @@ fun handle (wint_t c) -> bool { return !g.quitting; } -fun inotify_check () { - // Only provide simple indication that contents might have changed - char buf[4096]; ssize_t len; +fun watch_check () { bool changed = false; - while ((len = read (g.inotify_fd, buf, sizeof buf)) > 0) { + // Only provide simple indication that contents might have changed, + // if only because kqueue can't do any better +#ifdef __linux__ + char buf[4096]; ssize_t len; + while ((len = read (g.watch_fd, buf, sizeof buf)) > 0) { const inotify_event *e; for (char *ptr = buf; ptr < buf + len; ptr += sizeof *e + e->len) { e = (const inotify_event *) buf; - if (e->wd == g.inotify_wd) - changed = g.out_of_date = true; + if (e->wd == g.watch_wd) + changed = true; } } - if (changed) +#else + struct kevent ev {}; + struct timespec timeout {}; + if (kevent (g.watch_fd, nullptr, 0, &ev, 1, &timeout) > 0) + changed = ev.filter == EVFILT_VNODE && (ev.fflags & NOTE_WRITE); +#endif + if ((g.out_of_date = changed)) update (); } +fun load_cmdline (int argc, char *argv[]) { + if (argc < 3) + return; + + wstring line = to_wide (argv[1]); int cursor = atoi (argv[2]); + if (line.empty () || cursor < 0 || cursor > (int) line.length ()) + return; + + std::replace_if (begin (line), end (line), iswspace, L' '); + g.cmdline = apply_attrs (line += L' ', g.attrs[g.AT_CMDLINE]); + // It is tempting to touch the cchar_t directly, though let's rather not + g.cmdline[cursor] = cchar (g.attrs[g.AT_CMDLINE] ^ A_REVERSE, line[cursor]); +} + fun decode_ansi_sgr (const vector<string> &v) -> chtype { vector<int> args; for (const auto &arg : v) { @@ -1341,8 +1785,8 @@ fun load_ls_colors (vector<string> colors) { if (equal == string::npos) continue; auto key = pair.substr (0, equal), value = pair.substr (equal + 1); - if (key != g_ls_colors[LS_SYMLINK] - || !(g.ls_symlink_as_target = value == "target")) + if (key != g_ls_colors[LS_SYMLINK] || + !(g.ls_symlink_as_target = value == "target")) attrs[key] = decode_ansi_sgr (split (value, ";")); } for (int i = 0; i < LS_COUNT; i++) { @@ -1415,16 +1859,16 @@ fun parse_key (const string &key_name) -> wint_t { c |= ALT; p += 2; } - if (!strncmp (p, "C-", 2)) { + if (g.name_to_key.count (p)) { + return c | g.name_to_key.at (p); + } else if (!strncmp (p, "C-", 2)) { p += 2; - if (*p < 32) { + if (*p < '?' || *p > '~') { cerr << "bindings: invalid combination: " << key_name << endl; return WEOF; } - c |= CTRL *p; + c |= CTRL (*p); p += 1; - } else if (g.name_to_key.count (p)) { - return c | g.name_to_key.at (p); } else { wchar_t w; mbstate_t mb {}; auto len = strlen (p) + 1, res = mbrtowc (&w, p, len, &mb); @@ -1453,7 +1897,9 @@ fun learn_named_key (const string &name, wint_t key) { fun load_bindings () { learn_named_key ("space", ' '); learn_named_key ("escape", 0x1b); - for (int kc = KEY_MIN; kc < KEY_MAX; kc++) { + + int kc = 0; + for (kc = KEY_MIN; kc <= KEY_MAX; kc++) { const char *name = keyname (kc); if (!name) continue; @@ -1467,10 +1913,6 @@ fun load_bindings () { learn_named_key (filtered, SYM | kc); } - auto config = xdg_config_find ("bindings"); - if (!config) - return; - // Stringization in the preprocessor is a bit limited, we want lisp-case map<string, action> actions; int a = 0; @@ -1482,16 +1924,27 @@ fun load_bindings () { actions[name] = action (a++); } + auto config = xdg_config_find ("bindings"); + if (!config) + return; + vector<string> tokens; while (parse_line (*config, tokens)) { if (tokens.empty ()) continue; if (tokens.size () < 3) { - cerr << "bindings: expected: context binding action"; + cerr << "bindings: expected: define name key-sequence" + " | context binding action"; continue; } auto context = tokens[0], key_name = tokens[1], action = tokens[2]; + if (context == "define") { + // We haven't run initscr() yet, so define_key() would fail here + learn_named_key (key_name, SYM | (g.custom_keys[action] = ++kc)); + continue; + } + auto m = g_binding_contexts.find (context); if (m == g_binding_contexts.end ()) { cerr << "bindings: invalid context: " << context << endl; @@ -1510,10 +1963,11 @@ fun load_bindings () { } fun load_history_level (const vector<string> &v) { - if (v.size () != 7) + if (v.size () < 7) return; // Not checking the hostname and parent PID right now since we can't merge - g.levels.push_back ({stoi (v.at (4)), stoi (v.at (5)), v.at (3), v.at (6)}); + g.levels.push_back ({stoi (v.at (4)), stoi (v.at (5)), v.at (3), v.at (6), + set<string> (begin (v) + 7, end (v))}); } fun load_config () { @@ -1534,6 +1988,8 @@ fun load_config () { g.reverse_sort = tokens.at (1) == "1"; else if (tokens.front () == "show-hidden" && tokens.size () > 1) g.show_hidden = tokens.at (1) == "1"; + else if (tokens.front () == "ext-helpers" && tokens.size () > 1) + g.ext_helpers = tokens.at (1) == "1"; else if (tokens.front () == "sort-column" && tokens.size () > 1) g.sort_column = stoi (tokens.at (1)); else if (tokens.front () == "history") @@ -1550,6 +2006,7 @@ fun save_config () { write_line (*config, {"gravity", g.gravity ? "1" : "0"}); write_line (*config, {"reverse-sort", g.reverse_sort ? "1" : "0"}); write_line (*config, {"show-hidden", g.show_hidden ? "1" : "0"}); + write_line (*config, {"ext-helpers", g.ext_helpers ? "1" : "0"}); write_line (*config, {"sort-column", to_string (g.sort_column)}); @@ -1558,19 +2015,26 @@ fun save_config () { *hostname = 0; auto ppid = std::to_string (getppid ()); - for (auto i = g.levels.begin (); i != g.levels.end (); i++) - write_line (*config, {"history", hostname, ppid, i->path, - to_string (i->offset), to_string (i->cursor), i->filename}); - write_line (*config, {"history", hostname, ppid, g.cwd, - to_string (g.offset), to_string (g.cursor), - g.entries[g.cursor].filename}); + for (auto i = g.levels.begin (); i != g.levels.end (); i++) { + vector<string> line {"history", hostname, ppid, i->path, + to_string (i->offset), to_string (i->cursor), i->filename}; + line.insert (end (line), begin (i->selection), end (i->selection)); + write_line (*config, line); + } + vector<string> line {"history", hostname, ppid, g.cwd, + to_string (g.offset), to_string (g.cursor), at_cursor ().filename}; + line.insert (end (line), begin (g.selection), end (g.selection)); + write_line (*config, line); } int main (int argc, char *argv[]) { - (void) argc; - (void) argv; + if (argc == 2 && string (argv[1]) == "--version") { + cout << PROJECT_NAME << " " << PROJECT_VERSION << endl; + return 0; + } - // That bitch zle closes stdin before exec without redirection + // zsh before 5.4 may close stdin before exec without redirection, + // since then it redirects stdin to /dev/null (void) close (STDIN_FILENO); if (open ("/dev/tty", O_RDWR)) { cerr << "cannot open tty" << endl; @@ -1584,10 +2048,17 @@ int main (int argc, char *argv[]) { // So that the neither us nor our children stop on tcsetpgrp() signal (SIGTTOU, SIG_IGN); - if ((g.inotify_fd = inotify_init1 (IN_NONBLOCK)) < 0) { +#ifdef __linux__ + if ((g.watch_fd = inotify_init1 (IN_NONBLOCK)) < 0) { cerr << "cannot initialize inotify" << endl; return 1; } +#else + if ((g.watch_fd = kqueue ()) < 0) { + cerr << "cannot initialize kqueue" << endl; + return 1; + } +#endif locale::global (locale ("")); load_bindings (); @@ -1597,13 +2068,23 @@ int main (int argc, char *argv[]) { cerr << "cannot initialize screen" << endl; return 1; } + for (const auto &definition_kc : g.custom_keys) + define_key (definition_kc.first.c_str (), definition_kc.second); load_colors (); + load_cmdline (argc, argv); g.start_dir = g.cwd = initial_cwd (); - reload (g.cwd); - pop_levels (); + reload (false); + pop_levels (g.cwd); update (); + // Cunt, now I need to reïmplement all signal handling +#if NCURSES_VERSION_PATCH < 20210821 + // This gets applied along with the following halfdelay() + cur_term->Nttyb.c_cc[VSTOP] = + cur_term->Nttyb.c_cc[VSTART] = _POSIX_VDISABLE; +#endif + // Invoking keypad() earlier would make ncurses flush its output buffer, // which would worsen start-up flickering if (halfdelay (1) == ERR || keypad (stdscr, TRUE) == ERR) { @@ -1614,7 +2095,7 @@ int main (int argc, char *argv[]) { wint_t c; while (!read_key (c) || handle (c)) { - inotify_check (); + watch_check (); if (g.sort_flash_ttl && !--g.sort_flash_ttl) update (); if (g.message_ttl && !--g.message_ttl) { @@ -1626,17 +2107,23 @@ int main (int argc, char *argv[]) { save_config (); // Presumably it is going to end up as an argument, so quote it - if (!g.chosen.empty ()) - g.chosen = shell_escape (g.chosen); + string chosen; + for (const auto &item : g.chosen) { + if (!chosen.empty ()) + chosen += ' '; + chosen += shell_escape (item); + } // We can't portably create a standard stream from an FD, so modify the FD dup2 (output_fd, STDOUT_FILENO); + // TODO: avoid printing any of this unless the SDN envvar is set if (g.cwd != g.start_dir && !g.no_chdir) cout << "local cd=" << shell_escape (g.cwd) << endl; else cout << "local cd=" << endl; - cout << "local insert=" << shell_escape (g.chosen) << endl; + cout << "local insert=" << shell_escape (chosen) << endl; + cout << "local helper=" << shell_escape (g.ext_helper) << endl; return 0; } |