From 2ac93c0e0eb1519028f488e81e4435280004d6a5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C5=99emysl=20Eric=20Janouch?=
Date: Fri, 24 Jul 2026 12:39:47 +0200
Subject: [PATCH] Allow unpacking multiple levels of directories
To get an overview despite hierarchies.
---
NEWS | 3 +
sdn.cpp | 169 ++++++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 137 insertions(+), 35 deletions(-)
diff --git a/NEWS b/NEWS
index aaada7c..c37a545 100644
--- a/NEWS
+++ b/NEWS
@@ -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.
diff --git a/sdn.cpp b/sdn.cpp
index 690f8d7..3779734 100644
--- a/sdn.cpp
+++ b/sdn.cpp
@@ -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 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 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> &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> 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 &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 {
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];