Files
sfm/sfm.cpp
T
2026-08-16 14:25:34 +08:00

2561 lines
85 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// sfm - Simple File Manager in C++17 with curses
// Works with both ncurses and netbsd-curses.
// Rewrite of the POSIX sh original for speed and efficiency
#include <curses.h>
#include <algorithm>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <functional>
#include <string>
#include <unordered_set>
#include <vector>
#include <dirent.h>
#include <fcntl.h>
#include <grp.h>
#include <pwd.h>
#include <signal.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(); }
// ─── key input normalization ────────────────────────────────────────────────
// ncurses decodes escape sequences (arrows, Home/End, ...) into KEY_* codes
// itself. netbsd-curses returns the raw bytes (e.g. ESC [ B for the down
// arrow), which would leak stray characters into the command handling.
// get_key() presents KEY_* codes (or 27 for a bare ESC) on both.
#ifdef NCURSES_VERSION
static int get_key() { return getch(); }
#else // manual escape-sequence decoding (netbsd-curses etc.)
static void nap_ms(int ms) {
struct timespec ts;
ts.tv_sec = ms / 1000;
ts.tv_nsec = (ms % 1000) * 1000000L;
::nanosleep(&ts, nullptr);
}
static int g_pushback[32];
static int g_pushback_n = 0;
static void queue_key(int k) {
if (g_pushback_n < static_cast<int>(sizeof(g_pushback) / sizeof(g_pushback[0])))
g_pushback[g_pushback_n++] = k;
}
// seq holds the bytes after ESC; seq[0] is '[' (CSI) or 'O' (SS3).
static int decode_escape_seq(const std::string &seq) {
if (seq.size() < 2) return ERR;
char lead = seq[0];
char fin = seq.back();
if (lead == 'O') {
if (seq.size() != 2) return ERR;
switch (fin) {
case 'A': return KEY_UP;
case 'B': return KEY_DOWN;
case 'C': return KEY_RIGHT;
case 'D': return KEY_LEFT;
case 'H': return KEY_HOME;
case 'F': return KEY_END;
case 'P': case 'Q': case 'R': case 'S': return KEY_F(fin - 'P' + 1);
}
return ERR;
}
if (lead != '[') return ERR;
switch (fin) {
case 'A': return KEY_UP;
case 'B': return KEY_DOWN;
case 'C': return KEY_RIGHT;
case 'D': return KEY_LEFT;
case 'H': return KEY_HOME;
case 'F': return KEY_END;
case 'Z': return KEY_BTAB;
}
// CSI n~ : Home/Insert/Delete/End/PgUp/PgDn (single digit only; multi-
// digit and modified variants fall through and are swallowed).
if (seq.size() == 3 && fin == '~' && seq[1] >= '0' && seq[1] <= '9') {
switch (seq[1]) {
case '1': case '7': return KEY_HOME;
case '2': return KEY_IC;
case '3': return KEY_DC;
case '4': case '8': return KEY_END;
case '5': return KEY_PPAGE;
case '6': return KEY_NPAGE;
}
}
return ERR;
}
// Like getch(), but assembles raw ESC sequences into KEY_* codes. A bare ESC
// is returned as 27 (possibly after a short wait for the rest of a sequence).
static int get_key() {
if (g_pushback_n > 0) return g_pushback[--g_pushback_n];
int k = getch();
if (k != 27) return k;
// ESC — check whether an escape sequence follows. Real terminals send
// the whole sequence in one burst, so the bytes are already queued.
nodelay(stdscr, TRUE);
std::string seq;
int c;
for (int i = 0; i < 4 && seq.empty(); ++i) {
c = getch();
if (c == ERR) nap_ms(10);
else seq += static_cast<char>(c);
}
if (seq.empty()) { nodelay(stdscr, FALSE); return 27; } // bare ESC
if (seq[0] == '[') {
// CSI: parameter/intermediate bytes, then a final byte (0x40-0x7E).
// The lead '[' itself is in that range, so collect at least one more.
for (int i = 0; i < 8; ++i) {
if (seq.size() >= 2 && seq.back() >= 0x40 && seq.back() <= 0x7E) break;
c = getch();
if (c == ERR) { nap_ms(10); c = getch(); }
if (c == ERR) break;
seq += static_cast<char>(c);
}
} else if (seq[0] == 'O') {
// SS3: exactly one final byte follows.
for (int i = 0; i < 4 && seq.size() < 2; ++i) {
c = getch();
if (c == ERR) { nap_ms(10); c = getch(); }
if (c == ERR) break;
seq += static_cast<char>(c);
}
}
nodelay(stdscr, FALSE);
int key = decode_escape_seq(seq);
if (key == ERR) {
bool complete = seq.back() >= 0x40 && seq.back() <= 0x7E;
if (!complete) {
// Truncated sequence — replay it as raw keys (ESC first).
for (size_t i = seq.size(); i-- > 0;)
queue_key(static_cast<unsigned char>(seq[i]));
queue_key(27);
return 27;
}
// Unknown but well-formed sequence (e.g. an F-key): swallow it,
// like curses implementations do.
return ERR;
}
return key;
}
// netbsd-curses never reports KEY_RESIZE; catch SIGWINCH ourselves.
static volatile sig_atomic_t g_winch = 0;
static void on_sigwinch(int) { g_winch = 1; }
#endif // NCURSES_VERSION
// 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,
CP_ROOT_WARN,
};
#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)
#define ATTR_ROOT_WARN (COLOR_PAIR(CP_ROOT_WARN) | A_BOLD)
// ─── Entry data ─────────────────────────────────────────────────────────────
enum class EntryType { Directory, File, Symlink };
struct Entry {
std::string name; // display name (dirs end 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;
bool is_root_ = 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,
std::vector<std::string> items,
bool allow_cancel = true,
const std::function<void(const std::string &)> &on_delete = nullptr);
// ── input ──────────────────────────────────────────────────────────────
bool handle_key(int key); // returns false to quit
// ── 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();
}
// Clear screen and move cursor home (visible in scrollback otherwise)
printf("\033[2J\033[H");
}
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);
init_pair(CP_ROOT_WARN, 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;
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 ";
}
std::string root_tag = is_root_ ? "[root] " : "";
int total = static_cast<int>(root_tag.size() + left.size() + right.size());
int pad = cols_ - total;
if (pad < 0) pad = 0;
int x = 0;
if (is_root_) {
attron(ATTR_ROOT_WARN);
mvprintw(rows_ - 1, 0, "%s", root_tag.c_str());
attroff(ATTR_ROOT_WARN);
x = static_cast<int>(root_tag.size());
}
attron(ATTR_TOPBAR);
mvprintw(rows_ - 1, x, "%s", left.c_str());
for (int i = 0; i < pad; ++i) mvaddch(rows_ - 1, x + 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 (32126) 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
attron(ATTR_DIVIDER);
for (int r = 1; r < rows_ - 1; ++r)
mvaddch(r, list_cols_, ACS_VLINE);
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 = get_key();
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: need_full_redraw_ = true; return false;
default: break;
}
}
}
std::string FileManager::overlay_picker(const std::string &title,
std::vector<std::string> items,
bool allow_cancel,
const std::function<void(const std::string &)> &on_delete) {
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 = get_key();
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 KEY_DC:
if (on_delete && !items.empty()) {
on_delete(items[psel]);
items.erase(items.begin() + psel);
nitems = static_cast<int>(items.size());
if (nitems == 0) { need_full_redraw_ = true; return ""; }
}
break;
case 27: need_full_redraw_ = true; return "";
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 = get_key();
if (ch == 27) { // bare ESC — cancel
leaveok(stdscr, TRUE);
curs_set(0);
out.clear();
return false;
} 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
if (!target.empty() && 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 = get_key();
if (ch == 27) {
// Bare ESC — clear filter and exit search
filter_.clear();
searching_ = false;
apply_filter();
curs_set(0);
need_full_redraw_ = true;
} 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
get_key();
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 open bookmark picker", false, false},
{" B bookmark current dir", 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
get_key();
need_full_redraw_ = true;
}
void FileManager::do_rename() {
const Entry *e = get_sel();
if (!e) return;
if (access(cwd_.c_str(), W_OK) != 0) {
flash_msg("permission denied: " + cwd_);
return;
}
// Strip trailing /
std::string name = e->name;
if (!name.empty() && 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: " + std::string(std::strerror(errno)));
}
}
void FileManager::do_mkdir() {
if (access(cwd_.c_str(), W_OK) != 0) {
flash_msg("permission denied: " + cwd_);
return;
}
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: " + std::string(std::strerror(errno)));
}
}
void FileManager::do_newfile() {
if (access(cwd_.c_str(), W_OK) != 0) {
flash_msg("permission denied: " + cwd_);
return;
}
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: " + std::string(std::strerror(errno)));
}
}
void FileManager::do_delete() {
if (access(cwd_.c_str(), W_OK) != 0) {
flash_msg("permission denied: " + cwd_);
return;
}
// 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(sel_name, "/")));
} else {
const Entry *e = get_sel();
if (!e) return;
std::string n = e->name;
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;
if (access(cwd_.c_str(), W_OK) != 0) {
flash_msg("permission denied: " + cwd_);
return;
}
std::string name = e->name;
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: " + std::string(std::strerror(errno)));
}
}
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;
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;
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: " + std::string(std::strerror(errno)));
}
}
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;
}
auto remove_bookmark = [&](const std::string &path) {
std::vector<std::string> kept;
std::ifstream fin(bookmark_file_);
std::string l;
while (std::getline(fin, l))
if (l != path) kept.push_back(l);
fin.close();
std::ofstream fout(bookmark_file_, std::ios::trunc);
for (const auto &k : kept) fout << k << '\n';
flash_msg("bookmark removed: " + path);
};
std::string chosen = overlay_picker(" BOOKMARKS", bookmarks, true, remove_bookmark);
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();
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();
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();
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();
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();
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;
}
if (access(cwd_.c_str(), W_OK) != 0) {
flash_msg("permission denied: " + cwd_);
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 ────────────────────────────────────────────────────────
bool FileManager::handle_key(int key) {
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_jump(); break;
case 'B': do_bookmark_add(); 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 false;
case '!': do_shell(); break;
default: break;
}
return true;
}
void FileManager::run() {
setup_term();
if (can_color()) init_colors();
load_entries();
is_root_ = (geteuid() == 0);
#ifndef NCURSES_VERSION
struct sigaction sa;
std::memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_sigwinch;
sigaction(SIGWINCH, &sa, nullptr);
#endif
draw();
while (true) {
#ifndef NCURSES_VERSION
// netbsd-curses never delivers KEY_RESIZE; poll for SIGWINCH and
// re-query the terminal size ourselves.
if (g_winch) {
g_winch = 0;
endwin(); // endwin()+refresh() makes curses re-read the size
refresh();
need_full_redraw_ = true;
draw();
}
nodelay(stdscr, TRUE);
#endif
int key = get_key();
#ifndef NCURSES_VERSION
nodelay(stdscr, FALSE);
if (key == ERR) { nap_ms(20); continue; }
#endif
// Handle terminal resize (ncurses)
if (key == KEY_RESIZE) {
update_size();
need_full_redraw_ = true;
draw();
continue;
}
if (!handle_key(key)) return;
draw();
}
}
// ─── 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;
}