3 Commits
Author SHA1 Message Date
emmett1 c6f4023768 updated 2026-08-16 11:52:05 +08:00
emmett1 cc0dbb0cad fix 2026-07-23 12:08:05 +08:00
emmett1 cd2a4dffb8 added permission error messsage, fix pane line, add root message if running as root 2026-06-20 16:07:40 +08:00
3 changed files with 335 additions and 167 deletions
+5 -2
View File
@@ -7,7 +7,10 @@ DOCDIR ?= $(PREFIX)/share/doc/sfm
CXX ?= g++
CXXFLAGS ?= -std=c++17 -O2 -Wall -Wextra
LDFLAGS ?= -lncursesw
# Pick a curses library: prefer ncursesw, fall back to ncurses, then plain
# curses (netbsd-curses and BSD base systems provide only libcurses).
CURSES_LIBS ?= $(shell pkg-config --libs ncursesw 2>/dev/null || pkg-config --libs ncurses 2>/dev/null || echo -lcurses)
LDLIBS ?= $(CURSES_LIBS)
INSTALL ?= install
RM ?= rm -f
@@ -17,7 +20,7 @@ RM ?= rm -f
all: sfm
sfm: sfm.cpp
$(CXX) $(CXXFLAGS) -o $@ $< $(LDFLAGS)
$(CXX) $(CXXFLAGS) -o $@ $< $(LDLIBS)
install: all
@echo "Installing sfm to $(DESTDIR)$(BINDIR)/sfm ..."
+5 -4
View File
@@ -5,15 +5,16 @@ sfm - Simple File Manager
DESCRIPTION
-----------
sfm is a lightweight, terminal-based file manager with a vim-inspired
key layout. The primary version is a fast C++/ncurses binary. A portable
POSIX sh version (sfm.sh) is also included, requiring only standard Unix
tools (ls, awk, tput, stty, mv, cp, rm).
key layout. The primary version is a fast C++ binary that works with
both ncurses and netbsd-curses. A portable POSIX sh version (sfm.sh) is
also included, requiring only standard Unix tools (ls, awk, tput, stty,
mv, cp, rm).
REQUIREMENTS
------------
C++ version (sfm):
- ncursesw (libncursesw)
- A curses library: ncursesw, ncurses, or netbsd-curses (libcurses)
- A C++17 compiler (g++ or compatible)
Shell version (sfm.sh):
+268 -104
View File
@@ -1,6 +1,7 @@
// sfm - Simple File Manager in C++17 with ncurses
// sfm - Simple File Manager in C++17 with curses
// Works with both ncurses and netbsd-curses.
// Rewrite of the POSIX sh original for speed and efficiency
#include <ncurses.h>
#include <curses.h>
#include <algorithm>
#include <cerrno>
@@ -18,6 +19,7 @@
#include <fcntl.h>
#include <grp.h>
#include <pwd.h>
#include <signal.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
@@ -27,6 +29,140 @@ namespace fs = std::filesystem;
// ─── terminal helpers ───────────────────────────────────────────────────────
[[nodiscard]] bool can_color() { return has_colors(); }
// ─── key input normalization ────────────────────────────────────────────────
// ncurses decodes escape sequences (arrows, Home/End, ...) into KEY_* codes
// itself. netbsd-curses returns the raw bytes (e.g. ESC [ B for the down
// arrow), which would leak stray characters into the command handling.
// get_key() presents KEY_* codes (or 27 for a bare ESC) on both.
#ifdef NCURSES_VERSION
static int get_key() { return getch(); }
#else // manual escape-sequence decoding (netbsd-curses etc.)
static void nap_ms(int ms) {
struct timespec ts;
ts.tv_sec = ms / 1000;
ts.tv_nsec = (ms % 1000) * 1000000L;
::nanosleep(&ts, nullptr);
}
static int g_pushback[32];
static int g_pushback_n = 0;
static void queue_key(int k) {
if (g_pushback_n < static_cast<int>(sizeof(g_pushback) / sizeof(g_pushback[0])))
g_pushback[g_pushback_n++] = k;
}
// seq holds the bytes after ESC; seq[0] is '[' (CSI) or 'O' (SS3).
static int decode_escape_seq(const std::string &seq) {
if (seq.size() < 2) return ERR;
char lead = seq[0];
char fin = seq.back();
if (lead == 'O') {
if (seq.size() != 2) return ERR;
switch (fin) {
case 'A': return KEY_UP;
case 'B': return KEY_DOWN;
case 'C': return KEY_RIGHT;
case 'D': return KEY_LEFT;
case 'H': return KEY_HOME;
case 'F': return KEY_END;
case 'P': case 'Q': case 'R': case 'S': return KEY_F(fin - 'P' + 1);
}
return ERR;
}
if (lead != '[') return ERR;
switch (fin) {
case 'A': return KEY_UP;
case 'B': return KEY_DOWN;
case 'C': return KEY_RIGHT;
case 'D': return KEY_LEFT;
case 'H': return KEY_HOME;
case 'F': return KEY_END;
case 'Z': return KEY_BTAB;
}
// CSI n~ : Home/Insert/Delete/End/PgUp/PgDn (single digit only; multi-
// digit and modified variants fall through and are swallowed).
if (seq.size() == 3 && fin == '~' && seq[1] >= '0' && seq[1] <= '9') {
switch (seq[1]) {
case '1': case '7': return KEY_HOME;
case '2': return KEY_IC;
case '3': return KEY_DC;
case '4': case '8': return KEY_END;
case '5': return KEY_PPAGE;
case '6': return KEY_NPAGE;
}
}
return ERR;
}
// Like getch(), but assembles raw ESC sequences into KEY_* codes. A bare ESC
// is returned as 27 (possibly after a short wait for the rest of a sequence).
static int get_key() {
if (g_pushback_n > 0) return g_pushback[--g_pushback_n];
int k = getch();
if (k != 27) return k;
// ESC — check whether an escape sequence follows. Real terminals send
// the whole sequence in one burst, so the bytes are already queued.
nodelay(stdscr, TRUE);
std::string seq;
int c;
for (int i = 0; i < 4 && seq.empty(); ++i) {
c = getch();
if (c == ERR) nap_ms(10);
else seq += static_cast<char>(c);
}
if (seq.empty()) { nodelay(stdscr, FALSE); return 27; } // bare ESC
if (seq[0] == '[') {
// CSI: parameter/intermediate bytes, then a final byte (0x40-0x7E).
// The lead '[' itself is in that range, so collect at least one more.
for (int i = 0; i < 8; ++i) {
if (seq.size() >= 2 && seq.back() >= 0x40 && seq.back() <= 0x7E) break;
c = getch();
if (c == ERR) { nap_ms(10); c = getch(); }
if (c == ERR) break;
seq += static_cast<char>(c);
}
} else if (seq[0] == 'O') {
// SS3: exactly one final byte follows.
for (int i = 0; i < 4 && seq.size() < 2; ++i) {
c = getch();
if (c == ERR) { nap_ms(10); c = getch(); }
if (c == ERR) break;
seq += static_cast<char>(c);
}
}
nodelay(stdscr, FALSE);
int key = decode_escape_seq(seq);
if (key == ERR) {
bool complete = seq.back() >= 0x40 && seq.back() <= 0x7E;
if (!complete) {
// Truncated sequence — replay it as raw keys (ESC first).
for (size_t i = seq.size(); i-- > 0;)
queue_key(static_cast<unsigned char>(seq[i]));
queue_key(27);
return 27;
}
// Unknown but well-formed sequence (e.g. an F-key): swallow it,
// like curses implementations do.
return ERR;
}
return key;
}
// netbsd-curses never reports KEY_RESIZE; catch SIGWINCH ourselves.
static volatile sig_atomic_t g_winch = 0;
static void on_sigwinch(int) { g_winch = 1; }
#endif // NCURSES_VERSION
// Color pair indices
enum {
CP_DIR = 1,
@@ -42,6 +178,7 @@ enum {
CP_OVERLAY_TITLE,
CP_BUTTON_YES,
CP_BUTTON_NO,
CP_ROOT_WARN,
};
#define ATTR_DIR (COLOR_PAIR(CP_DIR) | A_BOLD)
@@ -54,12 +191,13 @@ enum {
#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 name; // display name (dirs end with '/')
std::string full_path; // absolute canonical path
EntryType type = EntryType::File;
bool is_hidden = false;
@@ -154,6 +292,7 @@ private:
bool show_details_ = false;
bool show_preview_ = false;
bool searching_ = false;
bool is_root_ = false;
std::string filter_;
std::string info_msg_;
@@ -197,6 +336,9 @@ private:
const std::vector<std::string> &items,
bool allow_cancel = true);
// ── input ──────────────────────────────────────────────────────────────
bool handle_key(int key); // returns false to quit
// ── line input ─────────────────────────────────────────────────────────
bool read_line(std::string &out, const std::string &prompt,
const std::string &initial = "");
@@ -295,6 +437,8 @@ void FileManager::restore_term() {
curs_set(1);
endwin();
}
// Clear screen and move cursor home (visible in scrollback otherwise)
printf("\033[2J\033[H");
}
void FileManager::update_size() {
@@ -321,6 +465,7 @@ void FileManager::init_colors() {
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 ──────────────────────────────────────────────────────
@@ -359,7 +504,6 @@ void FileManager::load_entries() {
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; }
@@ -554,13 +698,22 @@ void FileManager::draw_botbar() {
right = " press ? for help ";
}
int total = static_cast<int>(left.size() + right.size());
std::string root_tag = is_root_ ? "[root] " : "";
int total = static_cast<int>(root_tag.size() + left.size() + right.size());
int pad = cols_ - total;
if (pad < 0) pad = 0;
int x = 0;
if (is_root_) {
attron(ATTR_ROOT_WARN);
mvprintw(rows_ - 1, 0, "%s", root_tag.c_str());
attroff(ATTR_ROOT_WARN);
x = static_cast<int>(root_tag.size());
}
attron(ATTR_TOPBAR);
mvprintw(rows_ - 1, 0, "%s", left.c_str());
for (int i = 0; i < pad; ++i) mvaddch(rows_ - 1, static_cast<int>(left.size()) + i, ' ');
mvprintw(rows_ - 1, x, "%s", left.c_str());
for (int i = 0; i < pad; ++i) mvaddch(rows_ - 1, x + static_cast<int>(left.size()) + i, ' ');
attroff(ATTR_TOPBAR);
attron(ATTR_INFO);
@@ -711,11 +864,10 @@ void FileManager::draw_preview() {
if (!e) return;
// Draw vertical divider
for (int r = 1; r < rows_ - 1; ++r) {
attron(ATTR_DIVIDER);
mvaddch(r, list_cols_, '|');
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) {
@@ -932,7 +1084,7 @@ bool FileManager::confirm_overlay(const std::string &prompt) {
refresh();
int ch = getch();
int ch = get_key();
switch (ch) {
case KEY_LEFT: case 'h': sel_yes = true; break;
case KEY_RIGHT: case 'l': sel_yes = false; break;
@@ -943,18 +1095,7 @@ bool FileManager::confirm_overlay(const std::string &prompt) {
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;
}
case 27: need_full_redraw_ = true; return false;
default: break;
}
}
@@ -1053,7 +1194,7 @@ std::string FileManager::overlay_picker(const std::string &title,
refresh();
int ch = getch();
int ch = get_key();
switch (ch) {
case 'j': case KEY_DOWN: psel++; break;
case 'k': case KEY_UP: psel--; break;
@@ -1064,20 +1205,7 @@ std::string FileManager::overlay_picker(const std::string &title,
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;
}
case 27: need_full_redraw_ = true; return "";
default: break;
}
}
@@ -1113,18 +1241,12 @@ bool FileManager::read_line(std::string &out, const std::string &prompt,
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) {
int ch = get_key();
if (ch == 27) { // bare ESC — cancel
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);
@@ -1175,8 +1297,8 @@ void FileManager::do_open() {
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() == '@'))
// Strip trailing / for directory
if (!target.empty() && target.back() == '/')
target.pop_back();
switch (e->type) {
@@ -1294,20 +1416,14 @@ void FileManager::do_search() {
need_full_redraw_ = true;
draw();
int ch = getch();
if (ch == 27) { // ESC
nodelay(stdscr, TRUE);
int n = getch();
nodelay(stdscr, FALSE);
if (n == ERR) {
int ch = get_key();
if (ch == 27) {
// Bare ESC — clear filter and exit search
filter_.clear();
searching_ = false;
apply_filter();
curs_set(0);
need_full_redraw_ = true;
}
// else: arrow key or other sequence — ignore
} else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) {
searching_ = false;
curs_set(0);
@@ -1462,13 +1578,7 @@ void FileManager::do_info() {
refresh();
// Wait for any key
nodelay(stdscr, FALSE);
int ch = getch();
if (ch == 27) {
nodelay(stdscr, TRUE);
getch();
nodelay(stdscr, FALSE);
}
get_key();
need_full_redraw_ = true;
}
@@ -1585,22 +1695,22 @@ void FileManager::do_help() {
refresh();
// Wait for any key
nodelay(stdscr, FALSE);
int ch = getch();
if (ch == 27) {
nodelay(stdscr, TRUE);
getch();
nodelay(stdscr, FALSE);
}
get_key();
need_full_redraw_ = true;
}
void FileManager::do_rename() {
const Entry *e = get_sel();
if (!e) return;
// Strip trailing / or @
if (access(cwd_.c_str(), W_OK) != 0) {
flash_msg("permission denied: " + cwd_);
return;
}
// Strip trailing /
std::string name = e->name;
if (!name.empty() && (name.back() == '/' || name.back() == '@'))
if (!name.empty() && name.back() == '/')
name.pop_back();
std::string new_name;
@@ -1612,11 +1722,16 @@ void FileManager::do_rename() {
if (std::rename(src.c_str(), dst.c_str()) == 0) {
load_entries();
} else {
flash_msg("rename failed");
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; }
@@ -1625,11 +1740,16 @@ void FileManager::do_mkdir() {
if (fs::create_directory(path)) {
load_entries();
} else {
flash_msg("mkdir failed");
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; }
@@ -1640,22 +1760,27 @@ void FileManager::do_newfile() {
f.close();
load_entries();
} else {
flash_msg("cannot create file");
flash_msg("cannot create file: " + std::string(std::strerror(errno)));
}
}
void FileManager::do_delete() {
if (access(cwd_.c_str(), W_OK) != 0) {
flash_msg("permission denied: " + cwd_);
return;
}
// Gather items to delete
std::vector<std::string> targets;
if (!selected_.empty()) {
for (const auto &sel_name : selected_)
targets.push_back(join_path(cwd_, strip_suffix(strip_suffix(sel_name, "@"), "/")));
targets.push_back(join_path(cwd_, strip_suffix(sel_name, "/")));
} else {
const Entry *e = get_sel();
if (!e) return;
std::string n = e->name;
if (!n.empty() && n.back() == '/') n.pop_back();
if (!n.empty() && n.back() == '@') n.pop_back();
targets.push_back(join_path(cwd_, n));
}
@@ -1709,9 +1834,14 @@ void FileManager::do_delete() {
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);
@@ -1743,7 +1873,7 @@ void FileManager::do_trash() {
sel_ = std::max(0, static_cast<int>(entries_.size()) - 2);
load_entries();
} else {
flash_msg("trash failed");
flash_msg("trash failed: " + std::string(std::strerror(errno)));
}
}
@@ -1758,7 +1888,6 @@ void FileManager::do_open_with() {
return;
}
std::string name = e->name;
if (!name.empty() && name.back() == '@') name.pop_back();
std::string target = join_path(cwd_, name);
std::string prog;
@@ -1788,7 +1917,6 @@ void FileManager::do_chmod_x(bool set) {
return;
}
std::string name = e->name;
if (!name.empty() && name.back() == '@') name.pop_back();
std::string target = join_path(cwd_, name);
struct stat st;
@@ -1805,7 +1933,7 @@ void FileManager::do_chmod_x(bool set) {
flash_msg(set ? "chmod +x: " + e->name : "chmod -x: " + e->name);
load_entries();
} else {
flash_msg("chmod failed");
flash_msg("chmod failed: " + std::string(std::strerror(errno)));
}
}
@@ -1911,7 +2039,6 @@ void FileManager::do_copy_path() {
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
@@ -1971,7 +2098,7 @@ void FileManager::do_yank() {
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;
@@ -1982,7 +2109,7 @@ void FileManager::do_yank() {
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);
@@ -1996,7 +2123,7 @@ void FileManager::do_cut() {
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;
@@ -2007,7 +2134,7 @@ void FileManager::do_cut() {
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);
@@ -2021,6 +2148,11 @@ void FileManager::do_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);
@@ -2286,22 +2418,7 @@ void FileManager::open_file(const std::string &path) {
}
// ─── 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;
}
bool FileManager::handle_key(int key) {
switch (key) {
case 'j': case KEY_DOWN:
if (!entries_.empty() && sel_ < static_cast<int>(entries_.size()) - 1)
@@ -2356,10 +2473,57 @@ void FileManager::run() {
case 'r': do_rename(); break;
case 'm': do_mkdir(); break;
case 'n': do_newfile(); break;
case 'q': case 'Q': return;
case 'q': case 'Q': return false;
case '!': do_shell(); break;
default: break;
}
return true;
}
void FileManager::run() {
setup_term();
if (can_color()) init_colors();
load_entries();
is_root_ = (geteuid() == 0);
#ifndef NCURSES_VERSION
struct sigaction sa;
std::memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_sigwinch;
sigaction(SIGWINCH, &sa, nullptr);
#endif
draw();
while (true) {
#ifndef NCURSES_VERSION
// netbsd-curses never delivers KEY_RESIZE; poll for SIGWINCH and
// re-query the terminal size ourselves.
if (g_winch) {
g_winch = 0;
endwin(); // endwin()+refresh() makes curses re-read the size
refresh();
need_full_redraw_ = true;
draw();
}
nodelay(stdscr, TRUE);
#endif
int key = get_key();
#ifndef NCURSES_VERSION
nodelay(stdscr, FALSE);
if (key == ERR) { nap_ms(20); continue; }
#endif
// Handle terminal resize (ncurses)
if (key == KEY_RESIZE) {
update_size();
need_full_redraw_ = true;
draw();
continue;
}
if (!handle_key(key)) return;
draw();
}
}