aboutsummaryrefslogtreecommitdiff
path: root/sfm.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'sfm.cpp')
-rw-r--r--sfm.cpp2374
1 files changed, 2374 insertions, 0 deletions
diff --git a/sfm.cpp b/sfm.cpp
new file mode 100644
index 0000000..9913b62
--- /dev/null
+++ b/sfm.cpp
@@ -0,0 +1,2374 @@
+// sfm - Simple File Manager in C++17 with ncurses
+// Rewrite of the POSIX sh original for speed and efficiency
+#include <ncurses.h>
+
+#include <algorithm>
+#include <cerrno>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <ctime>
+#include <filesystem>
+#include <fstream>
+#include <string>
+#include <unordered_set>
+#include <vector>
+
+#include <dirent.h>
+#include <fcntl.h>
+#include <grp.h>
+#include <pwd.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+namespace fs = std::filesystem;
+
+// ─── terminal helpers ───────────────────────────────────────────────────────
+[[nodiscard]] bool can_color() { return has_colors(); }
+
+// Color pair indices
+enum {
+ CP_DIR = 1,
+ CP_VALID_SYMLINK,
+ CP_BROKEN_SYMLINK,
+ CP_EXECUTABLE,
+ CP_NORMAL,
+ CP_TOPBAR,
+ CP_DIVIDER,
+ CP_MARKER,
+ CP_INFO,
+ CP_OVERLAY_BORDER,
+ CP_OVERLAY_TITLE,
+ CP_BUTTON_YES,
+ CP_BUTTON_NO,
+};
+
+#define ATTR_DIR (COLOR_PAIR(CP_DIR) | A_BOLD)
+#define ATTR_VALID_SYMLINK (COLOR_PAIR(CP_VALID_SYMLINK) | A_BOLD)
+#define ATTR_BROKEN_SYMLINK (COLOR_PAIR(CP_BROKEN_SYMLINK) | A_BOLD)
+#define ATTR_EXECUTABLE (COLOR_PAIR(CP_EXECUTABLE) | A_BOLD)
+#define ATTR_NORMAL COLOR_PAIR(CP_NORMAL)
+#define ATTR_TOPBAR (COLOR_PAIR(CP_TOPBAR) | A_BOLD)
+#define ATTR_DIVIDER COLOR_PAIR(CP_DIVIDER)
+#define ATTR_MARKER COLOR_PAIR(CP_MARKER)
+#define ATTR_INFO COLOR_PAIR(CP_INFO)
+#define ATTR_OVERLAY_BORDER COLOR_PAIR(CP_OVERLAY_BORDER)
+
+// ─── Entry data ─────────────────────────────────────────────────────────────
+enum class EntryType { Directory, File, Symlink };
+
+struct Entry {
+ std::string name; // display name (dirs end with '/', symlinks with '@')
+ std::string full_path; // absolute canonical path
+ EntryType type = EntryType::File;
+ bool is_hidden = false;
+ bool is_executable = false;
+ off_t size = 0;
+ timespec mtime{};
+ std::string symlink_target; // empty unless type == Symlink
+ std::string permissions; // e.g. "rwxr-xr-x"
+ std::string owner;
+ std::string group;
+};
+
+// ─── utilities ──────────────────────────────────────────────────────────────
+static std::string home_dir() {
+ const char *h = getenv("HOME");
+ return h ? h : "/";
+}
+
+static std::string config_dir() {
+ const char *xdg = getenv("XDG_CONFIG_HOME");
+ return xdg ? std::string(xdg) + "/sfm" : home_dir() + "/.config/sfm";
+}
+
+static std::string data_dir() {
+ const char *xdg = getenv("XDG_DATA_HOME");
+ return xdg ? std::string(xdg) + "/sfm-trash" : home_dir() + "/.local/share/sfm-trash";
+}
+
+// Join CWD (already absolute) with a name, avoiding double-slash at root.
+static std::string join_path(const std::string &cwd, const std::string &name) {
+ if (cwd == "/") return "/" + name;
+ return cwd + "/" + name;
+}
+
+// Escape a string for safe use in a shell command (single-quote wrapping).
+static std::string shell_escape(const std::string &s) {
+ std::string r = "'";
+ for (char c : s) {
+ if (c == '\'') r += "'\\''";
+ else r += c;
+ }
+ r += "'";
+ return r;
+}
+
+// Lowercase a string (ASCII only — fast path for extension matching).
+static std::string lower(const std::string &s) {
+ std::string r = s;
+ for (auto &c : r) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
+ return r;
+}
+
+// Get file extension (lowercase, without dot) — empty if none.
+static std::string file_ext(const std::string &name) {
+ auto pos = name.rfind('.');
+ if (pos == std::string::npos || pos == 0) return "";
+ return lower(name.substr(pos + 1));
+}
+
+// Strip a suffix from a string (e.g., "foo/" -> "foo").
+static std::string strip_suffix(std::string s, const char *suf) {
+ size_t len = std::strlen(suf);
+ if (s.size() >= len && s.compare(s.size() - len, len, suf) == 0)
+ s.erase(s.size() - len);
+ return s;
+}
+
+// Forward — defined before draw_preview, used by render_row too.
+static std::string sanitize_display(const std::string &s);
+
+// ─── FileManager ────────────────────────────────────────────────────────────
+class FileManager {
+public:
+ FileManager(const std::string &start_path);
+ ~FileManager();
+ void run();
+
+private:
+ // ── state ──────────────────────────────────────────────────────────────
+ std::string cwd_;
+ std::string prev_cwd_; // for backtick toggle
+ std::string last_child_; // name (with trailing /) of dir we came from
+
+ std::vector<Entry> all_entries_; // source of truth (unfiltered)
+ std::vector<Entry> entries_; // filtered + sorted display list
+
+ int sel_ = 0, offset_ = 0;
+ int prev_sel_ = -1, prev_offset_ = -1;
+ bool need_full_redraw_ = true;
+
+ bool show_hidden_ = false;
+ bool show_details_ = false;
+ bool show_preview_ = false;
+ bool searching_ = false;
+ std::string filter_;
+ std::string info_msg_;
+
+ enum class SortMode { Name, Size, Date } sort_mode_ = SortMode::Name;
+
+ std::unordered_set<std::string> selected_; // entry display names
+ std::vector<std::string> clipboard_paths_; // absolute paths
+ enum class ClipMode { None, Copy, Cut } clip_mode_ = ClipMode::None;
+
+ int rows_ = 0, cols_ = 0;
+ int list_cols_ = 0, prev_col_ = 0, prev_width_ = 0;
+
+ std::string bookmark_file_;
+ std::string trash_dir_;
+ std::string opener_path_;
+
+ // ── terminal ───────────────────────────────────────────────────────────
+ void setup_term();
+ void restore_term();
+ void update_size();
+ void init_colors();
+
+ // ── directory loading ──────────────────────────────────────────────────
+ void load_entries();
+ void apply_filter();
+ void sort_entries();
+ int find_entry_idx(const std::string &name) const;
+ const Entry* get_sel() const;
+
+ // ── rendering ──────────────────────────────────────────────────────────
+ void draw();
+ void draw_topbar();
+ void draw_botbar();
+ void draw_file_list();
+ void draw_preview();
+ void render_row(int row, int idx, bool selected, int cols);
+
+ // ── overlays ───────────────────────────────────────────────────────────
+ bool confirm_overlay(const std::string &prompt);
+ std::string overlay_picker(const std::string &title,
+ const std::vector<std::string> &items,
+ bool allow_cancel = true);
+
+ // ── line input ─────────────────────────────────────────────────────────
+ bool read_line(std::string &out, const std::string &prompt,
+ const std::string &initial = "");
+
+ // ── actions ────────────────────────────────────────────────────────────
+ void do_open();
+ void do_go_back();
+ void do_go_home();
+ void do_jump_back();
+ void do_jump_path();
+ void do_search();
+ void do_clear_filter();
+ void do_toggle_hidden();
+ void do_toggle_details();
+ void do_toggle_preview();
+ void do_sort();
+ void do_info();
+ void do_help();
+ void do_rename();
+ void do_mkdir();
+ void do_newfile();
+ void do_delete();
+ void do_trash();
+ void do_open_trash();
+ void do_open_with();
+ void do_chmod_x(bool set);
+ void do_find();
+ void do_bookmark_add();
+ void do_bookmark_jump();
+ void do_copy_path();
+ void do_toggle_select();
+ void do_select_all();
+ void do_yank();
+ void do_cut();
+ void do_paste();
+ void do_shell();
+
+ // ── helpers ────────────────────────────────────────────────────────────
+ void open_file(const std::string &path);
+ void run_tty(const char *cmd, char *const argv[]);
+ void run_gui(const char *cmd, char *const argv[]);
+ bool try_run(const std::vector<const char *> &progs, const std::string &path,
+ bool gui);
+ void change_dir(const std::string &path);
+ void flash_msg(const std::string &msg) { info_msg_ = msg; need_full_redraw_ = true; }
+};
+
+// ─── constructor / destructor ───────────────────────────────────────────────
+FileManager::FileManager(const std::string &start_path) {
+ // Derive config/data paths early before ncurses takes over
+ bookmark_file_ = config_dir() + "/bookmarks";
+ trash_dir_ = data_dir();
+ opener_path_ = config_dir() + "/opener";
+
+ // Ensure config and trash directories exist
+ std::error_code ec;
+ fs::create_directories(config_dir(), ec);
+ fs::create_directories(trash_dir_, ec);
+ // Touch bookmark file if missing
+ if (!fs::exists(bookmark_file_)) {
+ std::ofstream(bookmark_file_, std::ios::app).close();
+ }
+
+ // Resolve starting directory
+ if (!start_path.empty()) {
+ std::error_code ec2;
+ cwd_ = fs::canonical(start_path, ec2);
+ if (ec2) {
+ std::fprintf(stderr, "sfm: %s: no such directory\n", start_path.c_str());
+ std::exit(1);
+ }
+ } else {
+ cwd_ = fs::current_path();
+ }
+}
+
+FileManager::~FileManager() {
+ restore_term();
+}
+
+// ─── terminal setup / teardown ──────────────────────────────────────────────
+void FileManager::setup_term() {
+ initscr();
+ raw();
+ noecho();
+ keypad(stdscr, TRUE);
+ set_escdelay(25); // minimal ESC delay — still catches arrow keys
+ curs_set(0);
+ leaveok(stdscr, TRUE);
+ if (can_color()) init_colors();
+ update_size();
+}
+
+void FileManager::restore_term() {
+ if (!isendwin()) {
+ curs_set(1);
+ endwin();
+ }
+}
+
+void FileManager::update_size() {
+ rows_ = getmaxy(stdscr);
+ cols_ = getmaxx(stdscr);
+ if (rows_ < 1) rows_ = 24;
+ if (cols_ < 1) cols_ = 80;
+}
+
+void FileManager::init_colors() {
+ start_color();
+ use_default_colors(); // enables -1 as "default bg" in init_pair
+
+ init_pair(CP_DIR, COLOR_BLUE, -1);
+ init_pair(CP_VALID_SYMLINK, COLOR_CYAN, -1);
+ init_pair(CP_BROKEN_SYMLINK, COLOR_RED, -1);
+ init_pair(CP_EXECUTABLE, COLOR_GREEN, -1);
+ init_pair(CP_NORMAL, COLOR_WHITE, -1);
+ init_pair(CP_TOPBAR, COLOR_CYAN, -1);
+ init_pair(CP_DIVIDER, COLOR_CYAN, -1);
+ init_pair(CP_MARKER, COLOR_YELLOW, -1);
+ init_pair(CP_INFO, COLOR_YELLOW, -1);
+ init_pair(CP_OVERLAY_BORDER, COLOR_CYAN, -1);
+ init_pair(CP_OVERLAY_TITLE, COLOR_CYAN, -1);
+ init_pair(CP_BUTTON_YES, COLOR_GREEN, -1);
+ init_pair(CP_BUTTON_NO, COLOR_RED, -1);
+}
+
+// ─── directory loading ──────────────────────────────────────────────────────
+void FileManager::load_entries() {
+ all_entries_.clear();
+
+ DIR *dir = opendir(cwd_.c_str());
+ if (!dir) {
+ flash_msg("cannot open directory");
+ // fall back to parent if possible
+ if (cwd_ != "/") {
+ cwd_ = fs::path(cwd_).parent_path();
+ load_entries();
+ }
+ return;
+ }
+
+ struct dirent *de;
+ while ((de = readdir(dir)) != nullptr) {
+ std::string name(de->d_name);
+ if (name == ".") continue;
+
+ bool hidden = (name[0] == '.');
+ if (!show_hidden_ && hidden) continue;
+
+ Entry e;
+ e.name = name;
+ e.full_path = join_path(cwd_, name);
+ e.is_hidden = hidden;
+
+ struct stat st;
+ if (lstat(e.full_path.c_str(), &st) != 0) continue;
+
+ if (S_ISDIR(st.st_mode)) {
+ e.type = EntryType::Directory;
+ e.name += '/';
+ } else if (S_ISLNK(st.st_mode)) {
+ e.type = EntryType::Symlink;
+ e.name += '@';
+ char buf[PATH_MAX];
+ ssize_t len = readlink(e.full_path.c_str(), buf, sizeof(buf) - 1);
+ if (len > 0) { buf[len] = '\0'; e.symlink_target = buf; }
+ } else {
+ e.type = EntryType::File;
+ }
+
+ e.size = st.st_size;
+ e.mtime = st.st_mtim;
+ e.is_executable = (st.st_mode & (S_IXUSR | S_IXGRP | S_IXOTH)) != 0;
+
+ // Permissions string
+ char perm[11] = "----------";
+ if (S_ISDIR(st.st_mode)) perm[0] = 'd';
+ if (S_ISLNK(st.st_mode)) perm[0] = 'l';
+ if (st.st_mode & S_IRUSR) perm[1] = 'r';
+ if (st.st_mode & S_IWUSR) perm[2] = 'w';
+ if (st.st_mode & S_IXUSR) perm[3] = 'x';
+ if (st.st_mode & S_IRGRP) perm[4] = 'r';
+ if (st.st_mode & S_IWGRP) perm[5] = 'w';
+ if (st.st_mode & S_IXGRP) perm[6] = 'x';
+ if (st.st_mode & S_IROTH) perm[7] = 'r';
+ if (st.st_mode & S_IWOTH) perm[8] = 'w';
+ if (st.st_mode & S_IXOTH) perm[9] = 'x';
+ e.permissions = perm;
+
+ // Owner / group
+ struct passwd *pw = getpwuid(st.st_uid);
+ e.owner = pw ? pw->pw_name : std::to_string(st.st_uid);
+ struct group *gr = getgrgid(st.st_gid);
+ e.group = gr ? gr->gr_name : std::to_string(st.st_gid);
+
+ all_entries_.push_back(std::move(e));
+ }
+ closedir(dir);
+
+ sort_entries();
+ apply_filter();
+ need_full_redraw_ = true;
+}
+
+void FileManager::sort_entries() {
+ std::stable_sort(all_entries_.begin(), all_entries_.end(),
+ [this](const Entry &a, const Entry &b) {
+ // Hidden items first (when shown), then dirs, then files
+ if (a.is_hidden != b.is_hidden)
+ return a.is_hidden; // hidden first
+ if (a.type != b.type) {
+ if (a.type == EntryType::Directory) return true;
+ if (b.type == EntryType::Directory) return false;
+ }
+
+ switch (sort_mode_) {
+ case SortMode::Name:
+ return strcoll(a.name.c_str(), b.name.c_str()) < 0;
+ case SortMode::Size:
+ if (a.size != b.size) return a.size > b.size;
+ return strcoll(a.name.c_str(), b.name.c_str()) < 0;
+ case SortMode::Date:
+ if (a.mtime.tv_sec != b.mtime.tv_sec)
+ return a.mtime.tv_sec > b.mtime.tv_sec;
+ if (a.mtime.tv_nsec != b.mtime.tv_nsec)
+ return a.mtime.tv_nsec > b.mtime.tv_nsec;
+ return strcoll(a.name.c_str(), b.name.c_str()) < 0;
+ }
+ return false;
+ });
+}
+
+void FileManager::apply_filter() {
+ entries_.clear();
+ if (filter_.empty()) {
+ entries_ = all_entries_;
+ } else {
+ for (const auto &e : all_entries_) {
+ if (e.name.find(filter_) != std::string::npos)
+ entries_.push_back(e);
+ }
+ }
+}
+
+int FileManager::find_entry_idx(const std::string &name) const {
+ for (size_t i = 0; i < entries_.size(); ++i) {
+ if (entries_[i].name == name) return static_cast<int>(i);
+ }
+ return -1;
+}
+
+const Entry* FileManager::get_sel() const {
+ if (entries_.empty() || sel_ < 0 || sel_ >= static_cast<int>(entries_.size()))
+ return nullptr;
+ return &entries_[sel_];
+}
+
+// ─── rendering ──────────────────────────────────────────────────────────────
+void FileManager::draw() {
+ update_size();
+
+ // Layout calculation
+ if (show_preview_) {
+ list_cols_ = cols_ / 2;
+ prev_col_ = list_cols_ + 2;
+ prev_width_ = cols_ - list_cols_ - 2;
+ } else {
+ list_cols_ = cols_;
+ prev_width_ = 0;
+ prev_col_ = 0;
+ }
+
+ int list_rows = rows_ - 2; // rows 0 = topbar, rows-1 = botbar
+ if (list_rows < 1) list_rows = 1;
+
+ // Clamp selection
+ if (sel_ < 0) sel_ = 0;
+ if (!entries_.empty() && sel_ >= static_cast<int>(entries_.size()))
+ sel_ = static_cast<int>(entries_.size()) - 1;
+
+ // Clamp scroll offset
+ if (sel_ < offset_) offset_ = sel_;
+ else if (sel_ >= offset_ + list_rows) offset_ = sel_ - list_rows + 1;
+ if (offset_ < 0) offset_ = 0;
+
+ // Detect offset change
+ if (offset_ != prev_offset_) need_full_redraw_ = true;
+
+ if (need_full_redraw_) {
+ erase();
+ draw_topbar();
+ draw_file_list();
+ if (show_preview_) draw_preview();
+ draw_botbar();
+ } else if (sel_ != prev_sel_ || offset_ != prev_offset_) {
+ // Fast path: only re-render the two changed rows
+ int prev_row = (prev_sel_ - offset_) + 1;
+ int curr_row = (sel_ - offset_) + 1;
+
+ // Un-highlight old row
+ if (prev_row >= 1 && prev_row <= list_rows) {
+ int pidx = prev_sel_ - offset_;
+ if (pidx >= 0 && pidx < offset_ + list_rows && pidx < static_cast<int>(entries_.size()))
+ render_row(prev_row, prev_sel_, false, list_cols_);
+ }
+ // Highlight new row
+ if (curr_row >= 1 && curr_row <= list_rows) {
+ render_row(curr_row, sel_, true, list_cols_);
+ }
+
+ if (show_preview_) draw_preview();
+ draw_botbar();
+ }
+
+ prev_sel_ = sel_;
+ prev_offset_ = offset_;
+ need_full_redraw_ = false;
+
+ refresh();
+}
+
+void FileManager::draw_topbar() {
+ attron(ATTR_TOPBAR);
+ for (int c = 0; c < cols_; ++c) mvaddch(0, c, ' ');
+ attroff(ATTR_TOPBAR);
+}
+
+void FileManager::draw_botbar() {
+ std::string left, right;
+
+ if (searching_) {
+ left = " " + std::to_string(entries_.empty() ? 0 : sel_ + 1) + "/"
+ + std::to_string(entries_.size()) + " " + cwd_;
+ right = " search: " + filter_ + " ";
+ } else if (!info_msg_.empty()) {
+ left = " " + std::to_string(entries_.empty() ? 0 : sel_ + 1) + "/"
+ + std::to_string(entries_.size()) + " " + cwd_;
+ right = " " + info_msg_ + " ";
+ info_msg_.clear();
+ } else {
+ std::string indicators;
+ if (clip_mode_ == ClipMode::Copy) indicators += " [copy]";
+ else if (clip_mode_ == ClipMode::Cut) indicators += " [cut]";
+ auto sc = selected_.size();
+ if (sc > 0) indicators += " [sel:" + std::to_string(sc) + "]";
+ if (show_hidden_) indicators += " [hidden]";
+ if (sort_mode_ != SortMode::Name) {
+ indicators += " [sort:";
+ indicators += (sort_mode_ == SortMode::Size) ? "size]" : "date]";
+ }
+ if (show_details_) indicators += " [details]";
+
+ left = " " + std::to_string(entries_.empty() ? 0 : sel_ + 1) + "/"
+ + std::to_string(entries_.size()) + " " + cwd_ + indicators;
+ right = " press ? for help ";
+ }
+
+ int total = static_cast<int>(left.size() + right.size());
+ int pad = cols_ - total;
+ if (pad < 0) pad = 0;
+
+ attron(ATTR_TOPBAR);
+ mvprintw(rows_ - 1, 0, "%s", left.c_str());
+ for (int i = 0; i < pad; ++i) mvaddch(rows_ - 1, static_cast<int>(left.size()) + i, ' ');
+ attroff(ATTR_TOPBAR);
+
+ attron(ATTR_INFO);
+ mvprintw(rows_ - 1, cols_ - static_cast<int>(right.size()), "%s", right.c_str());
+ attroff(ATTR_INFO);
+}
+
+void FileManager::draw_file_list() {
+ int list_rows = rows_ - 2;
+ int idx = offset_;
+ for (int row = 1; row <= list_rows && idx < static_cast<int>(entries_.size()); ++row, ++idx) {
+ render_row(row, idx, idx == sel_, list_cols_);
+ }
+ // Show "(empty)" if no entries
+ if (entries_.empty()) {
+ attron(ATTR_NORMAL);
+ mvaddstr(1, 0, " (empty)");
+ attroff(ATTR_NORMAL);
+ }
+}
+
+void FileManager::render_row(int row, int idx, bool highlighted, int cols) {
+ if (idx < 0 || idx >= static_cast<int>(entries_.size())) return;
+ const Entry &e = entries_[idx];
+
+ // Determine colour+attribute based on type
+ attr_t attr = ATTR_NORMAL;
+ if (e.type == EntryType::Directory) attr = ATTR_DIR;
+ else if (e.type == EntryType::Symlink) {
+ // Check if broken
+ struct stat dummy;
+ if (stat(e.full_path.c_str(), &dummy) == 0)
+ attr = ATTR_VALID_SYMLINK;
+ else
+ attr = ATTR_BROKEN_SYMLINK;
+ } else if (e.is_executable) attr = ATTR_EXECUTABLE;
+
+ if (highlighted) attr |= A_REVERSE;
+
+ // Build detail string (if enabled)
+ std::string detail;
+ if (show_details_) {
+ char buf[64];
+ // Size
+ double sz = static_cast<double>(e.size);
+ const char *units = "B";
+ if (sz >= 1024) { sz /= 1024; units = "K"; }
+ if (sz >= 1024) { sz /= 1024; units = "M"; }
+ if (sz >= 1024) { sz /= 1024; units = "G"; }
+ // Date
+ char date_buf[32];
+ struct tm tm_val;
+ localtime_r(&e.mtime.tv_sec, &tm_val);
+ std::strftime(date_buf, sizeof(date_buf), "%b %d %H:%M", &tm_val);
+
+ std::snprintf(buf, sizeof(buf), " %5.0f%s %s", sz, units, date_buf);
+ detail = buf;
+ }
+
+ // Build display name (sanitize — filenames can contain control chars)
+ std::string display;
+ if (e.type == EntryType::Symlink && !e.symlink_target.empty()) {
+ display = sanitize_display(e.name + " -> " + e.symlink_target);
+ struct stat _st;
+ if (stat(e.full_path.c_str(), &_st) != 0)
+ display += " [broken]";
+ } else {
+ display = sanitize_display(e.name);
+ }
+
+ // Multi-select marker
+ bool marked = selected_.count(e.name);
+ char marker = marked ? '*' : ' ';
+
+ // Calculate available width
+ int dlen = static_cast<int>(detail.size());
+ int maxw = cols - 2 - dlen;
+ if (maxw < 4) maxw = 4;
+
+ // Truncate display name if needed
+ std::string show = display;
+ if (static_cast<int>(show.size()) > maxw) {
+ show.resize(maxw > 3 ? maxw - 3 : 0);
+ show += "...";
+ }
+
+ int name_w = static_cast<int>(show.size()) + 1; // +1 for marker
+ int pad = cols - name_w - dlen;
+
+ // Truncate detail if it overflows past cols (can happen with narrow
+ // preview pane + detail mode)
+ std::string detail_str;
+ int dlen_actual = 0;
+ if (!detail.empty()) {
+ if (pad < 0) {
+ int avail = cols - name_w;
+ if (avail > 3) {
+ detail_str = detail.substr(0, avail - 3) + "...";
+ } else if (avail > 0) {
+ detail_str = detail.substr(0, avail);
+ }
+ dlen_actual = static_cast<int>(detail_str.size());
+ pad = cols - name_w - dlen_actual;
+ } else {
+ detail_str = detail;
+ dlen_actual = dlen;
+ }
+ if (pad < 0) pad = 0;
+ }
+
+ if (marked) {
+ attron(ATTR_MARKER);
+ mvaddch(row, 0, marker);
+ attroff(ATTR_MARKER);
+ } else {
+ mvaddch(row, 0, marker);
+ }
+
+ attron(attr);
+ mvaddstr(row, 1, show.c_str());
+ for (int p = 0; p < pad; ++p)
+ mvaddch(row, 1 + static_cast<int>(show.size()) + p, ' ');
+ if (!detail_str.empty()) {
+ attron(attr & ~A_REVERSE);
+ if (highlighted) attron(A_REVERSE);
+ mvaddstr(row, 1 + static_cast<int>(show.size()) + pad, detail_str.c_str());
+ }
+ attroff(attr);
+}
+
+// Allow only printable ASCII (32–126) and tab; replace everything else with '.'.
+// This keeps ANSI escapes, control chars, and high bytes (>127) from reaching
+// the terminal and corrupting the display.
+static std::string sanitize_display(const std::string &s) {
+ std::string r;
+ r.reserve(s.size());
+ for (size_t i = 0; i < s.size(); ++i) {
+ unsigned char c = static_cast<unsigned char>(s[i]);
+ if (c >= 32 && c < 127) { r += s[i]; continue; }
+ if (c == '\t') { r += ' '; continue; }
+ r += '.';
+ }
+ return r;
+}
+
+void FileManager::draw_preview() {
+ const Entry *e = get_sel();
+ if (!e) return;
+
+ // Draw vertical divider
+ for (int r = 1; r < rows_ - 1; ++r) {
+ attron(ATTR_DIVIDER);
+ mvaddch(r, list_cols_, '|');
+ attroff(ATTR_DIVIDER);
+ }
+
+ // Clear entire preview area of previous content (stale lines from last preview)
+ for (int r = 1; r < rows_ - 1; ++r) {
+ move(r, prev_col_);
+ clrtoeol();
+ }
+
+ int px = prev_col_;
+ int pw = prev_width_;
+ int max_lines = rows_ - 3;
+
+ // Resolve real path for symlinks/regular files
+ std::string real_path = e->full_path;
+
+ switch (e->type) {
+ case EntryType::Directory: {
+ // Collect and sort: subdirs first, then files
+ std::vector<std::string> subdirs, files;
+ std::error_code ec;
+ for (const auto &de : fs::directory_iterator(real_path, ec)) {
+ std::string n = de.path().filename().string();
+ if (n.empty()) continue;
+ if (de.is_directory()) {
+ subdirs.push_back(n + "/");
+ } else {
+ files.push_back(n);
+ }
+ }
+ std::sort(subdirs.begin(), subdirs.end());
+ std::sort(files.begin(), files.end());
+
+ int line = 0;
+ for (const auto &n : subdirs) {
+ if (line >= max_lines) break;
+ std::string show = n;
+ if (static_cast<int>(show.size()) > pw - 1)
+ show = show.substr(0, pw - 2) + "~";
+ attron(ATTR_DIR);
+ mvprintw(1 + line, px, " %s", sanitize_display(show).c_str());
+ attroff(ATTR_DIR);
+ line++;
+ }
+ for (const auto &n : files) {
+ if (line >= max_lines) break;
+ std::string show = n;
+ if (static_cast<int>(show.size()) > pw - 1)
+ show = show.substr(0, pw - 2) + "~";
+ attron(ATTR_NORMAL);
+ mvprintw(1 + line, px, " %s", sanitize_display(show).c_str());
+ attroff(ATTR_NORMAL);
+ line++;
+ }
+ if (line == 0) {
+ attron(ATTR_NORMAL);
+ mvprintw(1, px, " (empty)");
+ attroff(ATTR_NORMAL);
+ }
+ break;
+ }
+ case EntryType::Symlink: {
+ attron(ATTR_VALID_SYMLINK);
+ mvprintw(1, px, " [symlink]");
+ attroff(ATTR_VALID_SYMLINK);
+ attron(ATTR_NORMAL);
+ mvprintw(2, px, " -> %s", sanitize_display(e->symlink_target).c_str());
+ attroff(ATTR_NORMAL);
+ break;
+ }
+ case EntryType::File: {
+ // Detect if text file
+ bool is_text = false;
+ std::string ext = file_ext(e->name);
+
+ // Extension whitelist
+ static const std::unordered_set<std::string> text_exts = {
+ "txt","md","markdown","rst","log","conf","cfg","ini","toml","yaml","yml",
+ "sh","bash","zsh","fish","py","rb","pl","lua","js","ts","jsx","tsx","json",
+ "xml","html","htm","css","scss","sass","c","h","cpp","cc","cxx","hpp","rs",
+ "go","java","kt","swift","cs","php","r","sql","vim","diff","patch",
+ "makefile","dockerfile","gitignore","env","lock","mod","sum","csv","tsv"
+ };
+ if (text_exts.count(ext)) {
+ is_text = true;
+ } else {
+ // Fallback: check with `file` command
+ std::string cmd = "file --mime-type -b " + shell_escape(real_path) + " 2>/dev/null";
+ FILE *fp = popen(cmd.c_str(), "r");
+ if (fp) {
+ char buf[256];
+ if (fgets(buf, sizeof(buf), fp)) {
+ std::string mime(buf);
+ if (mime.find("text/") == 0 || mime.find("application/json") == 0
+ || mime.find("application/xml") == 0
+ || mime.find("application/javascript") == 0)
+ is_text = true;
+ }
+ pclose(fp);
+ }
+ }
+
+ if (is_text) {
+ // Show file contents
+ std::ifstream f(real_path);
+ if (f.is_open()) {
+ std::string line_str;
+ int line_no = 0;
+ while (line_no < max_lines && std::getline(f, line_str)) {
+ if (static_cast<int>(line_str.size()) > pw - 1)
+ line_str = line_str.substr(0, pw - 2) + "~";
+ attron(ATTR_NORMAL);
+ mvprintw(1 + line_no, px, " %s", sanitize_display(line_str).c_str());
+ attroff(ATTR_NORMAL);
+ line_no++;
+ }
+ if (line_no == 0) {
+ attron(ATTR_NORMAL);
+ mvprintw(1, px, " (empty file)");
+ attroff(ATTR_NORMAL);
+ }
+ } else {
+ attron(ATTR_NORMAL);
+ mvprintw(1, px, " (cannot read file)");
+ attroff(ATTR_NORMAL);
+ }
+ } else {
+ // Binary file — show size
+ double sz = static_cast<double>(e->size);
+ const char *units = "B";
+ if (sz >= 1024) { sz /= 1024; units = "K"; }
+ if (sz >= 1024) { sz /= 1024; units = "M"; }
+ if (sz >= 1024) { sz /= 1024; units = "G"; }
+ attron(ATTR_INFO);
+ mvprintw(1, px, " [binary] %.0f %s", sz, units);
+ attroff(ATTR_INFO);
+ }
+ break;
+ }
+ }
+}
+
+// ─── overlays ───────────────────────────────────────────────────────────────
+bool FileManager::confirm_overlay(const std::string &prompt) {
+ int bw = static_cast<int>(prompt.size()) + 10;
+ if (bw < 32) bw = 32;
+ if (bw > cols_ - 4) bw = cols_ - 4;
+ int bh = 5;
+ int bx = (cols_ - bw) / 2;
+ int by = (rows_ - bh) / 2;
+ if (bx < 0) bx = 0;
+ if (by < 1) by = 1;
+
+ int iw = bw - 2;
+ bool sel_yes = false;
+
+ const char *yes_lbl = "[ Yes ]";
+ const char *no_lbl = " [ No ]";
+ int yes_w = static_cast<int>(std::strlen(yes_lbl));
+ int no_w = static_cast<int>(std::strlen(no_lbl));
+ int btns_w = yes_w + no_w;
+
+ while (true) {
+ // Clear overlay area (erase old content)
+ for (int r = 0; r < bh; ++r)
+ for (int c = 0; c < bw; ++c) mvaddch(by + r, bx + c, ' ');
+
+ // ACS box borders
+ attron(ATTR_OVERLAY_BORDER);
+ mvaddch(by, bx, ACS_ULCORNER);
+ for (int i = 1; i < bw - 1; ++i) mvaddch(by, bx + i, ACS_HLINE);
+ mvaddch(by, bx + bw - 1, ACS_URCORNER);
+
+ mvaddch(by + 1, bx, ACS_VLINE);
+ mvaddch(by + 1, bx + bw - 1, ACS_VLINE);
+
+ mvaddch(by + 2, bx, ACS_LTEE);
+ for (int i = 1; i < bw - 1; ++i) mvaddch(by + 2, bx + i, ACS_HLINE);
+ mvaddch(by + 2, bx + bw - 1, ACS_RTEE);
+
+ mvaddch(by + 3, bx, ACS_VLINE);
+ mvaddch(by + 3, bx + bw - 1, ACS_VLINE);
+
+ mvaddch(by + 4, bx, ACS_LLCORNER);
+ for (int i = 1; i < bw - 1; ++i) mvaddch(by + 4, bx + i, ACS_HLINE);
+ mvaddch(by + 4, bx + bw - 1, ACS_LRCORNER);
+ attroff(ATTR_OVERLAY_BORDER);
+
+ // Prompt
+ attron(ATTR_NORMAL);
+ mvprintw(by + 1, bx + 1, " %-*s ", iw - 2, prompt.c_str());
+ attroff(ATTR_NORMAL);
+
+ // Buttons
+ int pad = (iw - btns_w) / 2;
+ if (pad < 0) pad = 0;
+
+ if (sel_yes) {
+ attron(COLOR_PAIR(CP_BUTTON_YES) | A_BOLD);
+ mvaddstr(by + 3, bx + 1 + pad, yes_lbl);
+ attroff(COLOR_PAIR(CP_BUTTON_YES) | A_BOLD);
+ } else {
+ attron(ATTR_NORMAL);
+ mvaddstr(by + 3, bx + 1 + pad, yes_lbl);
+ attroff(ATTR_NORMAL);
+ }
+ if (!sel_yes) {
+ attron(COLOR_PAIR(CP_BUTTON_NO) | A_BOLD);
+ mvaddstr(by + 3, bx + 1 + pad + yes_w, no_lbl);
+ attroff(COLOR_PAIR(CP_BUTTON_NO) | A_BOLD);
+ } else {
+ attron(ATTR_NORMAL);
+ mvaddstr(by + 3, bx + 1 + pad + yes_w, no_lbl);
+ attroff(ATTR_NORMAL);
+ }
+
+ refresh();
+
+ int ch = getch();
+ switch (ch) {
+ case KEY_LEFT: case 'h': sel_yes = true; break;
+ case KEY_RIGHT: case 'l': sel_yes = false; break;
+ case KEY_DOWN: case 'j': sel_yes = false; break;
+ case KEY_UP: case 'k': sel_yes = true; break;
+ case 'y': case 'Y': need_full_redraw_ = true; return true;
+ case 'n': case 'N': need_full_redraw_ = true; return false;
+ case '\n': case '\r': case KEY_ENTER:
+ need_full_redraw_ = true;
+ return sel_yes;
+ case 27: {
+ nodelay(stdscr, TRUE);
+ int n = getch();
+ nodelay(stdscr, FALSE);
+ if (n == ERR) { need_full_redraw_ = true; return false; }
+ if (n == '[') {
+ int m = getch();
+ if (m == 'D' || m == 'C') { sel_yes = !sel_yes; break; }
+ }
+ need_full_redraw_ = true;
+ return false;
+ }
+ default: break;
+ }
+ }
+}
+
+std::string FileManager::overlay_picker(const std::string &title,
+ const std::vector<std::string> &items,
+ bool allow_cancel) {
+ if (items.empty()) return "";
+
+ int nitems = static_cast<int>(items.size());
+ int bw = cols_ * 2 / 3;
+ if (bw > 70) bw = 70;
+ if (bw < 30) bw = 30;
+
+ int max_vis = rows_ - 6;
+ int vis = std::min(nitems, max_vis);
+ if (vis < 1) vis = 1;
+
+ int bh = vis + 4; // top + title + divider + vis items + bottom
+ int bx = (cols_ - bw) / 2;
+ int by = (rows_ - bh) / 2;
+ if (bx < 0) bx = 0;
+ if (by < 1) by = 1;
+
+ int iw = bw - 2;
+
+ int psel = 0, poff = 0;
+ curs_set(0);
+
+ while (true) {
+ if (psel < 0) psel = 0;
+ if (psel >= nitems) psel = nitems - 1;
+ if (psel < poff) poff = psel;
+ if (psel >= poff + vis) poff = psel - vis + 1;
+ if (poff < 0) poff = 0;
+
+ // Clear overlay area
+ for (int r = 0; r < bh; ++r)
+ for (int c = 0; c < bw; ++c) mvaddch(by + r, bx + c, ' ');
+
+ // ACS borders
+ attron(ATTR_OVERLAY_BORDER);
+ mvaddch(by, bx, ACS_ULCORNER);
+ for (int i = 1; i < bw - 1; ++i) mvaddch(by, bx + i, ACS_HLINE);
+ mvaddch(by, bx + bw - 1, ACS_URCORNER);
+
+ mvaddch(by + 1, bx, ACS_VLINE);
+ mvaddch(by + 1, bx + bw - 1, ACS_VLINE);
+
+ mvaddch(by + 2, bx, ACS_LTEE);
+ for (int i = 1; i < bw - 1; ++i) mvaddch(by + 2, bx + i, ACS_HLINE);
+ mvaddch(by + 2, bx + bw - 1, ACS_RTEE);
+
+ for (int i = 0; i < vis; ++i) {
+ mvaddch(by + 3 + i, bx, ACS_VLINE);
+ mvaddch(by + 3 + i, bx + bw - 1, ACS_VLINE);
+ }
+
+ mvaddch(by + bh - 1, bx, ACS_LLCORNER);
+ for (int i = 1; i < bw - 1; ++i) mvaddch(by + bh - 1, bx + i, ACS_HLINE);
+ mvaddch(by + bh - 1, bx + bw - 1, ACS_LRCORNER);
+ attroff(ATTR_OVERLAY_BORDER);
+
+ // Title
+ attron(ATTR_NORMAL);
+ mvprintw(by + 1, bx + 1, " %-*s ", iw - 2, title.c_str());
+ attroff(ATTR_NORMAL);
+
+ // Items
+ attron(ATTR_NORMAL);
+ for (int i = 0; i < vis; ++i) {
+ int idx = poff + i;
+ if (idx < nitems) {
+ std::string label = " " + std::to_string(idx + 1) + " " + items[idx];
+ int lw = static_cast<int>(label.size());
+ if (lw > iw - 1) label = label.substr(0, iw - 2) + "~";
+ int pad_r = iw - 1 - static_cast<int>(label.size());
+ if (pad_r < 0) pad_r = 0;
+
+ mvaddch(by + 3 + i, bx + 1, ' ');
+ if (idx == psel) {
+ attron(A_REVERSE);
+ addstr(label.c_str());
+ for (int p = 0; p < pad_r; ++p) addch(' ');
+ attroff(A_REVERSE);
+ } else {
+ addstr(label.c_str());
+ for (int p = 0; p < pad_r; ++p) addch(' ');
+ }
+ } else {
+ for (int p = 0; p < iw; ++p) mvaddch(by + 3 + i, bx + 1 + p, ' ');
+ }
+ }
+ attroff(ATTR_NORMAL);
+
+ refresh();
+
+ int ch = getch();
+ switch (ch) {
+ case 'j': case KEY_DOWN: psel++; break;
+ case 'k': case KEY_UP: psel--; break;
+ case 'g': psel = 0; break;
+ case 'G': psel = nitems - 1; break;
+ case '\n': case '\r': case KEY_ENTER: case 'l': case KEY_RIGHT:
+ return items[psel];
+ case 'q': case 'Q': case 'h':
+ if (allow_cancel) { need_full_redraw_ = true; return ""; }
+ break;
+ case 27: {
+ nodelay(stdscr, TRUE);
+ int n = getch();
+ nodelay(stdscr, FALSE);
+ if (n == ERR) { need_full_redraw_ = true; return ""; }
+ if (n == '[') {
+ int m = getch();
+ if (m == 'A') psel--;
+ else if (m == 'B') psel++;
+ else if (m == 'D') { need_full_redraw_ = true; return ""; }
+ else { need_full_redraw_ = true; return ""; }
+ }
+ break;
+ }
+ default: break;
+ }
+ }
+}
+
+// ─── line input ─────────────────────────────────────────────────────────────
+bool FileManager::read_line(std::string &out, const std::string &prompt,
+ const std::string &initial) {
+ out = initial;
+ int pos = static_cast<int>(out.size());
+ int bot = rows_ - 1;
+
+ leaveok(stdscr, FALSE); // need real cursor positioning for editing
+ curs_set(1);
+
+ while (true) {
+ // Clear the entire bottom line
+ move(bot, 0);
+ clrtoeol();
+
+ // Prompt
+ attron(ATTR_INFO);
+ mvprintw(bot, 0, "%s", prompt.c_str());
+ attroff(ATTR_INFO);
+
+ // Input text + pad to end of line
+ attron(ATTR_NORMAL);
+ addstr(out.c_str());
+ int used = static_cast<int>(prompt.size() + out.size());
+ for (int i = used; i < cols_; ++i) addch(' ');
+ attroff(ATTR_NORMAL);
+
+ move(bot, static_cast<int>(prompt.size() + pos));
+ refresh();
+
+ int ch = getch();
+ if (ch == 27) { // ESC
+ nodelay(stdscr, TRUE);
+ int n = getch();
+ nodelay(stdscr, FALSE);
+ if (n == ERR) {
+ leaveok(stdscr, TRUE);
+ curs_set(0);
+ out.clear();
+ return false;
+ }
+ continue;
+ } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) {
+ leaveok(stdscr, TRUE);
+ curs_set(0);
+ return true;
+ } else if (ch == KEY_LEFT) {
+ if (pos > 0) pos--;
+ } else if (ch == KEY_RIGHT) {
+ if (pos < static_cast<int>(out.size())) pos++;
+ } else if (ch == KEY_HOME || ch == 1) {
+ pos = 0;
+ } else if (ch == KEY_END || ch == 5) {
+ pos = static_cast<int>(out.size());
+ } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
+ if (pos > 0) {
+ out.erase(pos - 1, 1);
+ pos--;
+ }
+ } else if (ch == KEY_DC) {
+ if (pos < static_cast<int>(out.size()))
+ out.erase(pos, 1);
+ } else if (ch >= 32 && ch < 127) {
+ out.insert(pos, 1, static_cast<char>(ch));
+ pos++;
+ }
+ }
+}
+
+// ─── actions ────────────────────────────────────────────────────────────────
+void FileManager::change_dir(const std::string &path) {
+ prev_cwd_ = cwd_;
+ last_child_.clear();
+
+ std::error_code ec;
+ std::string resolved = fs::canonical(path, ec);
+ if (ec) {
+ flash_msg("cannot access directory");
+ return;
+ }
+ cwd_ = resolved;
+ filter_.clear();
+ selected_.clear();
+ sel_ = 0; offset_ = 0;
+ load_entries();
+}
+
+void FileManager::do_open() {
+ const Entry *e = get_sel();
+ if (!e) return;
+
+ std::string target = join_path(cwd_, e->name);
+ // Strip trailing / for directory and @ for symlink
+ if (!target.empty() && (target.back() == '/' || target.back() == '@'))
+ target.pop_back();
+
+ switch (e->type) {
+ case EntryType::Directory: {
+ if (access(target.c_str(), R_OK | X_OK) != 0) {
+ flash_msg("permission denied: " + e->name);
+ return;
+ }
+ prev_cwd_ = cwd_;
+ last_child_ = e->name; // includes trailing /
+ cwd_ = fs::canonical(target);
+ filter_.clear();
+ selected_.clear();
+ sel_ = 0; offset_ = 0;
+ load_entries();
+ break;
+ }
+ case EntryType::Symlink: {
+ // If symlink points to dir, enter it; otherwise open
+ struct stat st;
+ if (stat(target.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) {
+ if (access(target.c_str(), R_OK | X_OK) != 0) {
+ flash_msg("permission denied: " + e->name);
+ return;
+ }
+ prev_cwd_ = cwd_;
+ last_child_.clear();
+ cwd_ = fs::canonical(target);
+ filter_.clear();
+ selected_.clear();
+ sel_ = 0; offset_ = 0;
+ load_entries();
+ } else {
+ restore_term();
+ open_file(target);
+ setup_term();
+ need_full_redraw_ = true;
+ }
+ break;
+ }
+ case EntryType::File: {
+ if (access(target.c_str(), R_OK) != 0) {
+ flash_msg("permission denied: " + e->name);
+ return;
+ }
+ restore_term();
+ open_file(target);
+ setup_term();
+ need_full_redraw_ = true;
+ break;
+ }
+ }
+}
+
+void FileManager::do_go_back() {
+ if (cwd_ == "/") return;
+ filter_.clear();
+ selected_.clear();
+ prev_cwd_ = cwd_;
+ last_child_ = fs::path(cwd_).filename().string() + "/";
+ cwd_ = fs::path(cwd_).parent_path();
+ sel_ = 0; offset_ = 0;
+ load_entries();
+ // Restore selection to the dir we came from
+ if (!last_child_.empty()) {
+ int idx = find_entry_idx(last_child_);
+ if (idx >= 0) sel_ = idx;
+ }
+ last_child_.clear();
+}
+
+void FileManager::do_go_home() {
+ change_dir(home_dir());
+}
+
+void FileManager::do_jump_back() {
+ if (prev_cwd_.empty()) {
+ flash_msg("no previous directory");
+ return;
+ }
+ std::string tmp = cwd_;
+ cwd_ = prev_cwd_;
+ prev_cwd_ = tmp;
+ filter_.clear();
+ selected_.clear();
+ sel_ = 0; offset_ = 0;
+ load_entries();
+}
+
+void FileManager::do_jump_path() {
+ std::string path;
+ if (!read_line(path, " jump to: ")) { need_full_redraw_ = true; return; }
+ if (path.empty()) { need_full_redraw_ = true; return; }
+ // Expand ~
+ if (path[0] == '~') {
+ path = home_dir() + path.substr(1);
+ }
+ std::error_code ec;
+ std::string resolved = fs::canonical(path, ec);
+ if (ec || !fs::is_directory(resolved)) {
+ flash_msg("not found: " + path);
+ } else {
+ change_dir(resolved);
+ }
+}
+
+void FileManager::do_search() {
+ searching_ = true;
+ filter_.clear();
+ curs_set(1);
+
+ while (searching_) {
+ apply_filter();
+ sel_ = 0; offset_ = 0;
+ need_full_redraw_ = true;
+ draw();
+
+ int ch = getch();
+ if (ch == 27) { // ESC
+ nodelay(stdscr, TRUE);
+ int n = getch();
+ nodelay(stdscr, FALSE);
+ if (n == ERR) {
+ // Bare ESC — clear filter and exit search
+ filter_.clear();
+ searching_ = false;
+ apply_filter();
+ curs_set(0);
+ need_full_redraw_ = true;
+ }
+ // else: arrow key or other sequence — ignore
+ } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) {
+ searching_ = false;
+ curs_set(0);
+ need_full_redraw_ = true;
+ } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
+ if (!filter_.empty()) filter_.pop_back();
+ } else if (ch >= 32 && ch < 127) {
+ filter_.push_back(static_cast<char>(ch));
+ }
+ }
+ draw();
+}
+
+void FileManager::do_clear_filter() {
+ filter_.clear();
+ searching_ = false;
+ apply_filter();
+ sel_ = 0; offset_ = 0;
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_toggle_hidden() {
+ show_hidden_ = !show_hidden_;
+ flash_msg(show_hidden_ ? "hidden files shown" : "hidden files hidden");
+ sel_ = 0; offset_ = 0;
+ load_entries();
+}
+
+void FileManager::do_toggle_details() {
+ show_details_ = !show_details_;
+ flash_msg(show_details_ ? "details on" : "details off");
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_toggle_preview() {
+ show_preview_ = !show_preview_;
+ flash_msg(show_preview_ ? "preview on" : "preview off");
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_sort() {
+ switch (sort_mode_) {
+ case SortMode::Name: sort_mode_ = SortMode::Size; flash_msg("sort: size"); break;
+ case SortMode::Size: sort_mode_ = SortMode::Date; flash_msg("sort: date"); break;
+ case SortMode::Date: sort_mode_ = SortMode::Name; flash_msg("sort: name"); break;
+ }
+ sel_ = 0; offset_ = 0;
+ sort_entries();
+ apply_filter();
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_info() {
+ const Entry *e = get_sel();
+ if (!e) return;
+
+ const char *type_str = "file";
+ if (e->type == EntryType::Directory) type_str = "directory";
+ else if (e->type == EntryType::Symlink) type_str = "symlink";
+ else if (e->is_executable) type_str = "executable";
+
+ // Build info lines
+ std::vector<std::string> lines;
+ lines.push_back("Name: " + e->name);
+ lines.push_back("Type: " + std::string(type_str));
+ lines.push_back("Perm: " + e->permissions);
+ lines.push_back("Owner: " + e->owner + ":" + e->group);
+
+ double sz;
+ if (e->type == EntryType::Directory) {
+ uintmax_t total = 0;
+ std::error_code ec;
+ for (const auto &de : fs::recursive_directory_iterator(
+ e->full_path, fs::directory_options::skip_permission_denied, ec)) {
+ if (ec) break;
+ if (de.is_regular_file(ec) && !ec) {
+ auto s = de.file_size(ec);
+ if (!ec) total += s;
+ }
+ }
+ sz = static_cast<double>(total);
+ } else {
+ sz = static_cast<double>(e->size);
+ }
+ const char *u = "B";
+ if (sz >= 1024) { sz /= 1024; u = "K"; }
+ if (sz >= 1024) { sz /= 1024; u = "M"; }
+ if (sz >= 1024) { sz /= 1024; u = "G"; }
+ char size_buf[32];
+ std::snprintf(size_buf, sizeof(size_buf), "%.0f %s", sz, u);
+ lines.push_back("Size: " + std::string(size_buf));
+
+ struct tm tm_val;
+ localtime_r(&e->mtime.tv_sec, &tm_val);
+ char date_buf[64];
+ std::strftime(date_buf, sizeof(date_buf), "%Y-%m-%d %H:%M:%S", &tm_val);
+ lines.push_back("Mod: " + std::string(date_buf));
+
+ if (e->type == EntryType::Symlink) {
+ lines.push_back("Target: " + e->symlink_target);
+ }
+
+ // Measure max line width
+ int maxw = 0;
+ for (const auto &l : lines)
+ if (static_cast<int>(l.size()) > maxw) maxw = l.size();
+
+ int nlines = static_cast<int>(lines.size());
+ int bw = maxw + 4;
+ if (bw > cols_ - 4) bw = cols_ - 4;
+ int bh = nlines + 4; // top + title + divider + nlines + bottom
+ int bx = (cols_ - bw) / 2;
+ int by = (rows_ - bh) / 2;
+ if (bx < 0) bx = 0;
+ if (by < 1) by = 1;
+ int iw = bw - 2;
+
+ // Clear overlay area
+ for (int r = 0; r < bh; ++r)
+ for (int c = 0; c < bw; ++c) mvaddch(by + r, bx + c, ' ');
+
+ // ACS borders
+ attron(ATTR_OVERLAY_BORDER);
+ mvaddch(by, bx, ACS_ULCORNER);
+ for (int i = 1; i < bw - 1; ++i) mvaddch(by, bx + i, ACS_HLINE);
+ mvaddch(by, bx + bw - 1, ACS_URCORNER);
+
+ mvaddch(by + 1, bx, ACS_VLINE);
+ mvaddch(by + 1, bx + bw - 1, ACS_VLINE);
+
+ mvaddch(by + 2, bx, ACS_LTEE);
+ for (int i = 1; i < bw - 1; ++i) mvaddch(by + 2, bx + i, ACS_HLINE);
+ mvaddch(by + 2, bx + bw - 1, ACS_RTEE);
+
+ for (int i = 0; i < nlines; ++i) {
+ mvaddch(by + 3 + i, bx, ACS_VLINE);
+ mvaddch(by + 3 + i, bx + bw - 1, ACS_VLINE);
+ }
+
+ mvaddch(by + bh - 1, bx, ACS_LLCORNER);
+ for (int i = 1; i < bw - 1; ++i) mvaddch(by + bh - 1, bx + i, ACS_HLINE);
+ mvaddch(by + bh - 1, bx + bw - 1, ACS_LRCORNER);
+ attroff(ATTR_OVERLAY_BORDER);
+
+ // Title + info content
+ attron(ATTR_NORMAL);
+ mvprintw(by + 1, bx + 1, " FILE INFO %-*s", iw - 12, "");
+ for (int i = 0; i < nlines; ++i)
+ mvprintw(by + 3 + i, bx + 1, " %-*s ", iw - 2, lines[i].c_str());
+ attroff(ATTR_NORMAL);
+
+ refresh();
+
+ // Wait for any key
+ nodelay(stdscr, FALSE);
+ int ch = getch();
+ if (ch == 27) {
+ nodelay(stdscr, TRUE);
+ getch();
+ nodelay(stdscr, FALSE);
+ }
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_help() {
+ int bw = cols_ - 4;
+ if (bw > 52) bw = 52;
+ if (bw < 36) bw = 36;
+
+ struct HelpLine { const char *text; bool sep; bool centered; };
+ static const HelpLine help[] = {
+ {"", false, false},
+ {"KEYBOARD SHORTCUTS", false, true},
+ {"", true, false},
+ {" j/k up/down g/G top/bottom", false, false},
+ {" h/left go back", false, false},
+ {" l/right/enter open / enter dir", false, false},
+ {"", true, false},
+ {" / search filter", false, false},
+ {" esc clear filter / cancel", false, false},
+ {" . toggle hidden files", false, false},
+ {" i file info in status bar", false, false},
+ {" s cycle sort: name/size/date", false, false},
+ {" T toggle size/date details", false, false},
+ {" P toggle preview pane", false, false},
+ {"", true, false},
+ {" space toggle multi-select", false, false},
+ {" a select all / deselect all", false, false},
+ {" y yank/copy (works on selection)", false, false},
+ {" x cut (works on selection)", false, false},
+ {" p paste", false, false},
+ {" d delete (works on selection)", false, false},
+ {"", true, false},
+ {" r rename R refresh", false, false},
+ {" m make directory", false, false},
+ {" n new file", false, false},
+ {" u trash file (safe delete)", false, false},
+ {" U open trash directory", false, false},
+ {" ! drop to shell in CWD", false, false},
+ {" o open with custom program", false, false},
+ {" + chmod +x (make executable)", false, false},
+ {" - chmod -x (remove executable)", false, false},
+ {"", true, false},
+ {" b bookmark current dir", false, false},
+ {" B open bookmark picker", false, false},
+ {" c copy path to clipboard", false, false},
+ {" ~ go to home directory", false, false},
+ {" ` jump to previous directory", false, false},
+ {" : jump to path", false, false},
+ {" f find files recursively", false, false},
+ {"", true, false},
+ {" q quit", false, false},
+ {" ? this help", false, false},
+ {"", false, false},
+ {" press any key to close...", false, false},
+ };
+ const int nhelp = sizeof(help) / sizeof(help[0]);
+
+ int bh = nhelp + 2; // top border + nhelp rows + bottom border
+ if (bh > rows_) bh = rows_;
+ int bx = (cols_ - bw) / 2;
+ int by = (rows_ - bh) / 2;
+ if (bx < 0) bx = 0;
+ if (by < 1) by = 1;
+ int iw = bw - 2;
+
+ // Clear overlay area
+ for (int r = 0; r < bh; ++r)
+ for (int c = 0; c < bw; ++c) mvaddch(by + r, bx + c, ' ');
+
+ // ACS borders
+ attron(ATTR_OVERLAY_BORDER);
+ mvaddch(by, bx, ACS_ULCORNER);
+ for (int i = 1; i < bw - 1; ++i) mvaddch(by, bx + i, ACS_HLINE);
+ mvaddch(by, bx + bw - 1, ACS_URCORNER);
+
+ for (int i = 1; i < bh - 1; ++i) {
+ mvaddch(by + i, bx, ACS_VLINE);
+ mvaddch(by + i, bx + bw - 1, ACS_VLINE);
+ }
+
+ mvaddch(by + bh - 1, bx, ACS_LLCORNER);
+ for (int i = 1; i < bw - 1; ++i) mvaddch(by + bh - 1, bx + i, ACS_HLINE);
+ mvaddch(by + bh - 1, bx + bw - 1, ACS_LRCORNER);
+ attroff(ATTR_OVERLAY_BORDER);
+
+ // Help content
+ int visible = bh - 2;
+ for (int i = 0; i < nhelp && i < visible; ++i) {
+ if (help[i].sep) {
+ attron(ATTR_OVERLAY_BORDER);
+ mvaddch(by + 1 + i, bx, ACS_LTEE);
+ for (int c = 1; c < bw - 1; ++c) mvaddch(by + 1 + i, bx + c, ACS_HLINE);
+ mvaddch(by + 1 + i, bx + bw - 1, ACS_RTEE);
+ attroff(ATTR_OVERLAY_BORDER);
+ } else {
+ attron(ATTR_NORMAL);
+ int w = static_cast<int>(std::strlen(help[i].text));
+ if (help[i].centered) {
+ int lpad = (iw - w) / 2;
+ if (lpad < 0) lpad = 0;
+ for (int p = 0; p < lpad; ++p) mvaddch(by + 1 + i, bx + 1 + p, ' ');
+ mvaddstr(by + 1 + i, bx + 1 + lpad, help[i].text);
+ int rpad = iw - w - lpad;
+ for (int p = 0; p < rpad; ++p) mvaddch(by + 1 + i, bx + 1 + lpad + w + p, ' ');
+ } else {
+ mvaddstr(by + 1 + i, bx + 1, help[i].text);
+ int pad = iw - w;
+ for (int p = 0; p < pad; ++p) mvaddch(by + 1 + i, bx + 1 + w + p, ' ');
+ }
+ attroff(ATTR_NORMAL);
+ }
+ }
+
+ refresh();
+
+ // Wait for any key
+ nodelay(stdscr, FALSE);
+ int ch = getch();
+ if (ch == 27) {
+ nodelay(stdscr, TRUE);
+ getch();
+ nodelay(stdscr, FALSE);
+ }
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_rename() {
+ const Entry *e = get_sel();
+ if (!e) return;
+ // Strip trailing / or @
+ std::string name = e->name;
+ if (!name.empty() && (name.back() == '/' || name.back() == '@'))
+ name.pop_back();
+
+ std::string new_name;
+ if (!read_line(new_name, " Rename: ", name)) { need_full_redraw_ = true; return; }
+ if (new_name.empty() || new_name == name) { flash_msg("cancelled"); return; }
+
+ std::string src = join_path(cwd_, name);
+ std::string dst = join_path(cwd_, new_name);
+ if (std::rename(src.c_str(), dst.c_str()) == 0) {
+ load_entries();
+ } else {
+ flash_msg("rename failed");
+ }
+}
+
+void FileManager::do_mkdir() {
+ std::string name;
+ if (!read_line(name, " Directory name: ")) { need_full_redraw_ = true; return; }
+ if (name.empty()) { flash_msg("cancelled"); return; }
+
+ std::string path = join_path(cwd_, name);
+ if (fs::create_directory(path)) {
+ load_entries();
+ } else {
+ flash_msg("mkdir failed");
+ }
+}
+
+void FileManager::do_newfile() {
+ std::string name;
+ if (!read_line(name, " File name: ")) { need_full_redraw_ = true; return; }
+ if (name.empty()) { flash_msg("cancelled"); return; }
+
+ std::string path = join_path(cwd_, name);
+ std::ofstream f(path);
+ if (f) {
+ f.close();
+ load_entries();
+ } else {
+ flash_msg("cannot create file");
+ }
+}
+
+void FileManager::do_delete() {
+ // Gather items to delete
+ std::vector<std::string> targets;
+ if (!selected_.empty()) {
+ for (const auto &sel_name : selected_)
+ targets.push_back(join_path(cwd_, strip_suffix(strip_suffix(sel_name, "@"), "/")));
+ } else {
+ const Entry *e = get_sel();
+ if (!e) return;
+ std::string n = e->name;
+ if (!n.empty() && n.back() == '/') n.pop_back();
+ if (!n.empty() && n.back() == '@') n.pop_back();
+ targets.push_back(join_path(cwd_, n));
+ }
+
+ if (targets.empty()) return;
+
+ // Confirm
+ std::string prompt;
+ if (targets.size() == 1) {
+ prompt = "Delete \"" + fs::path(targets[0]).filename().string() + "\"?";
+ } else {
+ prompt = "Delete " + std::to_string(targets.size()) + " selected items?";
+ }
+
+ if (!confirm_overlay(prompt)) { need_full_redraw_ = true; return; }
+
+ // For single non-empty dir, second confirmation
+ if (targets.size() == 1) {
+ struct stat st;
+ if (stat(targets[0].c_str(), &st) == 0 && S_ISDIR(st.st_mode)) {
+ // Check if non-empty
+ DIR *d = opendir(targets[0].c_str());
+ if (d) {
+ bool empty = true;
+ struct dirent *de;
+ while ((de = readdir(d))) {
+ std::string dn(de->d_name);
+ if (dn != "." && dn != "..") { empty = false; break; }
+ }
+ closedir(d);
+ if (!empty) {
+ std::string prompt2 = "\"" + fs::path(targets[0]).filename().string()
+ + "\" not empty. Delete ALL?";
+ if (!confirm_overlay(prompt2)) { need_full_redraw_ = true; return; }
+ }
+ }
+ }
+ }
+
+ // Execute
+ std::error_code ec;
+ for (const auto &t : targets) {
+ fs::remove_all(t, ec);
+ }
+ selected_.clear();
+ if (sel_ >= static_cast<int>(entries_.size()) - 1)
+ sel_ = std::max(0, static_cast<int>(entries_.size()) - 2);
+ if (sel_ < 0) sel_ = 0;
+ load_entries();
+}
+
+void FileManager::do_trash() {
+ const Entry *e = get_sel();
+ if (!e) return;
+ std::string name = e->name;
+ if (!name.empty() && name.back() == '/') name.pop_back();
+ if (!name.empty() && name.back() == '@') name.pop_back();
+
+ std::string src = join_path(cwd_, name);
+
+ // Timestamp prefix
+ time_t now = time(nullptr);
+ struct tm tm_val;
+ localtime_r(&now, &tm_val);
+ char ts[20];
+ std::strftime(ts, sizeof(ts), "%Y%m%d_%H%M%S", &tm_val);
+
+ std::string dst = trash_dir_ + "/" + ts + "_" + name;
+
+ // Use copy + remove to handle cross-device moves
+ bool ok = false;
+ if (std::rename(src.c_str(), dst.c_str()) == 0) {
+ ok = true;
+ } else if (errno == EXDEV) {
+ // Cross-device: copy then delete
+ std::error_code ec;
+ fs::copy(src, dst, fs::copy_options::recursive, ec);
+ if (!ec) {
+ fs::remove_all(src, ec);
+ ok = !ec;
+ }
+ }
+ if (ok) {
+ flash_msg("trashed: " + e->name);
+ if (sel_ >= static_cast<int>(entries_.size()) - 1)
+ sel_ = std::max(0, static_cast<int>(entries_.size()) - 2);
+ load_entries();
+ } else {
+ flash_msg("trash failed");
+ }
+}
+
+void FileManager::do_open_trash() {
+ change_dir(trash_dir_);
+}
+
+void FileManager::do_open_with() {
+ const Entry *e = get_sel();
+ if (!e || e->type == EntryType::Directory) {
+ flash_msg("cannot open-with a directory");
+ return;
+ }
+ std::string name = e->name;
+ if (!name.empty() && name.back() == '@') name.pop_back();
+ std::string target = join_path(cwd_, name);
+
+ std::string prog;
+ if (!read_line(prog, " Open \"" + e->name + "\" with: ")) {
+ need_full_redraw_ = true; return;
+ }
+ if (prog.empty()) { flash_msg("cancelled"); return; }
+
+ // Check program exists
+ std::string which_cmd = "command -v " + shell_escape(prog) + " >/dev/null 2>&1";
+ if (system(which_cmd.c_str()) != 0) {
+ flash_msg("not found: " + prog);
+ return;
+ }
+
+ restore_term();
+ std::string cmd = shell_escape(prog) + " " + shell_escape(target);
+ system(cmd.c_str());
+ setup_term();
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_chmod_x(bool set) {
+ const Entry *e = get_sel();
+ if (!e || e->type == EntryType::Directory) {
+ flash_msg("cannot chmod a directory");
+ return;
+ }
+ std::string name = e->name;
+ if (!name.empty() && name.back() == '@') name.pop_back();
+ std::string target = join_path(cwd_, name);
+
+ struct stat st;
+ if (stat(target.c_str(), &st) != 0) {
+ flash_msg("chmod failed");
+ return;
+ }
+
+ mode_t mode = st.st_mode;
+ if (set) mode |= (S_IXUSR | S_IXGRP | S_IXOTH);
+ else mode &= ~(S_IXUSR | S_IXGRP | S_IXOTH);
+
+ if (chmod(target.c_str(), mode) == 0) {
+ flash_msg(set ? "chmod +x: " + e->name : "chmod -x: " + e->name);
+ load_entries();
+ } else {
+ flash_msg("chmod failed");
+ }
+}
+
+void FileManager::do_find() {
+ std::string query;
+ if (!read_line(query, " find (recursive): ")) { need_full_redraw_ = true; return; }
+ if (query.empty()) { need_full_redraw_ = true; return; }
+
+ std::vector<std::string> results;
+ std::string lq = lower(query);
+
+ try {
+ for (const auto &entry :
+ fs::recursive_directory_iterator(cwd_,
+ fs::directory_options::skip_permission_denied)) {
+ std::string fname = entry.path().filename().string();
+ if (lower(fname).find(lq) != std::string::npos)
+ results.push_back(entry.path().string());
+ }
+ } catch (const fs::filesystem_error &) {
+ // Skip errors
+ }
+
+ if (results.empty()) {
+ flash_msg("no results for: " + query);
+ return;
+ }
+
+ std::sort(results.begin(), results.end());
+
+ // Show picker
+ std::string chosen = overlay_picker(" find: " + query + " (" +
+ std::to_string(results.size()) + " results)",
+ results);
+ if (chosen.empty()) { need_full_redraw_ = true; return; }
+
+ if (fs::is_directory(chosen)) {
+ change_dir(chosen);
+ } else {
+ std::string dir = fs::path(chosen).parent_path();
+ std::string fname = fs::path(chosen).filename().string();
+ change_dir(dir);
+ // Try to highlight the file
+ int idx = find_entry_idx(fname);
+ if (idx < 0) {
+ // Maybe it's a directory
+ idx = find_entry_idx(fname + "/");
+ }
+ if (idx >= 0) sel_ = idx;
+ }
+}
+
+void FileManager::do_bookmark_add() {
+ // Read existing bookmarks
+ std::vector<std::string> bookmarks;
+ std::ifstream in(bookmark_file_);
+ std::string line;
+ while (std::getline(in, line)) {
+ if (!line.empty() && line[0] != '#') bookmarks.push_back(line);
+ }
+ in.close();
+
+ // Check if already bookmarked
+ auto it = std::find(bookmarks.begin(), bookmarks.end(), cwd_);
+ if (it != bookmarks.end()) {
+ bookmarks.erase(it);
+ std::ofstream out(bookmark_file_);
+ for (const auto &b : bookmarks) out << b << '\n';
+ flash_msg("bookmark removed: " + cwd_);
+ } else {
+ std::ofstream out(bookmark_file_, std::ios::app);
+ out << cwd_ << '\n';
+ flash_msg("bookmarked: " + cwd_);
+ }
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_bookmark_jump() {
+ std::vector<std::string> bookmarks;
+ std::ifstream in(bookmark_file_);
+ std::string line;
+ while (std::getline(in, line)) {
+ if (!line.empty() && line[0] != '#') bookmarks.push_back(line);
+ }
+ if (bookmarks.empty()) {
+ flash_msg("no bookmarks saved");
+ return;
+ }
+
+ std::string chosen = overlay_picker(" BOOKMARKS", bookmarks);
+ if (chosen.empty()) { need_full_redraw_ = true; return; }
+
+ if (fs::is_directory(chosen)) {
+ change_dir(chosen);
+ } else {
+ flash_msg("not found: " + chosen);
+ need_full_redraw_ = true;
+ }
+}
+
+void FileManager::do_copy_path() {
+ const Entry *e = get_sel();
+ if (!e) return;
+ std::string name = e->name;
+ if (!name.empty() && name.back() == '/') name.pop_back();
+ if (!name.empty() && name.back() == '@') name.pop_back();
+ std::string path = join_path(cwd_, name);
+
+ // Try clipboard tools
+ const char *tools[] = {"wl-copy", "xclip", "xsel", "pbcopy", nullptr};
+ bool ok = false;
+ for (int i = 0; tools[i]; ++i) {
+ std::string cmd = std::string("command -v ") + tools[i] + " >/dev/null 2>&1";
+ if (system(cmd.c_str()) == 0) {
+ std::string pipe_cmd;
+ if (std::string(tools[i]) == "xclip")
+ pipe_cmd = std::string("printf '%s' ") + shell_escape(path) + " | xclip -selection clipboard";
+ else if (std::string(tools[i]) == "xsel")
+ pipe_cmd = std::string("printf '%s' ") + shell_escape(path) + " | xsel --clipboard --input";
+ else
+ pipe_cmd = std::string("printf '%s' ") + shell_escape(path) + " | " + tools[i];
+ ok = (system(pipe_cmd.c_str()) == 0);
+ break;
+ }
+ }
+ if (ok)
+ flash_msg("path copied: " + path);
+ else
+ flash_msg("no clipboard tool found");
+}
+
+void FileManager::do_toggle_select() {
+ const Entry *e = get_sel();
+ if (!e) return;
+ if (selected_.count(e->name)) {
+ selected_.erase(e->name);
+ } else {
+ selected_.insert(e->name);
+ }
+ auto sc = selected_.size();
+ flash_msg(sc == 0 ? "selection cleared" : std::to_string(sc) + " selected");
+ sel_++;
+ if (sel_ >= static_cast<int>(entries_.size())) sel_ = std::max(0, static_cast<int>(entries_.size()) - 1);
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_select_all() {
+ if (!selected_.empty()) {
+ selected_.clear();
+ flash_msg("selection cleared");
+ } else {
+ for (const auto &e : entries_) {
+ if (e.name != "../") selected_.insert(e.name);
+ }
+ flash_msg(std::to_string(selected_.size()) + " items");
+ }
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_yank() {
+ clipboard_paths_.clear();
+ if (!selected_.empty()) {
+ for (const auto &sel_name : selected_) {
+ std::string n = sel_name;
+ if (!n.empty() && n.back() == '/') n.pop_back();
+ if (!n.empty() && n.back() == '@') n.pop_back();
+ clipboard_paths_.push_back(join_path(cwd_, n));
+ }
+ clip_mode_ = ClipMode::Copy;
+ flash_msg("yanked " + std::to_string(clipboard_paths_.size()) + " items");
+ selected_.clear();
+ } else {
+ const Entry *e = get_sel();
+ if (!e) return;
+ std::string n = e->name;
+ if (!n.empty() && n.back() == '/') n.pop_back();
+ if (!n.empty() && n.back() == '@') n.pop_back();
+ clipboard_paths_.push_back(join_path(cwd_, n));
+ clip_mode_ = ClipMode::Copy;
+ flash_msg("yanked: " + e->name);
+ }
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_cut() {
+ clipboard_paths_.clear();
+ if (!selected_.empty()) {
+ for (const auto &sel_name : selected_) {
+ std::string n = sel_name;
+ if (!n.empty() && n.back() == '/') n.pop_back();
+ if (!n.empty() && n.back() == '@') n.pop_back();
+ clipboard_paths_.push_back(join_path(cwd_, n));
+ }
+ clip_mode_ = ClipMode::Cut;
+ flash_msg("cut " + std::to_string(clipboard_paths_.size()) + " items");
+ selected_.clear();
+ } else {
+ const Entry *e = get_sel();
+ if (!e) return;
+ std::string n = e->name;
+ if (!n.empty() && n.back() == '/') n.pop_back();
+ if (!n.empty() && n.back() == '@') n.pop_back();
+ clipboard_paths_.push_back(join_path(cwd_, n));
+ clip_mode_ = ClipMode::Cut;
+ flash_msg("cut: " + e->name);
+ }
+ need_full_redraw_ = true;
+}
+
+void FileManager::do_paste() {
+ if (clipboard_paths_.empty() || clip_mode_ == ClipMode::None) {
+ flash_msg("nothing to paste");
+ return;
+ }
+
+ for (const auto &src : clipboard_paths_) {
+ std::string name = fs::path(src).filename().string();
+ std::string dst = join_path(cwd_, name);
+
+ // Auto-rename on collision
+ if (fs::exists(dst)) {
+ std::string stem = fs::path(name).stem().string();
+ std::string ext = fs::path(name).extension().string();
+
+ // First try _copy
+ std::string try_name = stem + "_copy" + ext;
+ std::string try_dst = join_path(cwd_, try_name);
+ if (fs::exists(try_dst)) {
+ // Then try _copy2, _copy3, ...
+ int n = 2;
+ do {
+ try_name = stem + "_copy" + std::to_string(n) + ext;
+ try_dst = join_path(cwd_, try_name);
+ n++;
+ } while (fs::exists(try_dst) && n < 1000);
+ }
+ dst = try_dst;
+ }
+
+ std::error_code ec;
+ if (clip_mode_ == ClipMode::Copy) {
+ fs::copy(src, dst, fs::copy_options::recursive, ec);
+ } else {
+ fs::rename(src, dst, ec);
+ if (ec && ec.value() == EXDEV) {
+ // Cross-device: copy then delete
+ std::error_code ec2;
+ fs::copy(src, dst, fs::copy_options::recursive, ec2);
+ if (!ec2) {
+ fs::remove_all(src, ec2);
+ if (!ec2) ec.clear();
+ }
+ }
+ }
+ if (ec) {
+ flash_msg("paste error: " + ec.message());
+ return;
+ }
+ }
+
+ clipboard_paths_.clear();
+ clip_mode_ = ClipMode::None;
+ flash_msg("pasted");
+ load_entries();
+}
+
+void FileManager::do_shell() {
+ restore_term();
+ std::printf("\033[2J\033[H");
+ if (chdir(cwd_.c_str()) != 0) {
+ std::fprintf(stderr, "sfm: cannot cd to %s\n", cwd_.c_str());
+ }
+ std::printf("type \"exit\" to return to sfm\n");
+
+ const char *sh = getenv("SHELL");
+ if (!sh || !*sh) sh = "/bin/sh";
+
+ pid_t pid = fork();
+ if (pid == 0) {
+ execl(sh, sh, nullptr);
+ _exit(127);
+ }
+ int status;
+ waitpid(pid, &status, 0);
+
+ // Restore CWD in case user cd'd around
+ char buf[PATH_MAX];
+ if (getcwd(buf, sizeof(buf))) cwd_ = buf;
+
+ setup_term();
+ need_full_redraw_ = true;
+}
+
+// ─── smart file opener ──────────────────────────────────────────────────────
+bool FileManager::try_run(const std::vector<const char *> &progs,
+ const std::string &path, bool gui) {
+ for (auto prog : progs) {
+ std::string which = std::string("command -v ") + prog + " >/dev/null 2>&1";
+ if (system(which.c_str()) == 0) {
+ pid_t pid = fork();
+ if (pid == 0) {
+ if (gui) {
+ setsid();
+ int dn = open("/dev/null", O_RDWR);
+ if (dn >= 0) { dup2(dn, 0); dup2(dn, 1); dup2(dn, 2); if (dn > 2) close(dn); }
+ }
+ execlp(prog, prog, path.c_str(), nullptr);
+ _exit(127);
+ }
+ if (!gui) {
+ int status;
+ waitpid(pid, &status, 0);
+ }
+ return true;
+ }
+ }
+ return false;
+}
+
+void FileManager::open_file(const std::string &path) {
+ // 1. Custom opener script
+ if (access(opener_path_.c_str(), X_OK) == 0) {
+ std::string cmd = shell_escape(opener_path_) + " " + shell_escape(path);
+ system(cmd.c_str());
+ return;
+ }
+
+ std::string ext = file_ext(path);
+
+ // 2. Text / code → $EDITOR or vi (terminal)
+ {
+ static const std::vector<const char *> text_exts = {
+ "txt","md","markdown","rst","csv","tsv","log","conf","cfg","ini",
+ "toml","yaml","yml","sh","bash","zsh","fish","py","rb","pl","lua",
+ "js","ts","jsx","tsx","json","xml","html","htm","css","scss","sass",
+ "c","h","cpp","cc","cxx","hpp","rs","go","java","kt","swift","cs",
+ "php","r","sql","vim","diff","patch","makefile","dockerfile",
+ "gitignore","env","lock","mod","sum"
+ };
+ if (std::find(text_exts.begin(), text_exts.end(), ext) != text_exts.end()) {
+ const char *ed = getenv("EDITOR");
+ std::vector<const char *> editors;
+ if (ed) editors.push_back(ed);
+ editors.push_back("vi");
+ try_run(editors, path, false);
+ return;
+ }
+ }
+
+ // 3. Images → GUI
+ {
+ static const std::vector<const char *> img_exts = {
+ "jpg","jpeg","png","gif","bmp","tiff","tif","webp","svg","ico",
+ "heic","heif","avif"
+ };
+ if (std::find(img_exts.begin(), img_exts.end(), ext) != img_exts.end()) {
+ if (try_run({"imv","imvr","feh","sxiv","nsxiv","eog","eom","viewnior",
+ "shotwell","gimp"}, path, true))
+ return;
+ }
+ }
+
+ // 4. Video → GUI
+ {
+ static const std::vector<const char *> vid_exts = {
+ "mp4","mkv","avi","mov","wmv","flv","webm","m4v","mpeg","mpg",
+ "3gp","ogv"
+ };
+ if (std::find(vid_exts.begin(), vid_exts.end(), ext) != vid_exts.end()) {
+ if (try_run({"mpv","vlc","mplayer","totem","celluloid","haruna"}, path, true))
+ return;
+ }
+ }
+
+ // 5. Audio → terminal or GUI
+ {
+ static const std::vector<const char *> aud_exts = {
+ "mp3","flac","ogg","wav","aac","m4a","opus","wma","aiff"
+ };
+ if (std::find(aud_exts.begin(), aud_exts.end(), ext) != aud_exts.end()) {
+ if (try_run({"mpv","vlc","mplayer","cmus","mocp"}, path, false))
+ return;
+ }
+ }
+
+ // 6. PDF → GUI
+ if (ext == "pdf") {
+ if (try_run({"zathura","evince","okular","mupdf","atril","xreader"}, path, true))
+ return;
+ }
+
+ // 7. Office docs → GUI
+ {
+ static const std::vector<const char *> off_exts = {
+ "odt","ods","odp","doc","docx","xls","xlsx","ppt","pptx"
+ };
+ if (std::find(off_exts.begin(), off_exts.end(), ext) != off_exts.end()) {
+ if (try_run({"libreoffice","soffice"}, path, true))
+ return;
+ }
+ }
+
+ // 8. Archives → list in pager
+ {
+ static const std::vector<const char *> arc_exts = {
+ "zip","tar","gz","bz2","xz","zst","7z","rar"
+ };
+ if (std::find(arc_exts.begin(), arc_exts.end(), ext) != arc_exts.end()) {
+ // atool
+ if (system("command -v atool >/dev/null 2>&1") == 0) {
+ std::string cmd = "atool -l " + shell_escape(path) + " 2>&1 | ${PAGER:-less}";
+ system(cmd.c_str());
+ return;
+ }
+ // bsdtar
+ if (system("command -v bsdtar >/dev/null 2>&1") == 0) {
+ std::string cmd = "bsdtar -tf " + shell_escape(path) + " 2>&1 | ${PAGER:-less}";
+ system(cmd.c_str());
+ return;
+ }
+ }
+ }
+
+ // 9. MIME fallback via `file`
+ {
+ std::string cmd = "file --mime-type -b " + shell_escape(path) + " 2>/dev/null";
+ FILE *fp = popen(cmd.c_str(), "r");
+ std::string mime;
+ if (fp) {
+ char buf[256];
+ if (fgets(buf, sizeof(buf), fp)) mime = buf;
+ // trim newline
+ if (!mime.empty() && mime.back() == '\n') mime.pop_back();
+ pclose(fp);
+ }
+
+ if (!mime.empty()) {
+ if (mime.find("text/") == 0
+ || mime == "application/json"
+ || mime == "application/xml"
+ || mime == "application/javascript") {
+ const char *ed = getenv("EDITOR");
+ std::vector<const char *> eds;
+ if (ed) eds.push_back(ed);
+ eds.push_back("vi");
+ try_run(eds, path, false);
+ return;
+ }
+ if (mime.find("image/") == 0) {
+ try_run({"imv","feh","sxiv","nsxiv","eog","gimp"}, path, true);
+ return;
+ }
+ if (mime.find("video/") == 0) {
+ try_run({"mpv","vlc","mplayer"}, path, true);
+ return;
+ }
+ if (mime.find("audio/") == 0) {
+ try_run({"mpv","vlc","mplayer"}, path, false);
+ return;
+ }
+ if (mime == "application/pdf") {
+ try_run({"zathura","evince","okular","mupdf"}, path, true);
+ return;
+ }
+ }
+ }
+
+ // 10. Last resort: xdg-open / open / EDITOR
+ {
+ if (try_run({"xdg-open"}, path, true)) return;
+ if (try_run({"open"}, path, true)) return;
+ const char *ed = getenv("EDITOR");
+ std::vector<const char *> eds;
+ if (ed) eds.push_back(ed);
+ eds.push_back("vi");
+ try_run(eds, path, false);
+ }
+}
+
+// ─── main event loop ────────────────────────────────────────────────────────
+void FileManager::run() {
+ setup_term();
+ if (can_color()) init_colors();
+ load_entries();
+
+ while (true) {
+ draw();
+ int key = getch();
+
+ // Handle terminal resize
+ if (key == KEY_RESIZE) {
+ update_size();
+ need_full_redraw_ = true;
+ continue;
+ }
+
+ switch (key) {
+ case 'j': case KEY_DOWN:
+ if (!entries_.empty() && sel_ < static_cast<int>(entries_.size()) - 1)
+ sel_++;
+ break;
+ case 'k': case KEY_UP:
+ if (sel_ > 0) sel_--;
+ break;
+ case 'g': sel_ = 0; break;
+ case 'G':
+ if (!entries_.empty()) sel_ = static_cast<int>(entries_.size()) - 1;
+ break;
+ case KEY_NPAGE:
+ sel_ = std::min(sel_ + rows_ / 2,
+ std::max(0, static_cast<int>(entries_.size()) - 1));
+ break;
+ case KEY_PPAGE:
+ sel_ = std::max(sel_ - rows_ / 2, 0);
+ break;
+ case KEY_DC: do_delete(); break;
+ case '\n': case '\r': case KEY_ENTER: case 'l': case KEY_RIGHT:
+ do_open(); break;
+ case 'h': case KEY_LEFT:
+ do_go_back(); break;
+ case 'b': do_bookmark_add(); break;
+ case 'B': do_bookmark_jump(); break;
+ case '?': do_help(); break;
+ case 'R': load_entries(); flash_msg("refreshed"); break;
+ case '/': do_search(); break;
+ case '.': do_toggle_hidden(); break;
+ case 'T': do_toggle_details(); break;
+ case 'P': do_toggle_preview(); break;
+ case 'i': do_info(); break;
+ case '+': do_chmod_x(true); break;
+ case '-': do_chmod_x(false); break;
+ case 'o': do_open_with(); break;
+ case 's': do_sort(); break;
+ case 'u': do_trash(); break;
+ case 'U': do_open_trash(); break;
+ case 'f': do_find(); break;
+ case ':': do_jump_path(); break;
+ case '~': do_go_home(); break;
+ case '`': do_jump_back(); break;
+ case 'c': do_copy_path(); break;
+ case 27: do_clear_filter(); break; // ESC key
+ case ' ': do_toggle_select(); break;
+ case 'a': do_select_all(); break;
+ case 'y': do_yank(); break;
+ case 'x': do_cut(); break;
+ case 'p': do_paste(); break;
+ case 'd': do_delete(); break;
+ case 'r': do_rename(); break;
+ case 'm': do_mkdir(); break;
+ case 'n': do_newfile(); break;
+ case 'q': case 'Q': return;
+ case '!': do_shell(); break;
+ default: break;
+ }
+ }
+}
+
+// ─── main ───────────────────────────────────────────────────────────────────
+int main(int argc, char *argv[]) {
+ std::string start_path;
+ if (argc > 1) start_path = argv[1];
+
+ FileManager fm(start_path);
+ fm.run();
+ return 0;
+}