Allow unpacking multiple levels of directories
Alpine 3.24 Success
Alpine 3.24 aarch64 Success
Arch Linux AUR Success
OpenBSD 7.8 Success

To get an overview despite hierarchies.
This commit is contained in:
2026-07-24 12:39:47 +02:00
parent 28dcbd8a2c
commit 2ac93c0e0e
2 changed files with 137 additions and 35 deletions
+3
View File
@@ -2,6 +2,9 @@ Unreleased
* Added a configurable status bar template, also replacing the "cwd" attribute.
* Added ]/[/}/{ bindings to unpack multiple levels of subdirectories
into the listing as relative paths (subdir/file).
* Added C-b and C-f bindings for page up and page down navigation
in normal mode. The respective actions now move by visible lines
rather than by the full height of the terminal.
+134 -35
View File
@@ -472,7 +472,8 @@ enum { ALT = 1 << 24, SYM = 1 << 25 }; // Outside the range of Unicode
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(TOGGLE_FULL) XX(REVERSE_SORT) XX(SHOW_HIDDEN) \
XX(PACK) XX(UNPACK) XX(PACK_ALL) XX(UNPACK_ALL) XX(REDRAW) XX(RELOAD) \
XX(INPUT_ABORT) XX(INPUT_CONFIRM) XX(INPUT_B_DELETE) XX(INPUT_DELETE) \
XX(INPUT_B_KILL_WORD) XX(INPUT_B_KILL_LINE) XX(INPUT_KILL_LINE) \
XX(INPUT_QUOTED_INSERT) \
@@ -515,6 +516,8 @@ static map<Key, action> g_normal_actions {
{KEY (F (6)), ACTION_RENAME_PREFILL}, {KEY (F (7)), ACTION_MKDIR},
{ALT | 't', ACTION_TOGGLE_FULL},
{'R', ACTION_REVERSE_SORT}, {ALT | '.', ACTION_SHOW_HIDDEN},
{']', ACTION_UNPACK}, {'[', ACTION_PACK},
{'}', ACTION_UNPACK_ALL}, {'{', ACTION_PACK_ALL},
{CTRL ('L'), ACTION_REDRAW}, {'r', ACTION_RELOAD},
};
static map<Key, action> g_input_actions {
@@ -593,6 +596,7 @@ static struct {
bool reverse_sort; ///< Reverse sort
bool show_hidden; ///< Show hidden files
bool ext_helpers; ///< Launch helpers externally
int unpack_depth; ///< Nested listing depth (-1 = all)
int max_widths[entry::COLUMNS]; ///< Column widths
int sort_column = entry::FILENAME; ///< Sorting column
int sort_flash_ttl; ///< Sorting column flash TTL
@@ -701,8 +705,10 @@ fun ls_format (const entry &e, bool for_target) -> chtype {
if (x != g.ls_colors.end ())
format = x->second;
auto dot = name.find_last_of ('.');
if (dot != string::npos && type == LS_FILE) {
auto slash = name.rfind ('/');
auto base = slash == string::npos ? 0 : slash + 1;
auto dot = name.rfind ('.');
if (dot != string::npos && dot >= base && type == LS_FILE) {
const auto x = g.ls_exts.find (name.substr (++dot));
if (x != g.ls_exts.end ())
format = x->second;
@@ -729,15 +735,15 @@ fun suffixize (off_t size, unsigned shift, wchar_t suffix, std::wstring &out)
return false;
}
fun make_entry (const struct dirent *f) -> entry {
fun make_entry (const string &path, unsigned char d_type) -> entry {
entry e;
e.filename = f->d_name;
e.info.st_mode = DTTOIF (f->d_type);
e.filename = path;
e.info.st_mode = DTTOIF (d_type);
auto &info = e.info;
// 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)) {
if (lstat (path.c_str (), &info)) {
e.cols[entry::MODES] = apply_attrs ({ decode_type (info.st_mode),
L'?', L'?', L'?', L'?', L'?', L'?', L'?', L'?', L'?' }, 0);
@@ -751,13 +757,20 @@ fun make_entry (const struct dirent *f) -> entry {
if (S_ISLNK (info.st_mode)) {
char buf[PATH_MAX] = {};
auto len = readlink (f->d_name, buf, sizeof buf);
auto len = readlink (path.c_str (), buf, sizeof buf);
if (len < 0 || size_t (len) >= sizeof buf) {
e.target_path = "?";
} else {
e.target_path = buf;
// Resolve relative targets against the symlink's directory
string target = buf;
if (target[0] != '/') {
if (auto slash = path.rfind ('/'); slash != string::npos)
target = path.substr (0, slash + 1) + target;
}
// If a symlink links to another symlink, we follow all the way
(void) stat (buf, &e.target_info);
(void) stat (target.c_str (), &e.target_info);
}
}
@@ -766,7 +779,7 @@ fun make_entry (const struct dirent *f) -> entry {
// 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)
if (acl_extended_file_nofollow (path.c_str ()) > 0)
mode += L"+";
#endif
e.cols[entry::MODES] = apply_attrs (mode, 0);
@@ -869,6 +882,10 @@ fun update () {
}
auto bar = format_status (g.status);
if (g.unpack_depth < 0)
bar += apply_attrs (L" [/*]", 0);
else if (g.unpack_depth > 0)
bar += apply_attrs (L" [/" + to_wstring (g.unpack_depth) + L"]", 0);
if (!g.show_hidden)
bar += apply_attrs (L" (hidden)", 0);
if (g.out_of_date)
@@ -978,8 +995,35 @@ fun focus (const string &anchor) {
}
}
fun resort_emit (map<string, vector<entry>> &children, const string &parent)
-> void {
auto &kids = children[parent];
sort (begin (kids), end (kids));
for (auto &e : kids) {
auto name = e.filename;
g.entries.push_back (std::move (e));
if (children.count (name))
resort_emit (children, name);
}
}
fun resort_hierarchical () {
map<string, vector<entry>> children;
for (auto &e : g.entries) {
if (auto slash = e.filename.rfind ('/'); slash == string::npos)
children[""].push_back (std::move (e));
else
children[e.filename.substr (0, slash)].push_back (std::move (e));
}
g.entries.clear ();
resort_emit (children, "");
}
fun resort (const string anchor = at_cursor ().filename) {
sort (begin (g.entries), end (g.entries));
if (g.unpack_depth)
resort_hierarchical ();
else
sort (begin (g.entries), end (g.entries));
focus (anchor);
}
@@ -997,6 +1041,47 @@ fun filter_selection (const set<string> &selection) {
return reselection;
}
fun collect_subentries (const string &prefix, int depth) {
auto dir = opendir (prefix.c_str ());
if (!dir)
return;
while (auto f = readdir (dir)) {
string name = f->d_name;
if (name == "." || name == ".." || (name[0] == '.' && !g.show_hidden))
continue;
string path = prefix + "/" + name;
g.entries.push_back (make_entry (path, f->d_type));
if (depth && S_ISDIR (g.entries.back ().info.st_mode))
collect_subentries (path, depth - (depth > 0));
}
closedir (dir);
}
fun collect_entries (int depth) {
auto dir = opendir (".");
if (!dir) {
show_message (strerror (errno));
if (g.cwd != "/")
g.entries.push_back (make_entry ("..", DT_DIR));
return;
}
while (auto f = readdir (dir)) {
string name = f->d_name;
// Two dots are for navigation but this ain't as useful
if (name == ".")
continue;
if (name == ".." ? g.cwd == "/" : (name[0] == '.' && !g.show_hidden))
continue;
g.entries.push_back (make_entry (name, f->d_type));
if (depth && name != ".." && S_ISDIR (g.entries.back ().info.st_mode))
collect_subentries (name, depth - (depth > 0));
}
closedir (dir);
}
fun reload (bool keep_anchor) {
g.unames.clear ();
while (auto *ent = getpwent ())
@@ -1013,31 +1098,11 @@ fun reload (bool keep_anchor) {
anchor = at_cursor ().filename;
auto now = time (NULL); g.now = *localtime (&now);
auto dir = opendir (".");
g.entries.clear ();
if (!dir) {
show_message (strerror (errno));
if (g.cwd != "/") {
struct dirent f = {};
strncpy (f.d_name, "..", sizeof f.d_name);
f.d_type = DT_DIR;
g.entries.push_back (make_entry (&f));
}
goto readfail;
}
while (auto f = readdir (dir)) {
string name = f->d_name;
// Two dots are for navigation but this ain't as useful
if (name == ".")
continue;
if (name == ".." ? g.cwd != "/" : (name[0] != '.' || g.show_hidden))
g.entries.push_back (make_entry (f));
}
closedir (dir);
collect_entries (g.unpack_depth);
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;
@@ -1250,8 +1315,10 @@ fun select_matches (bool dotdot) -> set<string> {
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))
auto base = e.filename.c_str ();
if (auto slash = e.filename.rfind ('/'); slash != string::npos)
base += slash + 1;
if (!fnmatch (to_mb (g.editor_line).c_str (), base, FNM_PATHNAME))
matches.insert (e.filename);
}
return matches;
@@ -1407,8 +1474,10 @@ fun change_dir (const string &path) {
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)
if (!same_path) {
g.unpack_depth = 0;
g.selection.clear ();
}
reload (same_path);
@@ -1769,6 +1838,30 @@ fun handle (Key k) -> bool {
g.show_hidden = !g.show_hidden;
reload (true);
break;
case ACTION_PACK:
if (g.unpack_depth == 0)
break;
g.unpack_depth = max (0, g.unpack_depth - 1);
reload (true);
break;
case ACTION_UNPACK:
if (g.unpack_depth < 0)
break;
g.unpack_depth++;
reload (true);
break;
case ACTION_PACK_ALL:
if (!g.unpack_depth)
break;
g.unpack_depth = 0;
reload (true);
break;
case ACTION_UNPACK_ALL:
if (g.unpack_depth < 0)
break;
g.unpack_depth = -1;
reload (true);
break;
case ACTION_REDRAW:
clear ();
break;
@@ -2082,11 +2175,16 @@ fun load_config () {
g.ext_helpers = tokens.at (1) == "1";
else if (tokens.front () == "sort-column" && tokens.size () > 1)
g.sort_column = stoi (tokens.at (1));
else if (tokens.front () == "unpack-depth" && tokens.size () > 1)
g.unpack_depth = stoi (tokens.at (1));
else if (tokens.front () == "status" && tokens.size () > 1)
g.status = to_wide (tokens.at (1));
else if (tokens.front () == "history")
load_history_level (tokens);
}
// FIXME: we don't reset unpack_depth when the last history level
// doesn't match the start directory.
}
fun save_config () {
@@ -2101,6 +2199,7 @@ fun save_config () {
write_line (*config, {"ext-helpers", g.ext_helpers ? "1" : "0"});
write_line (*config, {"sort-column", to_string (g.sort_column)});
write_line (*config, {"unpack-depth", to_string (g.unpack_depth)});
write_line (*config, {"status", to_mb (g.status)});
char hostname[256];