diff options
Diffstat (limited to 'sdn.cpp')
-rw-r--r-- | sdn.cpp | 622 |
1 files changed, 443 insertions, 179 deletions
@@ -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; } |