diff options
| -rw-r--r-- | .clang-format | 14 | ||||
| -rw-r--r-- | CMakeLists.txt | 39 | ||||
| -rw-r--r-- | LICENSE | 2 | ||||
| -rw-r--r-- | Makefile | 10 | ||||
| -rw-r--r-- | NEWS | 20 | ||||
| -rw-r--r-- | README.adoc | 32 | ||||
| -rwxr-xr-x | sdn-install | 6 | ||||
| -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 | 622 | 
13 files changed, 840 insertions, 220 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 0bef602..d0a6042 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,33 +1,42 @@  # target_compile_features has been introduced in that version -cmake_minimum_required (VERSION 3.1) -project (sdn VERSION 0.1 LANGUAGES CXX) +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 -Wno-misleading-indentation -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}") -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") @@ -1,4 +1,4 @@ -Copyright (c) 2017 - 2021, 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. @@ -5,15 +5,15 @@ CPPFLAGS = `sed -ne '/^project (\([^ )]*\) VERSION \([^ )]*\).*/ \  	s//-DPROJECT_NAME="\1" -DPROJECT_VERSION="\2"/p' CMakeLists.txt`  sdn: sdn.cpp CMakeLists.txt -	$(CXX) $(CXXFLAGS) $(CPPFLAGS) $< -o sdn \ +	$(CXX) $(CXXFLAGS) $(CPPFLAGS) $< -o $@ \  	-lacl `pkg-config --libs --cflags ncursesw` -static: sdn.cpp CMakeLists.txt -	$(CXX) $(CXXFLAGS) $(CPPFLAGS) $< -o sdn \ +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: static 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 6e8e363..b7aeecf 100644 --- a/README.adoc +++ b/README.adoc @@ -5,26 +5,29 @@ 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 from Archlinux's AUR. +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 \++  Unfortunately most LLVM libc{plus}{plus} versions have a bug that crashes 'sdn' @@ -73,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 @@ -89,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 93821bf..788c8bf 100755 --- a/sdn-install +++ b/sdn-install @@ -16,6 +16,9 @@ sdn-navigate () {      # 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 @@ -51,6 +54,7 @@ sdn-navigate () {        ((SDN_P=SDN_P+${#insert}+1))      }      [[ -z $helper ]] && break +    history -s -- "$helper"      /bin/sh -c "$helper" || break    done  } @@ -121,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 @@ -1,7 +1,7 @@  //  // sdn: simple directory navigator  // -// Copyright (c) 2017 - 2021, 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,42 +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 <time.h> +#include <pwd.h>  #include <signal.h> - -#include <sys/inotify.h> -#include <sys/xattr.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> +// 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 @@ -159,9 +172,9 @@ 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 no-op  	static char table[STATES][7] = { @@ -246,7 +259,7 @@ fun capitalize (const string &s) -> string {  /// Underlining for teletypes (also called overstriking),  /// also imitated in more(1) and less(1) -fun underline (const string& s) -> string { +fun underline (const string &s) -> string {  	string result;  	for (auto c : s)  		result.append ({c, 8, '_'}); @@ -268,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; @@ -297,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}; @@ -318,9 +345,9 @@ 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;  } @@ -403,14 +430,17 @@ 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(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(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_DELETE) \ -	XX(INPUT_B_KILL_LINE) XX(INPUT_KILL_LINE) XX(INPUT_QUOTED_INSERT) \ +	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, @@ -422,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}, @@ -437,12 +471,13 @@ static map<wint_t, action> g_normal_actions {  	{'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}, +	{'z', ACTION_CENTER},  	{'c', ACTION_CHDIR}, {ALT | KEY (UP), ACTION_PARENT},  	{'&', ACTION_GO_START}, {'~', ACTION_GO_HOME}, -	{'/', ACTION_SEARCH},  {'s', ACTION_SEARCH}, +	{'/', 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},  }; @@ -452,7 +487,8 @@ static map<wint_t, action> g_input_actions {  	// 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 ('D'), ACTION_INPUT_DELETE}, {CTRL ('U'), ACTION_INPUT_B_KILL_LINE}, +	{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}, @@ -463,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}, @@ -486,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); });  	} @@ -503,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 { @@ -510,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 @@ -524,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 @@ -538,13 +577,13 @@ static struct {  	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_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 @@ -557,8 +596,8 @@ static struct {  	// 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; @@ -589,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)) @@ -605,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; @@ -632,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); @@ -666,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;  } @@ -707,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 (); @@ -715,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); @@ -768,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); @@ -783,9 +865,10 @@ fun update () {  }  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; @@ -822,16 +905,43 @@ fun at_cursor () -> const entry & {  	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(); +	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 (keep_anchor) @@ -840,6 +950,16 @@ fun reload (bool keep_anchor) {  	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 @@ -849,45 +969,54 @@ fun reload (bool keep_anchor) {  			g.entries.push_back (make_entry (f));  	}  	closedir (dir); -	sort (begin (g.entries), end (g.entries)); -	g.out_of_date = false; -	if (!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]));  	} +	resort (anchor); +  	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); -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) { +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;  	} @@ -903,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 @@ -927,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); @@ -1004,24 +1138,7 @@ fun show_help () {  	fclose (contents);  } -/// Stays on the current match when there are no better ones, unless it's pushed -fun search (const wstring &needle, int push) -> int { -	int best = g.cursor, best_n = 0, matches = 0, step = push != 0 ? push : 1; -	for (int i = 0, count = g.entries.size (); i < count; i++) { -		int o = (g.cursor + (count + i * step) + (count + push)) % count; -		size_t n = prefix_length (to_wide (g.entries[o].filename), needle); -		matches += n == needle.size (); -		if (n > (size_t) best_n) { -			best = o; -			best_n = n; -		} -	} -	g.cursor = best; -	return matches; -} - -fun search_interactive (int push) { -	int matches = search (g.editor_line, push); +fun matches_to_editor_info (int matches) {  	if (g.editor_line.empty ())  		g.editor_info.clear ();  	else if (matches == 0) @@ -1032,6 +1149,56 @@ fun search_interactive (int push) {  		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; +		} +	} +	g.cursor = best; +} +  fun fix_cursor_and_offset () {  	g.cursor = min (g.cursor, int (g.entries.size ()) - 1);  	g.cursor = max (g.cursor, 0); @@ -1068,13 +1235,14 @@ fun relativize (string current, const string &path) -> string {  	return path;  } -fun pop_levels (const string& old_cwd) { +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 (); @@ -1088,7 +1256,7 @@ fun pop_levels (const string& old_cwd) {  	fix_cursor_and_offset ();  	if (!anchor.empty () && at_cursor ().filename != anchor) -		search (to_wide (anchor), 0); +		lookup (to_wide (anchor));  }  fun explode_path (const string &path, vector<string> &out) { @@ -1146,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]); @@ -1159,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) { @@ -1199,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);  	} @@ -1213,9 +1395,9 @@ fun choose (const entry &entry) {  // 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)); +	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) { @@ -1229,16 +1411,16 @@ fun handle_editor (wint_t c) {  			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 ()) +		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 = 0; @@ -1247,7 +1429,6 @@ fun handle_editor (wint_t c) {  		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: @@ -1257,13 +1438,13 @@ fun handle_editor (wint_t c) {  		g.editor_cursor = g.editor_line.length ();  		break;  	case ACTION_INPUT_BACKWARD: -		while (g.editor_cursor > 0 -			&& !move_towards_spacing (-1)) +		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)) +		while (g.editor_cursor < int (g.editor_line.length ()) && +			!move_towards_spacing (+1))  			;  		break;  	case ACTION_INPUT_B_DELETE: @@ -1281,6 +1462,17 @@ fun handle_editor (wint_t c) {  				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; @@ -1296,7 +1488,8 @@ fun handle_editor (wint_t c) {  		if (auto handler = g.editor_on[action]) {  			handler ();  		} else if (c & (ALT | SYM)) { -			beep (); +			if (c != KEY (RESIZE)) +				beep ();  		} else {  			g.editor_line.insert (g.editor_cursor, 1, c);  			g.editor_cursor++; @@ -1324,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: @@ -1352,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 (true); +		resort ();  		break;  	case ACTION_SORT_RIGHT:  		g.sort_column = (g.sort_column + entry::COLUMNS + 1) % entry::COLUMNS;  		g.sort_flash_ttl = 2; -		reload (true); +		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: @@ -1396,10 +1619,13 @@ 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 = [] { +		g.editor_on[ACTION_INPUT_CONFIRM] = [] {  			change_dir (untilde (to_mb (g.editor_line)));  		};  		break; @@ -1415,10 +1641,15 @@ fun handle (wint_t c) -> bool {  	case ACTION_SEARCH:  		g.editor = L"search"; -		g.editor_on_change       = [] { search_interactive (0); }; -		g.editor_on[ACTION_UP]   = [] { search_interactive (-1); }; -		g.editor_on[ACTION_DOWN] = [] { search_interactive (+1); }; -		g.editor_on_confirm      = [] { choose (at_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); @@ -1426,7 +1657,7 @@ fun handle (wint_t c) -> bool {  		// 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);  			if (rename (at_cursor ().filename.c_str (), mb.c_str ()))  				show_message (strerror (errno)); @@ -1435,10 +1666,12 @@ fun handle (wint_t c) -> bool {  		break;  	case ACTION_MKDIR:  		g.editor = L"mkdir"; -		g.editor_on_confirm = [] { -			if (mkdir (to_mb (g.editor_line).c_str (), 0777)) +		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; @@ -1447,7 +1680,7 @@ fun handle (wint_t c) -> bool {  		break;  	case ACTION_REVERSE_SORT:  		g.reverse_sort = !g.reverse_sort; -		reload (true); +		resort ();  		break;  	case ACTION_SHOW_HIDDEN:  		g.show_hidden = !g.show_hidden; @@ -1468,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 ();  } @@ -1544,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++) { @@ -1722,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 () { @@ -1773,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[]) { @@ -1802,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 (); @@ -1825,6 +2078,13 @@ int main (int argc, char *argv[]) {  	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) { @@ -1835,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) { @@ -1847,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); @@ -1859,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;  } | 
