/* * Bitty Browser (bb) * Copyright 2019 Bruce Hill * Released under the MIT license */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "config.h" #include "bterm.h" #define BB_VERSION "0.11.0" #ifndef PATH_MAX #define PATH_MAX 4096 #endif #define MAX(a,b) ((a) < (b) ? (b) : (a)) #define MIN(a,b) ((a) > (b) ? (b) : (a)) #define IS_SELECTED(p) (((p)->atme) != NULL) #define LOWERCASE(c) ('A' <= (c) && (c) <= 'Z' ? ((c) + 'a' - 'A') : (c)) static const char *T_ENTER_BBMODE = T_OFF(T_SHOW_CURSOR) T_ON(T_MOUSE_XY ";" T_MOUSE_CELL ";" T_MOUSE_SGR); static const char *T_LEAVE_BBMODE = T_OFF(T_MOUSE_XY ";" T_MOUSE_CELL ";" T_MOUSE_SGR ";" T_ALT_SCREEN); static const char *T_LEAVE_BBMODE_PARTIAL = T_OFF(T_MOUSE_XY ";" T_MOUSE_CELL ";" T_MOUSE_SGR); #define err(...) do { \ close_term(); \ fputs(T_OFF(T_ALT_SCREEN), stdout); \ fflush(stdout); \ fprintf(stderr, __VA_ARGS__); \ if (errno) fprintf(stderr, "\n%s", strerror(errno)); \ fprintf(stderr, "\n"); \ cleanup_and_exit(1); \ } while (0) // Types typedef enum { SORT_NONE = 0, SORT_DIR = '/', SORT_NAME = 'n', SORT_SIZE = 's', SORT_PERM = 'p', SORT_MTIME = 'm', SORT_CTIME = 'c', SORT_ATIME = 'a', SORT_RANDOM = 'r', SORT_SELECTED = '*', } sortmethod_t; /* entry_t uses intrusive linked lists. This means entries can only belong to * one list at a time, in this case the list of selected entries. 'atme' is an * indirect pointer to either the 'next' field of the previous list member, or * the variable that points to the first list member. In other words, * item->next->atme == &item->next and firstitem->atme == &firstitem. */ typedef struct entry_s { struct entry_s *next, **atme; char *name, *linkname; struct stat info; mode_t linkedmode; unsigned int refcount : 2; // Should only be between 0-2 int no_esc : 1; int link_no_esc : 1; int shufflepos; char fullname[1]; // Must be last } entry_t; #define MAX_COLS 12 #define MAX_SORT (2*MAX_COLS) typedef struct { char sort[MAX_SORT+1]; char columns[MAX_COLS+1]; char aligns[MAX_COLS+1]; // l,r,c int colwidths[MAX_COLS+1]; int show_dotdot : 1; int show_dot : 1; int show_dotfiles : 1; } options_t; typedef struct bb_s { entry_t **files; entry_t *firstselected; char path[PATH_MAX]; int nfiles; int scroll, cursor; options_t options, initialopts; char *marks[128]; // Mapping from key to directory } bb_t; typedef enum { BB_NOP = 0, BB_INVALID, BB_REFRESH, BB_QUIT } bb_result_t; // Functions static void update_term_size(int sig); static void init_term(void); static void close_term(void); static void cleanup_and_exit(int sig); static void* memcheck(void *p); static int run_cmd_on_selection(bb_t *bb, const char *cmd); static int fputs_escaped(FILE *f, const char *str, const char *color); static const char* color_of(mode_t mode); static void set_sort(bb_t *bb, const char *sort); static void render(bb_t *bb, int lazy); static int compare_files(void *r, const void *v1, const void *v2); static int find_file(bb_t *bb, const char *name); static void clear_selection(bb_t *bb); static void select_file(bb_t *bb, entry_t *e); static void deselect_file(bb_t *bb, entry_t *e); static void toggle_file(bb_t *bb, entry_t *e); static void set_cursor(bb_t *bb, int i); static void set_scroll(bb_t *bb, int i); static entry_t* load_entry(const char *path); static void populate_files(bb_t *bb, const char *path); static bb_result_t execute_cmd(bb_t *bb, const char *cmd); static void bb_browse(bb_t *bb, const char *path); static void print_bindings(int verbose); // Config options extern binding_t bindings[]; extern const char *startupcmds[]; // Global variables static struct termios orig_termios, bb_termios; static FILE *tty_out = NULL, *tty_in = NULL; static int termwidth, termheight; static int mouse_x, mouse_y; static char *cmdfilename = NULL; static const int colsizew = 7, coldatew = 19, colpermw = 4, colnamew = 40, colselw = 1, coldirw = 1, colrandw = 2; static struct timespec lastclick = {0, 0}; /* * Hanlder for SIGWINCH events */ void update_term_size(int sig) { (void)sig; struct winsize sz = {0}; ioctl(fileno(tty_in), TIOCGWINSZ, &sz); termwidth = sz.ws_col; termheight = sz.ws_row; } /* * Initialize the terminal files for /dev/tty and set up some desired * attributes like passing Ctrl-c as a key instead of interrupting */ void init_term(void) { tty_in = fopen("/dev/tty", "r"); tty_out = fopen("/dev/tty", "w"); tcgetattr(fileno(tty_out), &orig_termios); memcpy(&bb_termios, &orig_termios, sizeof(bb_termios)); bb_termios.c_iflag &= ~(unsigned long)( IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON); bb_termios.c_oflag &= (unsigned long)~OPOST; bb_termios.c_lflag &= (unsigned long)~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); bb_termios.c_cflag &= (unsigned long)~(CSIZE | PARENB); bb_termios.c_cflag |= (unsigned long)CS8; bb_termios.c_cc[VMIN] = 0; bb_termios.c_cc[VTIME] = 0; if (tcsetattr(fileno(tty_out), TCSAFLUSH, &bb_termios) == -1) err("Couldn't tcsetattr"); update_term_size(0); signal(SIGWINCH, update_term_size); // Initiate mouse tracking and disable text wrapping: fputs(T_ENTER_BBMODE, tty_out); fputs(T_OFF(T_WRAP), tty_out); } /* * Close the /dev/tty terminals and restore some of the attributes. */ void close_term(void) { if (tty_out) { tcsetattr(fileno(tty_out), TCSAFLUSH, &orig_termios); fputs(T_LEAVE_BBMODE_PARTIAL, tty_out); fputs(T_ON(T_WRAP), tty_out); fflush(tty_out); fclose(tty_out); tty_out = NULL; fclose(tty_in); tty_in = NULL; } else { fputs(T_LEAVE_BBMODE_PARTIAL, stdout); fputs(T_ON(T_WRAP), stdout); fflush(stdout); } signal(SIGWINCH, SIG_DFL); } /* * Close safely in a way that doesn't gunk up the terminal. */ void cleanup_and_exit(int sig) { (void)sig; close_term(); unlink(cmdfilename); exit(EXIT_FAILURE); } /* * Memory allocation failures are unrecoverable in bb, so this wrapper just * prints an error message and exits if that happens. */ void* memcheck(void *p) { if (!p) err("Allocation failure"); return p; } /* * Run a command with the selected files passed as sequential arguments to the * command (or pass the cursor file if none are selected). * Return the exit status of the command. */ int run_cmd_on_selection(bb_t *bb, const char *cmd) { pid_t child; sig_t old_handler = signal(SIGINT, SIG_IGN); if ((child = fork()) == 0) { signal(SIGINT, SIG_DFL); // TODO: is there a max number of args? Should this be batched? size_t space = 32; char **args = memcheck(calloc(space, sizeof(char*))); size_t i = 0; args[i++] = "sh"; args[i++] = "-c"; args[i++] = (char*)cmd; #ifdef __APPLE__ args[i++] = "--"; // ensure files like "-i" are not interpreted as flags for sh #endif entry_t *first = bb->firstselected ? bb->firstselected : bb->files[bb->cursor]; for (entry_t *e = first; e; e = e->next) { if (i >= space) args = memcheck(realloc(args, (space += 100)*sizeof(char*))); args[i++] = e->fullname; } args[i] = NULL; setenv("BBSELECTED", bb->firstselected ? "1" : "", 1); setenv("BBCURSOR", bb->files[bb->cursor]->fullname, 1); execvp("sh", args); err("Failed to execute command: '%s'", cmd); return -1; } if (child == -1) err("Failed to fork"); int status; waitpid(child, &status, 0); signal(SIGINT, old_handler); return status; } /* * Print a string, but replacing bytes like '\n' with a red-colored "\n". * The color argument is what color to put back after the red. * Returns the number of bytes that were escaped. */ int fputs_escaped(FILE *f, const char *str, const char *color) { static const char *escapes = " abtnvfr e"; int escaped = 0; for (const char *c = str; *c; ++c) { if (*c > 0 && *c <= '\x1b' && escapes[(int)*c] != ' ') { // "\n", etc. fprintf(f, "\033[31m\\%c%s", escapes[(int)*c], color); ++escaped; } else if (*c >= 0 && !(' ' <= *c && *c <= '~')) { // "\x02", etc. fprintf(f, "\033[31m\\x%02X%s", *c, color); ++escaped; } else { fputc(*c, f); } } return escaped; } /* * Returns the color of a file listing, given its mode. */ const char* color_of(mode_t mode) { if (S_ISDIR(mode)) return DIR_COLOR; else if (S_ISLNK(mode)) return LINK_COLOR; else if (mode & (S_IXUSR | S_IXGRP | S_IXOTH)) return EXECUTABLE_COLOR; else return NORMAL_COLOR; } void set_sort(bb_t *bb, const char *sort) { for (const char *s = sort; s[0] && s[1]; s += 2) { char *found; if ((found = strchr(bb->options.sort, s[1]))) { memmove(found-1, found+1, strlen(found+1)+1); } } size_t len = MIN(MAX_SORT, strlen(sort)); memmove(bb->options.sort + len, bb->options.sort, MAX_SORT+1 - len); memmove(bb->options.sort, sort, len); } /* * Draw everything to the screen. * If lazy is true, then use terminal scrolling to move the file listing * around and only update the files that have changed. */ void render(bb_t *bb, int lazy) { static int lastcursor = -1, lastscroll = -1; char buf[64]; if (lastcursor == -1 || lastscroll == -1) lazy = 0; if (lazy) { // Use terminal scrolling: if (lastscroll > bb->scroll) { fprintf(tty_out, "\033[3;%dr\033[%dT\033[1;%dr", termheight-1, lastscroll - bb->scroll, termheight); } else if (lastscroll < bb->scroll) { fprintf(tty_out, "\033[3;%dr\033[%dS\033[1;%dr", termheight-1, bb->scroll - lastscroll, termheight); } } if (!lazy) { // Path move_cursor(tty_out, 0, 0); const char *color = TITLE_COLOR; fputs(color, tty_out); fputs_escaped(tty_out, bb->path, color); fputs(" \033[K\033[0m", tty_out); // Columns move_cursor(tty_out, 0, 1); fputs("\033[0;44;30m\033[K", tty_out); int x = 0; for (int col = 0; bb->options.columns[col]; col++) { const char *title = NULL; switch (bb->options.columns[col]) { case '*': title = "*"; break; case '/': title = "/"; break; case 'r': title = "Random"; break; case 'n': title = "Name"; break; case 's': title = "Size"; break; case 'p': title = "Permissions"; break; case 'm': title = "Modified"; break; case 'a': title = "Accessed"; break; case 'c': title = "Created"; break; default: title = ""; break; } move_cursor(tty_out, x, 1); if (col > 0) { fputs("│\033[K", tty_out); x += 1; } int k = bb->options.aligns[col] == 'l' ? 0 : (bb->options.aligns[col] == 'r' ? 2 : 1); const char *indicator = " "; char *found; if ((found = strchr(bb->options.sort, bb->options.columns[col]))) indicator = found[-1] == '-' ? RSORT_INDICATOR : SORT_INDICATOR; move_cursor(tty_out, x + MAX(0, ((bb->options.colwidths[col] - (int)strlen(title) - 1)*k)/2), 1); if (bb->options.columns[col] == bb->options.sort[1]) fputs("\033[1m", tty_out); fputs(indicator, tty_out); fputs(title, tty_out); if (bb->options.columns[col] == bb->options.sort[1]) fputs("\033[22m", tty_out); x += bb->options.colwidths[col]; } fputs(" \033[K\033[0m", tty_out); } entry_t **files = bb->files; for (int i = bb->scroll; i < bb->scroll + termheight - 3; i++) { if (lazy) { if (i == bb->cursor || i == lastcursor) goto do_render; if (i < lastscroll || i >= lastscroll + termheight - 3) goto do_render; continue; } int y; do_render: y = i - bb->scroll + 2; move_cursor(tty_out, 0, y); if (i == bb->scroll && bb->nfiles == 0) { const char *s = "...no files here..."; fprintf(tty_out, "\033[37;2m%s\033[0m", s); } if (i >= bb->nfiles) { fputs("\033[K", tty_out); continue; } entry_t *entry = files[i]; fputs(IS_SELECTED(entry) ? SELECTED_INDICATOR : NOT_SELECTED_INDICATOR, tty_out); char color[128]; strcpy(color, color_of(entry->info.st_mode)); if (i == bb->cursor) strcat(color, CURSOR_COLOR); fputs(color, tty_out); int x = 0; for (int col = 0; bb->options.columns[col]; col++) { fprintf(tty_out, "\033[%d;%dH\033[K", y+1, x+1); if (col > 0) { if (i == bb->cursor) fputs("│", tty_out); else fputs("\033[37;2m│\033[22m", tty_out); fputs(color, tty_out); x += 1; } int k = bb->options.aligns[col] == 'l' ? 0 : (bb->options.aligns[col] == 'r' ? 2 : 1); switch (bb->options.columns[col]) { case '*': move_cursor(tty_out, x + MAX(0, (k*(bb->options.colwidths[col] - colselw))/2), y); fputs(IS_SELECTED(entry) ? SELECTED_INDICATOR : NOT_SELECTED_INDICATOR, tty_out); fputs(color, tty_out); break; case '/': move_cursor(tty_out, x + MAX(0, (k*(bb->options.colwidths[col] - coldirw))/2), y); if (S_ISDIR(S_ISLNK(entry->info.st_mode) ? entry->linkedmode : entry->info.st_mode)) fputs("/", tty_out); else fputs(" ", tty_out); break; case 'r': move_cursor(tty_out, x + MAX(0, (k*(bb->options.colwidths[col] - colrandw))/2), y); fprintf(tty_out, "\033[48;5;%dm \033[0m%s", 232 + (entry->shufflepos / (RAND_MAX / (255-232))), color); break; case 's': { int j = 0; const char* units = "BKMGTPEZY"; double bytes = (double)entry->info.st_size; while (bytes > 1024) { bytes /= 1024; j++; } move_cursor(tty_out, x + MAX(0, (k*(bb->options.colwidths[col] - colsizew))/2), y); fprintf(tty_out, "%6.*f%c", j > 0 ? 1 : 0, bytes, units[j]); break; } case 'm': move_cursor(tty_out, x + MAX(0, (k*(bb->options.colwidths[col] - coldatew))/2), y); strftime(buf, sizeof(buf), "%l:%M%p %b %e %Y", localtime(&(entry->info.st_mtime))); fputs(buf, tty_out); break; case 'c': move_cursor(tty_out, x + MAX(0, (k*(bb->options.colwidths[col] - coldatew))/2), y); strftime(buf, sizeof(buf), "%l:%M%p %b %e %Y", localtime(&(entry->info.st_ctime))); fputs(buf, tty_out); break; case 'a': move_cursor(tty_out, x + MAX(0, (k*(bb->options.colwidths[col] - coldatew))/2), y); strftime(buf, sizeof(buf), "%l:%M%p %b %e %Y", localtime(&(entry->info.st_atime))); fputs(buf, tty_out); break; case 'p': move_cursor(tty_out, x + MAX(0, (k*(bb->options.colwidths[col] - colpermw))/2), y); fprintf(tty_out, " %03o", entry->info.st_mode & 0777); break; case 'n': { move_cursor(tty_out, x + MAX(0, (k*(bb->options.colwidths[col] - (int)strlen(entry->name)))/2), y); if (entry->no_esc) fputs(entry->name, tty_out); else entry->no_esc |= !fputs_escaped(tty_out, entry->name, color); if (S_ISDIR(S_ISLNK(entry->info.st_mode) ? entry->linkedmode : entry->info.st_mode)) fputs("/", tty_out); if (entry->linkname) { if (i != bb->cursor) fputs("\033[37m", tty_out); fputs("\033[2m -> \033[22;3m", tty_out); strcpy(color, color_of(entry->linkedmode)); if (i == bb->cursor) strcat(color, CURSOR_COLOR); fputs(color, tty_out); if (entry->link_no_esc) fputs(entry->linkname, tty_out); else entry->link_no_esc |= !fputs_escaped(tty_out, entry->linkname, color); if (S_ISDIR(entry->linkedmode)) fputs("/", tty_out); fputs("\033[22;23m", tty_out); } break; } default: break; } x += bb->options.colwidths[col]; } fputs(" \033[K\033[0m", tty_out); // Reset color and attributes } static const char *help = "Press '?' to see key bindings "; move_cursor(tty_out, 0, termheight - 1); fputs("\033[K", tty_out); move_cursor(tty_out, MAX(0, termwidth - (int)strlen(help)), termheight - 1); fputs(help, tty_out); lastcursor = bb->cursor; lastscroll = bb->scroll; fflush(tty_out); } /* * Used for sorting, this function compares files according to the sorting-related options, * like bb->options.sort */ int compare_files(void *v, const void *v1, const void *v2) { #define compare(a, b) ((a) == (b) ? 0 : ((a) < (b) ? 1 : -1)) #define compare_time(t1, t2) ((t1).tv_sec == (t2).tv_sec ? compare((t1).tv_nsec, (t2).tv_nsec) : compare((t1).tv_sec, (t2).tv_sec)) bb_t *bb = (bb_t*)v; int diff = 0; const entry_t *f1 = *((const entry_t**)v1), *f2 = *((const entry_t**)v2); int sign = 1; for (char *sort = bb->options.sort; *sort && diff == 0; ++sort) { if (*sort == '-') { sign = -1; continue; } switch (*sort) { case SORT_DIR: { int d1 = S_ISDIR(f1->info.st_mode) || (S_ISLNK(f1->info.st_mode) && S_ISDIR(f1->linkedmode)); int d2 = S_ISDIR(f2->info.st_mode) || (S_ISLNK(f2->info.st_mode) && S_ISDIR(f2->linkedmode)); diff = compare(d1, d2); break; } case SORT_SELECTED: diff = compare(IS_SELECTED(f1), IS_SELECTED(f2)); break; case SORT_NAME: { /* This sorting method is not identical to strverscmp(). Notably, bb's sort * will order: [0, 1, 9, 00, 01, 09, 10, 000, 010] instead of strverscmp()'s * order: [000, 00, 01, 010, 09, 0, 1, 9, 10]. I believe bb's sort is consistent * with how people want their files grouped: all files padded to n digits * will be grouped together, and files with the same padding will be sorted * ordinally. This version also does case-insensitivity by lowercasing words, * so the following characters come before all letters: [\]^_` */ const char *n1 = f1->name, *n2 = f2->name; while (*n1 && *n2) { char c1 = LOWERCASE(*n1), c2 = LOWERCASE(*n2); diff = -compare(c1, c2); if ('0' <= c1 && c1 <= '9' && '0' <= c2 && c2 <= '9') { long i1 = strtol(n1, (char**)&n1, 10); long i2 = strtol(n2, (char**)&n2, 10); // Shorter numbers always go before longer. In practice, I assume // filenames padded to the same number of digits should be grouped // together, instead of // [1.png, 0001.png, 2.png, 0002.png, 3.png], it makes more sense to have: // [1.png, 2.png, 3.png, 0001.png, 0002.png] diff = -compare((n1 - f1->name), (n2 - f2->name)); if (diff != 0) goto found_diff; diff = -compare(i1, i2); if (diff != 0) goto found_diff; } else if (diff) { goto found_diff; } else { ++n1; ++n2; } } diff = -compare(LOWERCASE(*n1), LOWERCASE(*n2)); break; } case SORT_PERM: diff = compare((f1->info.st_mode & 0x3FF), (f2->info.st_mode & 0x3FF)); break; case SORT_SIZE: diff = compare(f1->info.st_size, f2->info.st_size); break; case SORT_MTIME: diff = compare_time(f1->info.st_mtimespec, f2->info.st_mtimespec); break; case SORT_CTIME: diff = compare_time(f1->info.st_ctimespec, f2->info.st_ctimespec); break; case SORT_ATIME: diff = compare_time(f1->info.st_atimespec, f2->info.st_atimespec); break; case SORT_RANDOM: diff = f1->shufflepos - f2->shufflepos; break; } found_diff: diff *= sign; sign = 1; } return diff; #undef compare #undef compare_time } int find_file(bb_t *bb, const char *name) { for (int i = 0; i < bb->nfiles; i++) { entry_t *e = bb->files[i]; if (strcmp(name[0] == '/' ? e->fullname : e->name, name) == 0) return i; } return -1; } /* * Deselect all files */ void clear_selection(bb_t *bb) { for (entry_t *next, *e = bb->firstselected; e; e = next) { next = e->next; *(e->atme) = NULL; e->atme = NULL; if (--e->refcount <= 0) free(e); } } /* * Select a file */ void select_file(bb_t *bb, entry_t *e) { if (IS_SELECTED(e)) return; if (strcmp(e->name, "..") == 0) return; if (strcmp(e->name, ".") == 0) return; if (bb->firstselected) bb->firstselected->atme = &e->next; e->next = bb->firstselected; e->atme = &bb->firstselected; ++e->refcount; bb->firstselected = e; } /* * Deselect a file */ void deselect_file(bb_t *bb, entry_t *e) { (void)bb; if (!IS_SELECTED(e)) return; if (e->next) e->next->atme = e->atme; *(e->atme) = e->next; --e->refcount; e->next = NULL; e->atme = NULL; } /* * Toggle a file's selection state */ void toggle_file(bb_t *bb, entry_t *e) { if (IS_SELECTED(e)) deselect_file(bb, e); else select_file(bb, e); } /* * Set bb's file cursor to the given index (and adjust the scroll as necessary) */ void set_cursor(bb_t *bb, int newcur) { if (newcur > bb->nfiles - 1) newcur = bb->nfiles - 1; if (newcur < 0) newcur = 0; bb->cursor = newcur; if (bb->nfiles <= termheight - 4) return; if (newcur < bb->scroll + SCROLLOFF) bb->scroll = newcur - SCROLLOFF; else if (newcur > bb->scroll + (termheight-4) - SCROLLOFF) bb->scroll = newcur - (termheight-4) + SCROLLOFF; if (bb->nfiles <= termheight - 4) { bb->scroll = 0; } else { int max_scroll = bb->nfiles - (termheight-4) - 1; if (max_scroll < 0) max_scroll = 0; if (bb->scroll > max_scroll) bb->scroll = max_scroll; if (bb->scroll < 0) bb->scroll = 0; } } /* * Set bb's scroll to the given index (and adjust the cursor as necessary) */ void set_scroll(bb_t *bb, int newscroll) { newscroll = MIN(newscroll, bb->nfiles - (termheight-4) - 1); newscroll = MAX(newscroll, 0); if (bb->nfiles <= termheight - 4) newscroll = 0; int delta = newscroll - bb->scroll; int oldcur = bb->cursor; if (bb->nfiles > termheight - 4) { if (bb->cursor > newscroll + (termheight-4) - SCROLLOFF && bb->scroll < bb->nfiles - (termheight-4) - 1) bb->cursor = newscroll + (termheight-4) - SCROLLOFF; else if (bb->cursor < newscroll + SCROLLOFF && bb->scroll > 0) bb->cursor = newscroll + SCROLLOFF; } else { newscroll = 0; } bb->scroll = newscroll; if (abs(bb->cursor - oldcur) < abs(delta)) bb->cursor += delta - (bb->cursor - oldcur); if (bb->cursor > bb->nfiles - 1) bb->cursor = bb->nfiles - 1; if (bb->cursor < 0) bb->cursor = 0; } /* * Load a file's info into an entry_t and return it (if found). * The returned entry must be free()ed by the caller. * Warning: this does not deduplicate entries, and it's best if there aren't * duplicate entries hanging around. */ entry_t* load_entry(const char *path) { ssize_t linkpathlen = -1; char linkbuf[PATH_MAX]; struct stat linkedstat, filestat; if (lstat(path, &filestat) == -1) return NULL; if (S_ISLNK(filestat.st_mode)) { linkpathlen = readlink(path, linkbuf, sizeof(linkbuf)); if (linkpathlen < 0) err("Couldn't read link: '%s'", path); linkbuf[linkpathlen] = 0; if (stat(path, &linkedstat) == -1) memset(&linkedstat, 0, sizeof(linkedstat)); } size_t entry_size = sizeof(entry_t) + (strlen(path) + 1) + (size_t)(linkpathlen + 1); entry_t *entry = memcheck(calloc(entry_size, 1)); char *end = stpcpy(entry->fullname, path); if (linkpathlen >= 0) entry->linkname = strcpy(end + 1, linkbuf); entry->name = strrchr(entry->fullname, '/'); if (!entry->name) err("No slash found in '%s'", entry->fullname); ++entry->name; if (S_ISLNK(filestat.st_mode)) entry->linkedmode = linkedstat.st_mode; entry->info = filestat; return entry; } /* * Remove all the files currently stored in bb->files and if `path` is non-NULL, * update `bb` with a listing of the files in `path` */ void populate_files(bb_t *bb, const char *path) { // Clear old files (if any) if (bb->files) { for (int i = 0; i < bb->nfiles; i++) { if (--bb->files[i]->refcount <= 0) free(bb->files[i]); } free(bb->files); bb->files = NULL; } int old_scroll = bb->scroll; bb->nfiles = 0; bb->cursor = 0; bb->scroll = 0; if (path == NULL) return; int samedir = strcmp(path, bb->path) == 0; if (!samedir) strcpy(bb->path, path); // Hash inode -> entry_t with linear probing int nselected = 0; for (entry_t *p = bb->firstselected; p; p = p->next) ++nselected; int hashsize = 2 * nselected; entry_t **selecthash = NULL; if (nselected > 0) { selecthash = memcheck(calloc((size_t)hashsize, sizeof(entry_t*))); for (entry_t *p = bb->firstselected; p; p = p->next) { int probe = ((int)p->info.st_ino) % hashsize; while (selecthash[probe]) probe = (probe + 1) % hashsize; selecthash[probe] = p; } } DIR *dir = opendir(bb->path); if (!dir) err("Couldn't open dir: %s", bb->path); size_t pathlen = strlen(bb->path); size_t filecap = 0; char pathbuf[PATH_MAX]; strcpy(pathbuf, path); pathbuf[pathlen] = '/'; for (struct dirent *dp; (dp = readdir(dir)) != NULL; ) { if (dp->d_name[0] == '.') { if (dp->d_name[1] == '.' && dp->d_name[2] == '\0') { if (!bb->options.show_dotdot) continue; } else if (dp->d_name[1] == '\0') { if (!bb->options.show_dot) continue; } else if (!bb->options.show_dotfiles) continue; } if ((size_t)bb->nfiles >= filecap) { filecap += 100; bb->files = memcheck(realloc(bb->files, filecap*sizeof(entry_t*))); } // Hashed lookup from selected: if (nselected > 0) { for (int probe = ((int)dp->d_ino) % hashsize; selecthash[probe]; probe = (probe + 1) % hashsize) { if (selecthash[probe]->info.st_ino == dp->d_ino) { ++selecthash[probe]->refcount; bb->files[bb->nfiles++] = selecthash[probe]; goto next_file; } } } strcpy(&pathbuf[pathlen+1], dp->d_name); entry_t *entry = load_entry(pathbuf); if (!entry) err("Failed to load entry: '%s'", pathbuf); ++entry->refcount; bb->files[bb->nfiles++] = entry; next_file: continue; } closedir(dir); free(selecthash); // TODO: this may have some weird aliasing issues, but eh, it's simple and effective for (int i = 0; i < bb->nfiles; i++) bb->files[i]->shufflepos = rand(); qsort_r(bb->files, (size_t)bb->nfiles, sizeof(entry_t*), bb, compare_files); set_scroll(bb, old_scroll); } /* * Run a bb internal command (e.g. "+refresh") and return an indicator of what * needs to happen next. */ bb_result_t execute_cmd(bb_t *bb, const char *cmd) { char *value = strchr(cmd, ':'); if (value) ++value; switch (cmd[0]) { case 'r': { // +refresh populate_files(bb, bb->path); return BB_REFRESH; } case 'q': // +quit return BB_QUIT; case 's': // +select:, +scroll:, +spread: switch (cmd[1]) { case 'c': { // scroll: if (!value) return BB_INVALID; // TODO: figure out the best version of this int isdelta = value[0] == '+' || value[0] == '-'; int n = (int)strtol(value, &value, 10); if (*value == '%') n = (n * (value[1] == 'n' ? bb->nfiles : termheight)) / 100; if (isdelta) set_scroll(bb, bb->scroll + n); else set_scroll(bb, n); return BB_NOP; } case 'p': // +spread: goto move; case '\0': case 'e': // +select: if (!value) value = bb->files[bb->cursor]->name; if (strcmp(value, "*") == 0) { for (int i = 0; i < bb->nfiles; i++) select_file(bb, bb->files[i]); } else { int f = find_file(bb, value); if (f >= 0) select_file(bb, bb->files[f]); // TODO: support selecting files in other directories } return BB_REFRESH; } case 'c': { // +cd: char pbuf[PATH_MAX]; cd: if (!value) return BB_INVALID; if (value[0] == '~') { char *home; if (!(home = getenv("HOME"))) return BB_INVALID; strcpy(pbuf, home); strcat(pbuf, value+1); value = pbuf; } char *rpbuf = realpath(value, NULL); if (!rpbuf) return BB_INVALID; if (strcmp(rpbuf, bb->path) == 0) { free(rpbuf); return BB_NOP; } if (chdir(rpbuf)) { free(rpbuf); return BB_INVALID; } char *oldpath = memcheck(strdup(bb->path)); populate_files(bb, rpbuf); free(rpbuf); if (strcmp(value, "..") == 0) { int f = find_file(bb, oldpath); if (f >= 0) set_cursor(bb, f); } free(oldpath); return BB_REFRESH; } case 't': { // +toggle: if (!value) value = bb->files[bb->cursor]->name; int f = find_file(bb, value); if (f < 0) return BB_INVALID; toggle_file(bb, bb->files[f]); return f == bb->cursor ? BB_NOP : BB_REFRESH; } case 'o': { // +options: if (!value) return BB_INVALID; char *cmdscratch = memcheck(strdup(value)); char *v, *nextv = cmdscratch; while ((v = strsep(&nextv, " ")) != NULL) { if (!*v) continue; char *k; if ((k = strsep(&v, "=")) == NULL) v = "1"; #define matches(s) (strncmp(k, (s), strlen(k)) == 0) if (matches("..")) { bb->options.show_dotdot = v[0] == '1'; } else if (matches(".*")) { bb->options.show_dotfiles = v[0] == '1'; } else if (matches(".")) { bb->options.show_dot = v[0] == '1'; } else if (matches("columns") || matches("cols")) { strncpy(bb->options.columns, v, MAX_COLS); for (int i = 0; i < v[i] && i < MAX_COLS; i++) { int *colw = &bb->options.colwidths[i]; switch (v[i]) { case 'c': case 'm': case 'a': *colw = coldatew; break; case 's': *colw = colsizew; break; case 'p': *colw = colpermw; break; case 'n': *colw = colnamew; break; case '*': *colw = colselw; break; case 'r': *colw = colrandw; break; case '/': *colw = coldirw; break; } } } else if (matches("sort")) { set_sort(bb, v); } else if (matches("aligns")) { strncpy(bb->options.aligns, v, MAX_COLS); } else { } #undef matches } free(cmdscratch); populate_files(bb, bb->path); return BB_REFRESH; } case 'd': // +deselect: if (!value) value = bb->files[bb->cursor]->name; if (strcmp(value, "*") == 0) { clear_selection(bb); return BB_REFRESH; } else { int f = find_file(bb, value); if (f < 0) return BB_INVALID; select_file(bb, bb->files[f]); return f == bb->cursor ? BB_NOP : BB_REFRESH; } case 'g': { // +goto: if (!value) return BB_INVALID; int f = find_file(bb, value); if (f >= 0) { set_cursor(bb, f); return BB_NOP; } char *path = memcheck(strdup(value)); char *lastslash = strrchr(path, '/'); if (!lastslash) return BB_INVALID; *lastslash = '\0'; // Split in two char *real = realpath(path, NULL); if (!real || chdir(real)) return BB_INVALID; populate_files(bb, real); free(real); // estate if (lastslash[1]) { f = find_file(bb, lastslash + 1); if (f >= 0) set_cursor(bb, f); } return BB_REFRESH; } case 'm': { // +move:, +mark: switch (cmd[1]) { case 'a': { // +mark: if (!value) return BB_INVALID; char key = value[0]; if (key < 0) return BB_INVALID; value = strchr(value, '='); if (!value) value = bb->path; else ++value; if (bb->marks[(int)key]) free(bb->marks[(int)key]); bb->marks[(int)key] = memcheck(strdup(value)); return BB_NOP; } default: { // +move: int oldcur, isdelta, n; move: if (!value) return BB_INVALID; oldcur = bb->cursor; isdelta = value[0] == '-' || value[0] == '+'; n = (int)strtol(value, &value, 10); if (*value == '%') n = (n * (value[1] == 'n' ? bb->nfiles : termheight)) / 100; if (isdelta) set_cursor(bb, bb->cursor + n); else set_cursor(bb, n); if (cmd[0] == 's') { // +spread: int sel = IS_SELECTED(bb->files[oldcur]); for (int i = bb->cursor; i != oldcur; i += (oldcur > i ? 1 : -1)) { if (sel != IS_SELECTED(bb->files[i])) toggle_file(bb, bb->files[i]); } if (abs(oldcur - bb->cursor) > 1) return BB_REFRESH; } return BB_NOP; } } } case 'j': { // +jump: if (!value) return BB_INVALID; char key = value[0]; if (bb->marks[(int)key]) { value = bb->marks[(int)key]; goto cd; } return BB_INVALID; } default: err("UNKNOWN COMMAND: '%s'", cmd); break; } return BB_INVALID; } /* * Use bb to browse a path. */ void bb_browse(bb_t *bb, const char *path) { static long cmdpos = 0; int lastwidth = termwidth, lastheight = termheight; int lazy = 0, check_cmds = 1; { char *real = realpath(path, NULL); if (!real || chdir(real)) err("Not a valid path: %s\n", path); populate_files(bb, real); free(real); // estate } for (int i = 0; startupcmds[i]; i++) { if (startupcmds[i][0] == '+') { if (execute_cmd(bb, startupcmds[i] + 1) == BB_QUIT) goto quit; } else { run_cmd_on_selection(bb, startupcmds[i]); check_cmds = 1; } } memcpy(&bb->initialopts, &bb->options, sizeof(bb->initialopts)); init_term(); fputs(T_ON(T_ALT_SCREEN), tty_out); bb->scroll = 0; bb->cursor = 0; refresh: lazy = 0; redraw: render(bb, lazy); lazy = 1; next_input: if (termwidth != lastwidth || termheight != lastheight) { lastwidth = termwidth; lastheight = termheight; lazy = 0; goto redraw; } if (check_cmds) { FILE *cmdfile = fopen(cmdfilename, "r"); if (!cmdfile) { if (!lazy) goto redraw; goto get_keyboard_input; } if (cmdpos) fseek(cmdfile, cmdpos, SEEK_SET); char *cmd = NULL; size_t cap = 0; while (cmdfile && getdelim(&cmd, &cap, '\0', cmdfile) >= 0) { cmdpos = ftell(cmdfile); if (!cmd[0]) continue; switch (execute_cmd(bb, cmd)) { case BB_INVALID: break; case BB_NOP: free(cmd); fclose(cmdfile); goto redraw; case BB_REFRESH: free(cmd); fclose(cmdfile); goto refresh; case BB_QUIT: free(cmd); fclose(cmdfile); goto quit; } } free(cmd); fclose(cmdfile); unlink(cmdfilename); cmdpos = 0; check_cmds = 0; } int key; get_keyboard_input: key = bgetkey(tty_in, &mouse_x, &mouse_y, KEY_DELAY); switch (key) { case KEY_MOUSE_LEFT: { struct timespec clicktime; clock_gettime(CLOCK_MONOTONIC, &clicktime); double dt_ms = 1e3*(double)(clicktime.tv_sec - lastclick.tv_sec); dt_ms += 1e-6*(double)(clicktime.tv_nsec - lastclick.tv_nsec); lastclick = clicktime; // Get column: char column[3] = "+?"; for (int col = 0, x = 0; bb->options.columns[col]; col++) { if (col > 0) x += 1; x += bb->options.colwidths[col]; if (x >= mouse_x) { column[1] = bb->options.columns[col]; break; } } if (mouse_y == 1) { char *pos; if ((pos = strstr(bb->options.sort, column)) && pos == bb->options.sort) column[0] = '-'; set_sort(bb, column); qsort_r(bb->files, (size_t)bb->nfiles, sizeof(entry_t*), bb, compare_files); goto refresh; } else if (2 <= mouse_y && mouse_y <= termheight - 2) { int clicked = bb->scroll + (mouse_y - 2); if (clicked > bb->nfiles - 1) goto next_input; if (column[1] == SORT_SELECTED) { toggle_file(bb, bb->files[clicked]); lazy = 0; goto redraw; } set_cursor(bb, clicked); if (dt_ms <= 200) { key = KEY_MOUSE_DOUBLE_LEFT; goto user_bindings; } goto redraw; } break; } case KEY_CTRL_C: cleanup_and_exit(SIGINT); goto quit; // Unreachable case KEY_CTRL_Z: fputs(T_OFF(T_ALT_SCREEN), tty_out); close_term(); raise(SIGTSTP); init_term(); fputs(T_ON(T_ALT_SCREEN), tty_out); lazy = 0; goto redraw; case KEY_CTRL_H: { move_cursor(tty_out, 0,termheight-1); fputs("\033[K\033[33;1mPress any key...\033[0m", tty_out); while ((key = bgetkey(tty_in, &mouse_x, &mouse_y, 1000)) == -1) ; move_cursor(tty_out, 0,termheight-1); fputs("\033[K\033[1m<\033[33m", tty_out); const char *name = bkeyname(key); if (name) fputs(name, tty_out); else if (' ' <= key && key <= '~') fputc((char)key, tty_out); else fprintf(tty_out, "\033[31m\\x%02X", key); fputs("\033[0;1m> is bound to: \033[34;1m", tty_out); for (int i = 0; bindings[i].keys[0] > 0; i++) { for (int j = 0; bindings[i].keys[j]; j++) { if (key == bindings[i].keys[j]) { fputs(bindings[i].description, tty_out); fputs("\033[0m", tty_out); goto next_input; } } } fputs("--- nothing ---\033[0m", tty_out); goto next_input; } case -1: goto next_input; default: { // Search user-defined key bindings from config.h: binding_t *binding; user_bindings: for (int i = 0; bindings[i].keys[0] > 0; i++) { for (int j = 0; bindings[i].keys[j]; j++) { if (key == bindings[i].keys[j]) { // Move to front optimization: if (i > 2) { binding_t tmp; tmp = bindings[0]; bindings[0] = bindings[i]; bindings[i] = tmp; i = 0; } binding = &bindings[i]; goto run_binding; } } } // Nothing matched goto next_input; run_binding: if (cmdpos != 0) err("Command file still open"); if (binding->command[0] == '+') { switch (execute_cmd(bb, binding->command + 1)) { case BB_INVALID: break; case BB_NOP: goto redraw; case BB_REFRESH: goto refresh; case BB_QUIT: goto quit; } goto get_keyboard_input; } move_cursor(tty_out, 0, termheight-1); if (binding->flags & NORMAL_TERM) { fputs(T_OFF(T_ALT_SCREEN), tty_out); fputs(T_ON(T_WRAP), tty_out); } if (binding->flags & AT_CURSOR && !bb->firstselected) { move_cursor(tty_out, 0, 3 + bb->cursor - bb->scroll); fputs("\033[K", tty_out); } close_term(); run_cmd_on_selection(bb, binding->command); init_term(); if (binding->flags & NORMAL_TERM) fputs(T_ON(T_ALT_SCREEN), tty_out); if (binding->flags & NORMAL_TERM) lazy = 0; check_cmds = 1; goto redraw; } } goto next_input; quit: populate_files(bb, NULL); fputs(T_LEAVE_BBMODE, tty_out); close_term(); } /* * Print the current key bindings */ void print_bindings(int verbose) { struct winsize sz = {0}; ioctl(STDOUT_FILENO, TIOCGWINSZ, &sz); int _width = sz.ws_col; if (_width == 0) _width = 80; char buf[1024]; char *kb = "Key Bindings"; printf("\n\033[33;1;4m\033[%dG%s\033[0m\n\n", (_width-(int)strlen(kb))/2, kb); for (int i = 0; bindings[i].keys[0]; i++) { char *p = buf; for (int j = 0; bindings[i].keys[j]; j++) { if (j > 0) *(p++) = ','; int key = bindings[i].keys[j]; const char *name = bkeyname(key); if (name) p = stpcpy(p, name); else if (' ' <= key && key <= '~') p += sprintf(p, "%c", (char)key); else p += sprintf(p, "\033[31m\\x%02X", key); } *p = '\0'; printf("\033[1m\033[%dG%s\033[0m", _width/2 - 1 - (int)strlen(buf), buf); printf("\033[0m\033[%dG\033[34;1m%s\033[0m", _width/2 + 1, bindings[i].description); if (verbose) { printf("\n\033[%dG\033[0;32m", MAX(1, (_width - (int)strlen(bindings[i].command))/2)); fputs_escaped(stdout, bindings[i].command, "\033[0;32m"); fflush(stdout); } printf("\033[0m\n"); } printf("\n"); } int main(int argc, char *argv[]) { char *initial_path = NULL, *depthstr; char sep = '\n'; int print_dir = 0, print_selection = 0; int cmd_args = 0; for (int i = 1; i < argc; i++) { if (argv[i][0] == '+') { ++cmd_args; continue; } if (strcmp(argv[i], "--") == 0) { if (i + 1 < argc) initial_path = argv[i+1]; break; } if (argv[i][0] != '-' && !initial_path) initial_path = argv[i]; } if (!initial_path && cmd_args == 0) initial_path = "."; FILE *cmdfile = NULL; if (initial_path) { has_initial_path: cmdfilename = memcheck(strdup(CMDFILE_FORMAT)); if (!mktemp(cmdfilename)) err("Couldn't create tmpfile\n"); cmdfile = fopen(cmdfilename, "a"); if (!cmdfile) err("Couldn't create cmdfile: '%s'\n", cmdfilename); // Set up environment variables depthstr = getenv("BB_DEPTH"); int depth = depthstr ? atoi(depthstr) : 0; if (asprintf(&depthstr, "%d", depth + 1) < 0) err("Allocation failure"); setenv("BB_DEPTH", depthstr, 1); setenv("BBCMD", cmdfilename, 1); } else if (cmd_args > 0) { char *parent_bbcmd = getenv("BBCMD"); if (!parent_bbcmd || parent_bbcmd[0] == '\0') { initial_path = "."; goto has_initial_path; } cmdfile = fopen(parent_bbcmd, "a"); if (!cmdfile) err("Couldn't open cmdfile: '%s'\n", parent_bbcmd); } int i; for (i = 1; i < argc; i++) { if (argv[i][0] == '?') { fclose(cmdfile); init_term(); char *line = breadline(tty_in, tty_out, argv[i] + 1, argv[i+1]); close_term(); if (!line) return 1; fputs(line, stdout); free(line); fflush(stdout); return 0; } if (argv[i][0] == '+') { fwrite(argv[i]+1, sizeof(char), strlen(argv[i]+1)+1, cmdfile); continue; } if (strcmp(argv[i], "--") == 0) break; if (strcmp(argv[i], "--help") == 0) { usage: printf("bb - an itty bitty console TUI file browser\n"); printf("Usage: bb [-h/--help] [-s] [-b] [-0] [path]\n"); return 0; } if (strcmp(argv[i], "--version") == 0) { version: printf("bb " BB_VERSION "\n"); return 0; } if (argv[i][0] == '-' && argv[i][1] == '-') { if (argv[i][2] == '\0') break; continue; } if (argv[i][0] == '-') { for (char *c = &argv[i][1]; *c; c++) { switch (*c) { case 'h':goto usage; case 'v': goto version; case 'd': print_dir = 1; break; case '0': sep = '\0'; break; case 's': print_selection = 1; break; case 'b': print_bindings(0); return 0; case 'B': print_bindings(1); return 0; } } continue; } } if (cmdfile) fclose(cmdfile); if (!initial_path) return 0; // Default values setenv("SHELL", "bash", 0); setenv("EDITOR", "nano", 0); setenv("PAGER", "less", 0); signal(SIGTERM, cleanup_and_exit); signal(SIGINT, cleanup_and_exit); signal(SIGXCPU, cleanup_and_exit); signal(SIGXFSZ, cleanup_and_exit); signal(SIGVTALRM, cleanup_and_exit); signal(SIGPROF, cleanup_and_exit); signal(SIGSEGV, cleanup_and_exit); char *real = realpath(initial_path, NULL); if (!real || chdir(real)) err("Not a valid path: %s\n", initial_path); bb_t *bb = memcheck(calloc(1, sizeof(bb_t))); bb->options.columns[0] = 'n'; bb->options.sort[0] = 'n'; bb_browse(bb, real); free(real); if (bb->firstselected && print_selection) { for (entry_t *e = bb->firstselected; e; e = e->next) { const char *p = e->fullname; while (*p) { const char *p2 = strchr(p, '\n'); if (!p2) p2 = p + strlen(p); write(STDOUT_FILENO, p, (size_t)(p2 - p)); if (*p2 == '\n' && sep == '\n') write(STDOUT_FILENO, "\\", 1); p = p2; } write(STDOUT_FILENO, &sep, 1); } fflush(stdout); } if (print_dir) { printf("%s\n", initial_path); } for (int m = 0; m < 128; m++) if (bb->marks[m]) free(bb->marks[m]); free(bb); return 0; } // vim: ts=4 sw=0 et cino=L2,l1,(0,W4,m1