// sfm - Simple File Manager in C++17 with ncurses // Rewrite of the POSIX sh original for speed and efficiency #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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, 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 '/', 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(std::tolower(static_cast(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 all_entries_; // source of truth (unfiltered) std::vector 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 selected_; // entry display names std::vector 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 &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 &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); 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; 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(i); } return -1; } const Entry* FileManager::get_sel() const { if (entries_.empty() || sel_ < 0 || sel_ >= static_cast(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(entries_.size())) sel_ = static_cast(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(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(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(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(left.size()) + i, ' '); attroff(ATTR_TOPBAR); attron(ATTR_INFO); mvprintw(rows_ - 1, cols_ - static_cast(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(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(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(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(detail.size()); int maxw = cols - 2 - dlen; if (maxw < 4) maxw = 4; // Truncate display name if needed std::string show = display; if (static_cast(show.size()) > maxw) { show.resize(maxw > 3 ? maxw - 3 : 0); show += "..."; } int name_w = static_cast(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(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(show.size()) + p, ' '); if (!detail_str.empty()) { attron(attr & ~A_REVERSE); if (highlighted) attron(A_REVERSE); mvaddstr(row, 1 + static_cast(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(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 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(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(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 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(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(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(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(std::strlen(yes_lbl)); int no_w = static_cast(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 &items, bool allow_cancel) { if (items.empty()) return ""; int nitems = static_cast(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(label.size()); if (lw > iw - 1) label = label.substr(0, iw - 2) + "~"; int pad_r = iw - 1 - static_cast(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(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(prompt.size() + out.size()); for (int i = used; i < cols_; ++i) addch(' '); attroff(ATTR_NORMAL); move(bot, static_cast(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(out.size())) pos++; } else if (ch == KEY_HOME || ch == 1) { pos = 0; } else if (ch == KEY_END || ch == 5) { pos = static_cast(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(out.size())) out.erase(pos, 1); } else if (ch >= 32 && ch < 127) { out.insert(pos, 1, static_cast(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(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 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(total); } else { sz = static_cast(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(l.size()) > maxw) maxw = l.size(); int nlines = static_cast(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(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; if (access(cwd_.c_str(), W_OK) != 0) { flash_msg("permission denied: " + cwd_); 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: " + 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 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(entries_.size()) - 1) sel_ = std::max(0, static_cast(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(); 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(entries_.size()) - 1) sel_ = std::max(0, static_cast(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; 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: " + 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 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 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 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(entries_.size())) sel_ = std::max(0, static_cast(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; } 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 &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 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 editors; if (ed) editors.push_back(ed); editors.push_back("vi"); try_run(editors, path, false); return; } } // 3. Images → GUI { static const std::vector 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 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 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 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 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 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 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(); is_root_ = (geteuid() == 0); 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(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(entries_.size()) - 1; break; case KEY_NPAGE: sel_ = std::min(sel_ + rows_ / 2, std::max(0, static_cast(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; }