diff options
-rw-r--r-- | CMakeLists.txt | 30 | ||||
-rw-r--r-- | NEWS | 20 | ||||
-rw-r--r-- | README.adoc | 26 | ||||
-rwxr-xr-x | sdn-install | 2 | ||||
-rw-r--r-- | sdn-install.1 | 2 | ||||
-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 | 13 | ||||
-rw-r--r-- | sdn.cpp | 331 |
10 files changed, 622 insertions, 102 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 50ebf3b..d0a6042 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ # target_compile_features has been introduced in that version cmake_minimum_required (VERSION 3.1...3.27) -project (sdn VERSION 0.1 LANGUAGES CXX) +project (sdn VERSION 1.0 LANGUAGES CXX) if ("${CMAKE_CXX_COMPILER_ID}" MATCHES "GNU") set (CMAKE_CXX_FLAGS @@ -8,19 +8,35 @@ if ("${CMAKE_CXX_COMPILER_ID}" MATCHES "GNU") 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=\"${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}) -install (PROGRAMS ${PROJECT_NAME}-install DESTINATION ${CMAKE_INSTALL_BINDIR}) -install (FILES sdn.1 sdn-install.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) +# 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") @@ -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 28ff6c1..b7aeecf 100644 --- a/README.adoc +++ b/README.adoc @@ -5,14 +5,15 @@ 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"] @@ -25,8 +26,8 @@ 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 \++ Unfortunately most LLVM libc{plus}{plus} versions have a bug that crashes 'sdn' @@ -75,6 +76,7 @@ that of git, only named colours aren't supported: .... cursor 231 202 +select 202 bold bar 16 255 ul cwd bold input @@ -91,15 +93,19 @@ To obtain more vifm-like controls, you may write the following to your .... normal h parent -normal l choose +normal l enter .... Helper programs ~~~~~~~~~~~~~~~ -The F3 and F4 keys are normally bound to actions 'view' 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. +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 diff --git a/sdn-install b/sdn-install index 0e28390..788c8bf 100755 --- a/sdn-install +++ b/sdn-install @@ -125,7 +125,7 @@ done # Figure out the shell to integrate with login=$(basename "$SHELL") -actual=$(ps -p $$ -o ppid= | xargs ps -o comm= -p) +actual=$(ps -p $$ -o ppid= | xargs ps -o comm= -p | sed 's/^-//') if [ -z "$shell" ] then if [ "$login" != "$actual" ] diff --git a/sdn-install.1 b/sdn-install.1 index c186f63..a6972c8 100644 --- a/sdn-install.1 +++ b/sdn-install.1 @@ -1,6 +1,6 @@ .Dd October 27, 2020 .Dt SDN-INSTALL 1 -.Os Linux +.Os .Sh NAME .Nm sdn-install .Nd integrate sdn with the shell 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. @@ -1,7 +1,7 @@ \" https://mandoc.bsd.lv/man/roff.7.html#Sentence_Spacing -.Dd October 27, 2020 +.Dd December 30, 2024 .Dt SDN 1 -.Os Linux +.Os .Sh NAME .Nm sdn .Nd directory navigator @@ -68,8 +68,8 @@ 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 key binding as well as to show -the internal help message. +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 @@ -95,7 +95,7 @@ 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 choose +normal l enter normal M-f4 quit .Ed .Pp @@ -107,7 +107,7 @@ For rxvt, that would be: define C-ppage ^[[5^ define C-npage ^[[6^ normal C-ppage parent -normal C-npage choose +normal C-npage enter .Ed .Pp Escape characters must be inserted verbatim, e.g., by pressing C-v ESC in vi, @@ -120,6 +120,7 @@ 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 @@ -28,6 +28,8 @@ #include <locale> #include <map> #include <memory> +#include <set> +#include <sstream> #include <string> #include <tuple> #include <vector> @@ -39,27 +41,32 @@ #include <libgen.h> #include <pwd.h> #include <signal.h> -#include <sys/acl.h> #include <sys/stat.h> #include <sys/types.h> +#include <sys/wait.h> #include <time.h> #include <unistd.h> -#include <acl/libacl.h> -#include <ncurses.h> +#ifdef __linux__ #include <sys/inotify.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> // 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 -// Unicode is complex enough already and we might make assumptions #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 @@ -303,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}; @@ -409,7 +430,9 @@ enum { ALT = 1 << 24, SYM = 1 << 25 }; // Outside the range of Unicode #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(CENTER) \ XX(CHDIR) XX(PARENT) XX(GO_START) XX(GO_HOME) \ @@ -429,14 +452,18 @@ 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}, + {'\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 (4)), ACTION_EDIT}, + {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}, + {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}, @@ -450,7 +477,7 @@ static map<wint_t, action> g_normal_actions { {'/', ACTION_SEARCH}, {'s', ACTION_SEARCH}, {CTRL ('S'), ACTION_SEARCH}, {ALT | 'e', ACTION_RENAME_PREFILL}, {'e', ACTION_RENAME}, {KEY (F (6)), ACTION_RENAME_PREFILL}, {KEY (F (7)), ACTION_MKDIR}, - {'t', ACTION_TOGGLE_FULL}, {ALT | 't', ACTION_TOGGLE_FULL}, + {ALT | 't', ACTION_TOGGLE_FULL}, {'R', ACTION_REVERSE_SORT}, {ALT | '.', ACTION_SHOW_HIDDEN}, {CTRL ('L'), ACTION_REDRAW}, {'r', ACTION_RELOAD}, }; @@ -472,6 +499,7 @@ static map<wint_t, action> g_input_actions { 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}, @@ -512,6 +540,7 @@ 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 { @@ -519,6 +548,7 @@ static struct { 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 @@ -533,12 +563,12 @@ 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 @@ -549,10 +579,11 @@ static struct { void (*editor_on_change) (); ///< Callback on editor change map<action, void (*) ()> editor_on; ///< Handlers for custom actions - enum { AT_CURSOR, AT_BAR, AT_CWD, AT_INPUT, AT_INFO, AT_CMDLINE, AT_COUNT }; - chtype attrs[AT_COUNT] = {A_REVERSE, 0, A_BOLD, 0, A_ITALIC, 0}; + 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", "bar", "cwd", "input", "info", "cmdline"}; + {"cursor", "select", "bar", "cwd", "input", "info", "cmdline"}; map<int, chtype> ls_colors; ///< LS_COLORS decoded map<string, chtype> ls_exts; ///< LS_COLORS file extensions @@ -597,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)) @@ -692,11 +725,13 @@ fun make_entry (const struct dirent *f) -> entry { } auto mode = decode_mode (info.st_mode); +#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); @@ -726,8 +761,8 @@ fun make_entry (const struct dirent *f) -> entry { 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 (L" -> ", 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; } @@ -744,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); @@ -797,12 +839,23 @@ fun update () { print (info, info_width); } - auto start = sanitize (prompt + line.substr (0, g.editor_cursor)); - move (LINES - 1, compute_width (start)); + 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); @@ -870,6 +923,15 @@ fun show_message (const string &message, int ttl = 30) { 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 ()) @@ -908,6 +970,8 @@ fun reload (bool keep_anchor) { } closedir (dir); + g.selection = filter_selection (g.selection); + readfail: g.out_of_date = false; for (int col = 0; col < entry::COLUMNS; col++) { @@ -921,23 +985,38 @@ readfail: g.cursor = max (0, min (g.cursor, int (g.entries.size ()) - 1)); g.offset = max (0, min (g.offset, int (g.entries.size ()) - 1)); - if (g.inotify_wd != -1) - inotify_rm_watch (g.inotify_fd, g.inotify_wd); +#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); + + 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) { + 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 + // 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 = found + (" -- " + shell_escape (filename)); + g.ext_helper.assign (found).append (args); g.quitting = true; return; } @@ -953,8 +1032,8 @@ fun run_program (initializer_list<const char *> list, const string &filename) { tcsetpgrp (STDOUT_FILENO, getpgid (0)); for (auto program : list) - if (program) execl ("/bin/sh", "/bin/sh", "-c", (string (program) - + " " + shell_escape (filename)).c_str (), NULL); + 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 @@ -977,12 +1056,17 @@ fun run_program (initializer_list<const char *> list, const string &filename) { update (); } -fun view (const string &filename) { +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 ("SDN_VIEWER"), "sdn-view", + (const char *) getenv ("PAGER"), "less", "cat"}, filename); +} + fun edit (const string &filename) { run_program ({(const char *) getenv ("VISUAL"), (const char *) getenv ("EDITOR"), "vi"}, filename); @@ -1054,6 +1138,17 @@ fun show_help () { fclose (contents); } +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 (), @@ -1070,15 +1165,23 @@ fun match (const wstring &needle, int push) -> int { } fun match_interactive (int push) { - int matches = match (g.editor_line, push); - 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)"; + 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 @@ -1139,6 +1242,7 @@ fun pop_levels (const string &old_cwd) { g.offset = i->offset; g.cursor = i->cursor; anchor = i->filename; + g.selection = filter_selection (i->selection); } i++; g.levels.pop_back (); @@ -1223,9 +1327,12 @@ fun change_dir (const string &path) { return; } - level last {g.offset, g.cursor, g.cwd, at_cursor ().filename}; + level last {g.offset, g.cursor, g.cwd, at_cursor ().filename, g.selection}; g.cwd = full_path; bool same_path = last.path == g.cwd; + if (!same_path) + g.selection.clear (); + reload (same_path); if (!same_path) { @@ -1263,12 +1370,23 @@ 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); } @@ -1399,16 +1517,19 @@ fun handle (wint_t c) -> bool { auto i = g_normal_actions.find (c); switch (i == g_normal_actions.end () ? ACTION_NONE : i->second) { case ACTION_CHOOSE_FULL: - // FIXME: in the root directory, this inserts //item - 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_VIEW: + 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: (is_directory ? change_dir : view) (current.filename); break; case ACTION_EDIT: @@ -1435,6 +1556,33 @@ fun handle (wint_t c) -> bool { 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: g.cursor--; break; @@ -1496,7 +1644,12 @@ fun handle (wint_t c) -> bool { 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] = [] { choose (at_cursor ()); }; + 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); @@ -1548,19 +1701,27 @@ 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 (); } @@ -1802,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 () { @@ -1853,12 +2015,16 @@ 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), - at_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[]) { @@ -1882,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 (); @@ -1922,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) { @@ -1934,8 +2107,12 @@ 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); @@ -1946,7 +2123,7 @@ int main (int argc, char *argv[]) { 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; } |