2539 lines
84 KiB
C++
2539 lines
84 KiB
C++
// 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 <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,
|
||
const std::vector<std::string> &items,
|
||
bool allow_cancel = true);
|
||
|
||
// ── 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 (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
|
||
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,
|
||
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 = 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 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 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
|
||
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;
|
||
}
|
||
|
||
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();
|
||
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_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 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;
|
||
}
|