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