/* The MIT License (MIT) Copyright © 2020-2025 pacman64 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /* You can build this command-line app by running c++ -Wall -s -O3 -march=native -mtune=native -flto -o bf bf.cpp -lncursesw */ #include #include #include #include #include #include #include #include #include #include #include #include #ifdef RED_ERRORS #define ERROR_STYLE "\x1b[38;2;204;0;0m" #ifdef __APPLE__ #define ERROR_STYLE "\x1b[31m" #endif #define RESET_STYLE "\x1b[0m" #else #define ERROR_STYLE "" #define RESET_STYLE "" #endif #define ERROR_LINE(MSG) (ERROR_STYLE MSG RESET_STYLE "\n") #define NO_CHOICE 1 #define BAD_ALLOC 2 #define BAD_ARGS 3 #define BAD_TTY 4 #define NOT_A_FILE 5 #ifndef IBUF_SIZE #define IBUF_SIZE (32 * 1024) #endif #define FILE_SIZE_ROOM 16 #ifndef MAX_LINES #define MAX_LINES (500 * 1000) #endif #ifndef MAX_BYTES #define MAX_BYTES (500 * 1024 * 1024) #endif using namespace std; vector help = { "bf [option...] [folder...]", "", "", "Browse Folders is a text user-interface (TUI) to do just that. By default", "it starts browsing from the current folder, but you can choose a different", "starting point as an optional cmd-line argument when starting this script.", "", "", " Enter Quit this app, emitting the currently-selected entry", " Escape Quit this app without emitting an entry; quit text viewers", " F1 Toggle help-message screen; the Escape key also quits it", " F5 Update current-folder entries, in case they've changed", " F6 Toggle size-sorting mode, and update current-folder entries", " F10 Quit this app without emitting an entry; quit text viewers", " F12 Quit this app without emitting an entry; quit text viewers", "", " Left Go to the current folder's parent folder", " Right Go to the currently-selected folder", " Backspace Go to the current folder's parent folder", " Tab Go to the currently-selected folder", "", " Home Select the first entry in the current folder", " End Select the last entry in the current folder", " Up Select the entry before the currently selected one", " Down Select the entry after the currently selected one", " Page Up Select entry by jumping one screen backward", " Page Down Select entry by jumping one screen forward", "", " [a-z0-9] Select next entry starting with letter/digit", "", "", "Escape quits the app without emitting the currently-selected item and with", "an error-code, while Enter emits the selected item, quitting successfully.", "", "Folders are shown without a file-size, and are always shown before files.", "", "Some file/folder entries may be special and/or give an error when queried", "for their file-size: these are shown with a question mark where their size", "would normally be.", "", "Entering text files lets you view and scroll their lines up and down.", "", "The right side of the screen also shows little symbols when there are more", "entries before/after the ones currently showing.", "", "The only options are:", "", " -c, --c emit file contents instead of file/folder paths", " -f, --f emit file contents instead of file/folder paths", "", " -h, --h show this help message and quit", " -help, --help show this help message and quit", "", "You can also view this help message by pressing the F1 key while browsing", "folders: the help viewer works the same as the text-file viewer; you can", "quit the help screen with the Escape key, or with the F1 key.", "", "When things have changed in the current folder, you can press the F5 key", "to reload the entries on screen, so there's no need to manually get out", "and back into the current folder as a workaround.", "", "When given a file name, instead of a starting folder, this app tries to", "load it as plain-text, letting you scroll up/down its lines, unless the", "file is too big or has too many lines.", }; bool ends_with(const char* s, char what) { if (s == NULL) { return false; } char last = 0; for (; *s != 0; s++) { last = *s; } return last == what; } char* clone(const char* s) { if (s == NULL) { return NULL; } const auto n = strlen(s); char* p = static_cast(malloc(n + 1)); if (p != NULL) { strcpy(p, s); } return p; } bool entry_info(const char* path, size_t* size, bool* is_file) { struct stat st; if (stat(path, &st) != 0) { return false; } if (S_ISDIR(st.st_mode)) { *size = 0; *is_file = false; return true; } *is_file = true; *size = st.st_size; return true; } bool is_folder(const char* path) { size_t size = 0; bool is_file = false; return entry_info(path, &size, &is_file) && !is_file; } struct entry { char* name; // relative name of file/folder ssize_t size; // file-size, or negative value if entry is a folder }; struct browser { char* folder; // full path of the current folder vector entries; // cache of the current/latest folder entries vector stack; // history of folder names up to the current one char* result; // the filepath picked, if any size_t width; // current number of visible cells per line in terminal size_t height; // current number of visible lines in terminal size_t top; // index of the top entry showing on screen size_t selection; // index of the highlighted entry int exit_code; bool sort_size; // whether to reverse-sort file entries by size bool help_mode; // whether currently showing the help screen }; bool change_folder(browser& b, const char* path) { if (chdir(path) != 0) { return false; } char name[PATH_MAX]; if (getcwd(name, sizeof(name)) == NULL) { return false; } free(b.folder); b.folder = clone(name); return true; } ssize_t find_name(const browser& b, const char* name) { for (size_t i = 0; i < b.entries.size(); i++) { if (strcmp(name, b.entries[i].name)) { return i; } } return -1; } // find the index of the top item which would show on the last page ssize_t max_top(const browser& b, size_t length) { if (length == 0 || length + 1 <= b.height) { return 0; } return length - (b.height - 1); } bool isalphanum(int n) { return false || ('A' <= n && n <= 'Z') || ('a' <= n && n <= 'z') || ('0' <= n && n <= '9'); } ssize_t seek_initial_next(const browser& b, char lead, ssize_t start) { const ssize_t n = b.entries.size(); if (start < 0) { start = -1; } if (start >= n) { start = -1; } lead = tolower(lead); for (ssize_t i = start + 1; i < n; i++) { const char* s = b.entries[i].name; if (tolower(s[0]) == lead) { return i; } } for (ssize_t i = 0; i < start; i++) { const char* s = b.entries[i].name; if (tolower(s[0]) == lead) { return i; } } return -1; } void write_spaces(ssize_t n) { static const char spaces[256] = { 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, }; const ssize_t k = sizeof(spaces); for (; n >= k; n -= k) { addnstr(spaces, k); } if (n > 0) { addnstr(spaces, n); } } // show a file size using commas between thousands, millions, etc. void write_commas_uint(size_t n) { size_t digits = 0; // 20 is the most digits unsigned 64-bit ints can ever need char buf[24]; if (n == 0) { buf[sizeof(buf) - 1] = '0'; digits++; } for (; n > 0; digits++, n /= 10) { buf[sizeof(buf) - 1 - digits] = (n % 10) + '0'; } // 32 is more than 20 digits and 6 commas can ever need char result[32]; // now emit the leading digits, which will be fewer than 3 size_t leading = digits % 3; char* start = buf + sizeof(buf) - digits; memcpy(result, start, leading); start += leading; digits -= leading; // now the number of digits left is a multiple of 3 size_t i = leading; for (; digits > 0; start += 3, digits -= 3) { if (i > 0) { result[i++] = ','; } result[i++] = start[0]; result[i++] = start[1]; result[i++] = start[2]; } // result[i] = 0; write_spaces(FILE_SIZE_ROOM - i); addnstr(result, i); } // forward-sort file entries by name void sort_names(vector& files) { sort(files.begin(), files.end(), [](const auto& x, const auto& y) { return strcmp(x.name, y.name) < 0; }); } // backward-sort file entries by size void sort_sizes(vector& files) { sort(files.begin(), files.end(), [](const auto& x, const auto& y) { return y.size < x.size; }); } void hightlight_top(const browser& b, const char* s, size_t len) { if (len == 0) { len = strlen(s); } if (len > b.width) { len = b.width; } move(0, 0); write_spaces(b.width); move(0, 0); attron(A_REVERSE); addnstr(s, len); attroff(A_REVERSE); } // update folder entries, without showing anything; file entries are sorted void update_entries(browser& b, char* folder, DIR* entries) { string full_path; vector folders; vector files; while (true) { const auto item = readdir(entries); if (item == NULL) { break; } const auto name = item->d_name; // ignore entries `.` and `..` if (name[0] == '.') { if ((name[1] == 0) || (name[1] == '.' && name[2] == 0)) { continue; } } full_path.clear(); full_path.append(folder); full_path.push_back('/'); full_path.append(name); size_t size = 0; bool is_file = false; if (!entry_info(full_path.c_str(), &size, &is_file)) { continue; } if (is_file) { files.push_back(entry{clone(name), (ssize_t)size}); } else { folders.push_back(clone(name)); } } sort(folders.begin(), folders.end(), [](const auto& x, const auto& y) { return strcmp(x, y) < 0; }); if (b.sort_size) { sort_sizes(files); } else { sort_names(files); } b.entries.clear(); b.entries.reserve(folders.size() + files.size()); for (const auto& s : folders) { b.entries.push_back(entry{s, -1}); } b.entries.insert(b.entries.end(), files.begin(), files.end()); } void show_entry(const entry& e, size_t n) { if (e.size >= 0) { write_commas_uint(e.size); write_spaces(2); } else { write_spaces(FILE_SIZE_ROOM + 2); } addnstr(e.name, n - FILE_SIZE_ROOM - 2); } void update_arrows(const browser& b, ssize_t top, size_t n) { if (b.width < 2 || b.height < 1) { return; } move(0, b.width - 1); addstr((top > 0) ? static_cast("▲") : static_cast(" ")); move(b.height - 1, b.width - 1); addstr((top + b.height - 1 < n) ? static_cast("▼") : static_cast(" ")); } // show a screenful of folder entries from scratch, clearing previously-shown // contents void show_entries(browser& b) { const auto w = b.width; const auto h = b.height; const auto max_w = w - 2; const auto n = b.entries.size(); clear(); move(0, 0); addnstr(b.folder, max_w); for (size_t i = b.top, row = 1; i < n && row < h; i++, row++) { move(row, 0); if (i - b.top == b.selection) { attron(A_REVERSE); show_entry(b.entries[i], max_w); attroff(A_REVERSE); } else { show_entry(b.entries[i], max_w); } } update_arrows(b, b.top, b.entries.size()); } bool handle_folder(browser& b) { if (b.folder == NULL) { return false; } DIR* entries = opendir(b.folder); if (entries == NULL) { return false; } update_entries(b, b.folder, entries); closedir(entries); show_entries(b); refresh(); return true; } bool is_selection_showing(const browser& b, ssize_t i) { return i >= (ssize_t)b.top && (size_t)i < b.top + b.height - 1; } void show_plain_entry(const browser& b, ssize_t i) { if (!is_selection_showing(b, i)) { return; } move(i + 1 - b.top, 0); write_spaces(b.width); const auto& e = b.entries[i]; move(i + 1 - b.top, 0); if (e.size >= 0) { write_commas_uint(e.size); write_spaces(2); } else { write_spaces(FILE_SIZE_ROOM + 2); } addnstr(e.name, b.width - FILE_SIZE_ROOM - 2 - 2); } void show_selected_entry(const browser& b, ssize_t i) { if (!is_selection_showing(b, i)) { return; } move(i + 1 - b.top, 0); write_spaces(b.width); const auto& e = b.entries[i]; move(i + 1 - b.top, 0); attron(A_REVERSE); if (e.size >= 0) { write_commas_uint(e.size); write_spaces(2); } else { write_spaces(FILE_SIZE_ROOM + 2); } addnstr(e.name, b.width - FILE_SIZE_ROOM - 2 - 2); attroff(A_REVERSE); } // calculate the index of the top item of the currently showing page size_t get_page_top(const browser& b, ssize_t i) { if (0 <= i && (size_t)i < b.entries.size()) { return i - (i % (b.height - 1)); } return -1; } // change currently selected item among folder entries void handle_select(browser& b, ssize_t i) { const auto n = b.entries.size(); if (n == 0) { return; } if (i < 0) { i = n - 1; } else if (i >= (ssize_t)n) { i = 0; } const auto p1 = get_page_top(b, b.selection); const auto p2 = get_page_top(b, i); if (p1 == p2) { show_plain_entry(b, b.selection); show_selected_entry(b, i); b.selection = i; } else { b.top = p2; b.selection = i; show_entries(b); show_selected_entry(b, i); } update_arrows(b, b.top, b.entries.size()); refresh(); } void handle_page_change(browser& b, ssize_t i) { const auto n = b.entries.size(); if (n == 0) { return; } if (i < 0) { i = 0; } handle_select(b, get_page_top(b, i)); } void pick(browser& b) { free(b.result); b.exit_code = 0; if (b.selection < b.entries.size()) { b.result = clone(b.entries[b.selection].name); } else { b.result = clone(b.folder); } } void unpick(browser& b) { free(b.result); b.result = NULL; b.exit_code = NO_CHOICE; } // remove leading UTF-8 BOM bytes if present void de_bom(string &s) { const auto n = s.size(); if (n >= 3 && s[0] == '\xef' && s[1] == '\xbb' && s[2] == '\xbf') { s.erase(0, 3); } } // remove trailing carriage-return byte if present void de_cr(string &s) { s.erase(remove(s.begin(), s.end(), '\r'), s.end()); } bool load_lines(const char* path, vector& lines) { size_t bytes; bool is_file = false; if (!entry_info(path, &bytes, &is_file)) { return false; } if (!is_file || bytes > MAX_BYTES) { return false; } string line; ifstream r(path); if (!r.is_open()) { return false; } if (!getline(r, line)) { r.close(); return true; } de_bom(line); de_cr(line); lines.push_back(line); // try to load everything, but give up after loading too many lines for (size_t n = 1; getline(r, line) && n <= MAX_LINES; n++) { de_cr(line); lines.push_back(line); } r.close(); return true; } void show_lines(const browser& b, vector& lines, size_t top) { const auto w = b.width; const auto h = b.height; const auto max_w = w; const size_t n = lines.size(); for (size_t i = top, row = 1; i < n && row < h; i++, row++) { move(row, 0); write_spaces(max_w); move(row, 0); addnstr(lines[i].c_str(), max_w); } update_arrows(b, top, n); } bool view_help(browser& b); bool view_lines(browser& b, const string& title, vector& lines) { ssize_t top = 0; const char* top_title = title.c_str(); clear(); hightlight_top(b, top_title, title.size()); show_lines(b, lines, top); refresh(); while (true) { const auto got = getch(); switch (got) { case '\x1b': if (b.help_mode) { b.help_mode = false; return true; } unpick(b); return false; case KEY_F(10): case KEY_F(12): unpick(b); return false; case '\n': pick(b); return false; case KEY_HOME: if (top != 0) { top = 0; show_lines(b, lines, top); refresh(); } continue; case KEY_END: if (top != max_top(b, lines.size())) { top = max_top(b, lines.size()); show_lines(b, lines, top); refresh(); } continue; case KEY_LEFT: case KEY_BACKSPACE: return true; case KEY_UP: if (top > 0) { top--; show_lines(b, lines, top); refresh(); } continue; case KEY_DOWN: if (top < max_top(b, lines.size())) { top++; show_lines(b, lines, top); refresh(); } continue; case KEY_PPAGE: if (top < ssize_t(b.height - 1)) { top = 0; } else { top -= b.height - 1; } show_lines(b, lines, top); refresh(); continue; case KEY_NPAGE: if (top + ssize_t(b.height - 1) < max_top(b, lines.size())) { top += b.height - 1; } else { top = max_top(b, lines.size()); } show_lines(b, lines, top); refresh(); continue; case KEY_F(1): if (b.help_mode) { return true; } if (!view_help(b)) { return false; } clear(); hightlight_top(b, top_title, title.size()); show_lines(b, lines, top); refresh(); continue; case KEY_RESIZE: clear(); hightlight_top(b, top_title, title.size()); show_lines(b, lines, top); refresh(); continue; } } } bool view_text_file(browser& b, const char* path) { vector lines; if (!load_lines(path, lines)) { return true; } string s; s.append(b.folder); if (!ends_with(b.folder, '/')) { s.push_back('/'); } s.append(path); return view_lines(b, s, lines); } bool view_help(browser& b) { if (b.help_mode) { return true; } b.help_mode = true; const string help_title = "Help for Browse Folders (bf)"; const auto ok = view_lines(b, help_title, help); b.help_mode = false; return ok; } bool dig_in(browser& b) { const char* path = b.entries[b.selection].name; if (!is_folder(path)) { if (!view_text_file(b, path)) { return false; } return handle_folder(b); } if (!change_folder(b, path)) { return true; } b.selection = 0; b.stack.push_back(b.folder); return handle_folder(b); } bool back_out(browser& b) { if (!change_folder(b, "..")) { return true; } if (b.stack.size() > 0) { b.stack.pop_back(); } const auto n = b.stack.size(); b.selection = (n > 0) ? find_name(b, b.stack[n - 1]) : -1; return handle_folder(b); } void bf(browser& b, entry start) { if (start.size >= 0) { if (!change_folder(b, ".")) { b.exit_code = BAD_ARGS; return; } if (view_text_file(b, start.name)) { b.result = clone(start.name); } b.exit_code = (b.result == NULL) ? NO_CHOICE : 0; return; } if (!change_folder(b, start.name)) { b.exit_code = BAD_ARGS; return; } b.stack.push_back(b.folder); if (!handle_folder(b)) { return; } while (true) { const auto got = getch(); switch (got) { case KEY_RESIZE: getmaxyx(NULL, b.height, b.width); show_entries(b); refresh(); continue; case '\n': pick(b); return; case '\x1b': case KEY_F(10): case KEY_F(12): unpick(b); return; case KEY_RIGHT: case '\t': if (!dig_in(b)) { return; } continue; case KEY_LEFT: case KEY_BACKSPACE: if (!back_out(b)) { return; } continue; case KEY_UP: handle_select(b, b.selection - 1); continue; case KEY_DOWN: handle_select(b, b.selection + 1); continue; case KEY_PPAGE: handle_page_change(b, b.selection - (b.height - 1)); continue; case KEY_NPAGE: handle_page_change(b, b.selection + (b.height - 1)); continue; case KEY_HOME: handle_select(b, 0); continue; case KEY_END: handle_select(b, b.entries.size() - 1); continue; case KEY_F(1): if (!view_help(b)) { return; } show_entries(b); refresh(); continue; case KEY_F(5): if (!handle_folder(b)) { return; } continue; case KEY_F(6): b.sort_size = !b.sort_size; if (!handle_folder(b)) { return; } continue; default: if (isalphanum(got)) { const auto i = seek_initial_next(b, got, b.selection); if (i >= 0) { handle_select(b, i); } } continue; } } } int cat(FILE* w, const char* path) { if (is_folder(path)) { return NOT_A_FILE; } FILE* r = fopen(path, "rb"); if (r == NULL) { fprintf(stderr, ERROR_LINE("can't open file named '%s'"), path); return BAD_ARGS; } unsigned char buf[IBUF_SIZE]; while (!feof(w)) { size_t len = fread(buf, sizeof(buf[0]), sizeof(buf), r); if (len < 1) { break; } fwrite(buf, 1, len, w); } fclose(r); return 0; } // run returns the number of errors int run(entry start, bool file_contents) { FILE* in = fopen("/dev/tty", "rb"); if (in == NULL) { fprintf(stderr, ERROR_LINE("can't open tty for input")); return BAD_TTY; } FILE* out = fopen("/dev/tty", "wb"); if (out == NULL) { fclose(in); fprintf(stderr, ERROR_LINE("can't open tty for output")); return BAD_TTY; } // use UTF-8 setlocale(LC_ALL, ""); // initscr takes over stdin and stdout, which interferes with being // used/run via xargs and with emitting a chosen path at the end newterm("xterm", out, in); noecho(); // don't show keys pressed keypad(stdscr, TRUE); // handle all keys correctly set_escdelay(10); // don't wait long after the escape key is pressed curs_set(0); // hide the cursor refresh(); // switch to alternate screen immediately browser b; b.folder = NULL; b.width = 80; b.height = 25; b.top = 0; b.selection = 0; b.result = NULL; b.exit_code = 0; b.sort_size = false; b.help_mode = false; getmaxyx(stdscr, b.height, b.width); try { bf(b, start); } catch (const bad_alloc& e) { endwin(); fclose(out); fclose(in); fprintf(stderr, ERROR_LINE("out of memory")); free(b.result); return BAD_ALLOC; } endwin(); fclose(out); fclose(in); if (b.result == NULL) { return NO_CHOICE; } if (file_contents) { b.exit_code = cat(stdout, b.result); free(b.result); return b.exit_code; } char buf[PATH_MAX]; const auto full = realpath(b.result, buf); fprintf(stdout, "%s\n", (full != NULL) ? full : b.result); free(b.result); return b.exit_code; } int main(int argc, char** argv) { bool file_contents = false; size_t start_args = 1; if (argc > 1) { if ( strcmp(argv[1], "-h") == 0 || strcmp(argv[1], "-help") == 0 || strcmp(argv[1], "--h") == 0 || strcmp(argv[1], "--help") == 0 ) { for (const auto& line : help) { fprintf(stdout, "%s\n", line.c_str()); } return 0; } if ( strcmp(argv[1], "-c") == 0 || strcmp(argv[1], "-f") == 0 || strcmp(argv[1], "--c") == 0 || strcmp(argv[1], "--f") == 0 ) { file_contents = true; start_args++; } } if (argc - start_args > 0 && strcmp(argv[1], "--") == 0) { start_args++; } if (argc - start_args > 1) { const auto w = stderr; fprintf(w, ERROR_LINE("can't have multiple starting files/folders")); return BAD_ARGS; } entry start; size_t size = 0; bool is_file = false; if (start_args == (size_t)argc) { start.name = const_cast("."); } else { start.name = argv[start_args]; } if (!entry_info(start.name, &size, &is_file)) { const char* s = start.name; fprintf(stderr, ERROR_LINE("no file/folder named \"%s\" found"), s); return BAD_ARGS; } start.size = is_file ? ssize_t(size) : -1; return run(start, file_contents) == 0 ? 0 : 1; }