diff options
-rw-r--r-- | CMakeLists.txt | 46 | ||||
-rw-r--r-- | LICENSE | 13 | ||||
-rw-r--r-- | Makefile | 12 | ||||
-rw-r--r-- | README.adoc | 75 | ||||
-rw-r--r-- | sdn.cpp | 446 |
5 files changed, 592 insertions, 0 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..03f03c2 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,46 @@ +# target_compile_features has been introduced in that version +cmake_minimum_required (VERSION 3.1.0) + +project (sdn CXX) +set (version 0.1) + +if ("${CMAKE_CXX_COMPILER_ID}" MATCHES "GNU") + set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -pedantic") +endif () + +# Since we use a language with slow compilers, let's at least use a fast linker +execute_process (COMMAND ${CMAKE_CXX_COMPILER} -fuse-ld=gold -Wl,--version + ERROR_QUIET OUTPUT_VARIABLE ld_version) +if ("${ld_version}" MATCHES "GNU gold") + set (CMAKE_EXE_LINKER_FLAGS "-fuse-ld=gold ${CMAKE_EXE_LINKER_FLAGS}") +endif () + +find_package (PkgConfig REQUIRED) +pkg_check_modules (NCURSESW QUIET ncursesw) + +add_executable (${PROJECT_NAME} ${PROJECT_NAME}.cpp) +target_include_directories (${PROJECT_NAME} PUBLIC ${NCURSESW_INCLUDE_DIRS}) +target_link_libraries (${PROJECT_NAME} PUBLIC ${NCURSESW_LIBRARIES}) +target_compile_features (${PROJECT_NAME} PUBLIC cxx_std_14) +target_compile_definitions (${PROJECT_NAME} PUBLIC + -DPROJECT_NAME=\"${PROJECT_NAME}\" -DPROJECT_VERSION=\"${version}\") + +include (GNUInstallDirs) +install (TARGETS ${PROJECT_NAME} DESTINATION ${CMAKE_INSTALL_BINDIR}) +install (FILES LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR}) + +set (CPACK_PACKAGE_DESCRIPTION_SUMMARY "Directory navigator") +set (CPACK_PACKAGE_VENDOR "Premysl Janouch") +set (CPACK_PACKAGE_CONTACT "Přemysl Janouch <p.janouch@gmail.com>") +set (CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE") +set (CPACK_PACKAGE_VERSION ${version}) +set (CPACK_GENERATOR "TGZ;ZIP") +set (CPACK_PACKAGE_FILE_NAME + "${PROJECT_NAME}-${version}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}") +set (CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME}-${version}") +set (CPACK_SOURCE_GENERATOR "TGZ;ZIP") +set (CPACK_SOURCE_IGNORE_FILES "/\\\\.git;/build;/CMakeLists.txt.user") +set (CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${version}") + +set (CPACK_SET_DESTDIR TRUE) +include (CPack) @@ -0,0 +1,13 @@ +Copyright (c) 2017, Přemysl Janouch <p.janouch@gmail.com> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..de28c23 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +SHELL = /bin/sh +CXXFLAGS = -g -std=c++14 -Wall -Wextra -pedantic -static-libstdc++ + +all: sdn +%: %.cpp CMakeLists.txt + $(CXX) $(CXXFLAGS) $< -o $@ `pkg-config --libs --cflags ncursesw` \ + `sed -ne 's/^project (\([^ )]*\).*/-DPROJECT_NAME="\1"/p' \ + -e 's/^set (version \([^ )]*\).*/-DPROJECT_VERSION="\1"/p' CMakeLists.txt` +clean: + rm -f sdn + +.PHONY: all clean diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..e0c7dce --- /dev/null +++ b/README.adoc @@ -0,0 +1,75 @@ +sdn +=== +:compact-option: + +'sdn' is a simple directory navigator that you can invoke while editing shell +commands. It enables you to: + + * take a quick peek at directory contents without running `ls` + * browse the filesystem without all the mess that Midnight Commander does: + there's no need to create a subshell in a new pty. The current command line + can be simply forwarded if it is to be edited. What's more, it will always + be obvious whether the navigator is running. + +Development has just started and the only supported platform is Linux. +I wanted to try a different, simpler approach here. + +Building +-------- +Build dependencies: CMake and/or make, a C++14 compiler, pkg-config + +Runtime dependencies: ncursesw + + $ git clone https://github.com/pjanouch/sdn.git + $ mkdir sdn/build + $ cd sdn/build + $ cmake .. -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Debug + $ make + +To install the application, you can do either the usual: + + # make install + +Or you can try telling CMake to make a package for you. For Debian it is: + + $ cpack -G DEB + # dpkg -i sdn-*.deb + +There is also a Makefile you can use to quickly build a binary to be copied +into the PATH of any machine you want to have 'sdn' on. + +zsh +--- +To start using this navigator, put the following in your .zshrc: +.... +navigate () { + # ... possibly zle-line-init + eval `navigator` + [ -z "$cd" ] || cd "$cd" + [ -z "$insert" ] || LBUFFER="$LBUFFER$insert " + zle reset-prompt + # ... possibly zle-line-finish +} +zle -N navigate +bindkey '\eo' navigate +.... + +As far as I'm aware, bash cannot be used for this, as there is no command to +reset the prompt from within a `bind -x` handler. + +Contributing and Support +------------------------ +Use this project's GitHub to report any bugs, request features, or submit pull +requests. If you want to discuss this project, or maybe just hang out with +the developer, feel free to join me at irc://irc.janouch.name, channel #dev. + +Bitcoin donations: 12r5uEWEgcHC46xd64tt3hHt9EUvYYDHe9 + +License +------- +'sdn' is written by Přemysl Janouch <p.janouch@gmail.com>. + +You may use the software under the terms of the ISC license, the text of which +is included within the package, or, at your option, you may relicense the work +under the MIT or the Modified BSD License, as listed at the following site: + +http://www.gnu.org/licenses/license-list.html @@ -0,0 +1,446 @@ +// +// sdn: simple directory navigator +// +// Copyright (c) 2017, Přemysl Janouch <p.janouch@gmail.com> +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +#include <string> +#include <vector> +#include <locale> +#include <iostream> +#include <algorithm> +#include <cwchar> +#include <climits> + +#include <ncurses.h> +#include <unistd.h> +#include <dirent.h> +#include <sys/stat.h> +#include <sys/inotify.h> +#include <fcntl.h> + +// Unicode is complex enough already and we might make assumptions +#ifndef __STDC_ISO_10646__ +#error Unicode required for wchar_t +#endif + +using namespace std; + +// For some reason handling of encoding in C and C++ is extremely annoying +// and C++17 ironically obsoletes C++11 additions that made it less painful +static wstring +to_wide (const string &multi) { + wstring wide; wchar_t w; mbstate_t mb {}; + size_t n = 0, len = multi.length () + 1; + while (auto res = mbrtowc (&w, multi.c_str () + n, len - n, &mb)) { + if (res == size_t (-1) || res == size_t (-2)) + return L"/invalid encoding/"; + + n += res; + wide += w; + } + return wide; +} + +static string +to_mb (const wstring &wide) { + string mb; char buf[MB_LEN_MAX + 1]; mbstate_t mbs {}; + for (size_t n = 0; n <= wide.length (); n++) { + auto res = wcrtomb (buf, wide.c_str ()[n], &mbs); + if (res == size_t (-1)) + throw invalid_argument ("invalid encoding"); + mb.append (buf, res); + } + // There's one extra NUL character added by wcrtomb() + mb.erase (mb.length () - 1); + return mb; +} + +static int +print (const wstring &wide, int limit) { + int total_width = 0; + for (wchar_t w : wide) { + // TODO: controls as ^X, show in inverse + if (!isprint (w)) + w = L'?'; + + int width = wcwidth (w); + if (total_width + width > limit) + break; + + cchar_t c = {}; + c.chars[0] = w; + add_wch (&c); + total_width += width; + } + return total_width; +} + +static int +prefix (const wstring &in, const wstring &of) { + int score = 0; + for (size_t i = 0; i < of.size () && in.size () >= i && in[i] == of[i]; i++) + score++; + return score; +} + +static string +shell_escape (const string &v) { + string result; + for (auto c : v) + if (c == '\'') + result += "'\\''"; + else + result += c; + return "'" + result + "'"; +} + +// --- Application ------------------------------------------------------------- + +#define CTRL 31 & + +struct entry { + string filename; + struct stat info; + bool operator< (const entry &other) { + auto a = S_ISDIR (info.st_mode); + auto b = S_ISDIR (other.info.st_mode); + return (a && !b) || (a == b && filename < other.filename); + } +}; + +// Between std and ncurses, make at least the globals stand out +static struct { + string cwd; + vector<entry> entries; + int offset, cursor; + string chosen; + bool chosen_full; + int inotify_fd, inotify_wd = -1; + bool out_of_date; + + wchar_t editor; + wstring editor_line; +} g; + +static inline int visible_lines () { return LINES - 2; } + +static void +update () { + erase (); + + attrset (A_BOLD); + mvprintw (0, 0, "%s", g.cwd.c_str ()); + if (g.out_of_date) + addstr (" [+]"); + + for (int i = 0; i < visible_lines (); i++) { + int index = g.offset + i; + if (index >= int (g.entries.size ())) + break; + + attrset (0); + if (index == g.cursor) + attron (A_REVERSE); + + move (2 + i, 0); + auto &entry = g.entries[index]; + + // TODO display more information from "info" + char modes[] = "- "; + const auto &stat = entry.info; + if (S_ISDIR (stat.st_mode)) modes[0] = 'd'; + if (S_ISBLK (stat.st_mode)) modes[0] = 'b'; + if (S_ISCHR (stat.st_mode)) modes[0] = 'c'; + if (S_ISLNK (stat.st_mode)) modes[0] = 'l'; + if (S_ISFIFO (stat.st_mode)) modes[0] = 'p'; + if (S_ISSOCK (stat.st_mode)) modes[0] = 's'; + addstr (modes); + + // TODO show symbolic link target + auto width = COLS - 2; + hline (' ', width - print (to_wide (entry.filename), width)); + } + + attrset (0); + if (g.editor) { + move (1, 0); + wchar_t prefix[] = { g.editor, L' ', L'\0' }; + addwstr (prefix); + move (1, print (g.editor_line, COLS - 3) + 2); + curs_set (1); + } else + curs_set (0); + + refresh (); +} + +static void +reload () { + char buf[4096]; + g.cwd = getcwd (buf, sizeof buf); + + auto dir = opendir ("."); + g.entries.clear (); + while (auto f = readdir (dir)) { + // Two dots are for navigation but this ain't as useful + if (f->d_name == string (".")) + continue; + + struct stat sb = {}; + lstat (f->d_name, &sb); + g.entries.push_back ({ f->d_name, sb }); + } + closedir (dir); + sort (begin (g.entries), end (g.entries)); + g.out_of_date = false; + + g.cursor = min (g.cursor, int (g.entries.size ()) - 1); + g.offset = min (g.offset, int (g.entries.size ()) - 1); + update (); + + if (g.inotify_wd != -1) + inotify_rm_watch (g.inotify_fd, g.inotify_wd); + + g.inotify_wd = inotify_add_watch (g.inotify_fd, buf, + IN_ALL_EVENTS | IN_ONLYDIR); +} + +static void +search (const wstring &needle) { + int best = g.cursor, best_n = 0; + for (int i = 0; i < int (g.entries.size ()); i++) { + auto o = (i + g.cursor) % g.entries.size (); + int n = prefix (to_wide (g.entries[o].filename), needle); + if (n > best_n) { + best = o; + best_n = n; + } + } + g.cursor = best; +} + +static void +handle_editor (wint_t c, bool is_char) { + if (c == 27 || c == (CTRL L'g')) { + g.editor_line.clear (); + g.editor = 0; + } else if (c == L'\r' || (!is_char && c == KEY_ENTER)) { + if (g.editor == L'e') { + auto mb = to_mb (g.editor_line); + rename (g.entries[g.cursor].filename.c_str (), mb.c_str ()); + reload (); + } + g.editor_line.clear (); + g.editor = 0; + } else if (is_char) { + g.editor_line += c; + if (g.editor == L'/' + || g.editor == L's') + search (g.editor_line); + } else if (c == KEY_BACKSPACE) { + if (!g.editor_line.empty ()) + g.editor_line.erase (g.editor_line.length () - 1); + } else + beep (); +} + +static bool +handle (wint_t c, bool is_char) { + // If an editor is active, let it handle the key instead and eat it + if (g.editor) { + handle_editor (c, is_char); + c = WEOF; + } + + // Translate the Alt key into a bit outside the range of Unicode + enum { ALT = 1 << 24 }; + if (c == 27) { + if (get_wch (&c) == ERR) { + beep (); + return true; + } + c |= ALT; + } + + const auto ¤t = g.entries[g.cursor]; + switch (c) { + case ALT | L'\r': + case ALT | KEY_ENTER: + g.chosen_full = true; + g.chosen = current.filename; + return false; + case L'\r': + case KEY_ENTER: + { + bool is_dir = S_ISDIR (current.info.st_mode) != 0; + // Dive into directories and accessible symlinks to them + if (S_ISLNK (current.info.st_mode)) { + char buf[PATH_MAX]; + struct stat sb = {}; + auto len = readlink (current.filename.c_str (), buf, sizeof buf); + is_dir = len > 0 && size_t (len) < sizeof buf + && !stat (current.filename.c_str (), &sb) + && S_ISDIR (sb.st_mode) != 0; + } + if (!is_dir) { + g.chosen = current.filename; + return false; + } + if (!chdir (current.filename.c_str ())) { + g.cursor = 0; + reload (); + } + break; + } + + // M-o ought to be the same shortcut the navigator is launched with + case ALT | L'o': + case L'q': + return false; + + case L'k': case CTRL L'p': case KEY_UP: + g.cursor--; + break; + case L'j': case CTRL L'n': case KEY_DOWN: + g.cursor++; + break; + case L'g': case ALT | L'<': case KEY_HOME: + g.cursor = 0; + break; + case L'G': case ALT | L'>': case KEY_END: + g.cursor = int (g.entries.size ()) - 1; + break; + + case KEY_PPAGE: g.cursor -= LINES; break; + case KEY_NPAGE: g.cursor += LINES; break; + + case CTRL L'e': g.offset++; break; + case CTRL L'y': g.offset--; break; + + case ALT | L'e': + g.editor_line = to_wide (current.filename); + // Fall-through + case L'e': + g.editor = c & ~ALT; + break; + case L'/': + case L's': + g.editor = c; + break; + + case CTRL L'L': + clear (); + break; + case L'r': + reload (); + break; + case KEY_RESIZE: + case WEOF: + break; + default: + beep (); + } + g.cursor = max (g.cursor, 0); + g.cursor = min (g.cursor, int (g.entries.size ()) - 1); + + // Make sure cursor is visible + g.offset = max (g.offset, 0); + g.offset = min (g.offset, int (g.entries.size ()) - 1); + + if (g.offset > g.cursor) + g.offset = g.cursor; + if (g.cursor - g.offset >= visible_lines ()) + g.offset = g.cursor - visible_lines () + 1; + + update (); + return true; +} + +static void +inotify_check () { + // Only provide simple indication that contents might have changed + char buf[4096]; ssize_t len; + bool changed = false; + while ((len = read (g.inotify_fd, buf, sizeof buf)) > 0) { + const inotify_event *e; + for (char *ptr = buf; ptr < buf + len; ptr += sizeof *e + e->len) { + e = (const inotify_event *) buf; + if (e->wd == g.inotify_wd) + changed = true; + } + } + if (changed) + update (); +} + +int +main (int argc, char *argv[]) { + (void) argc; + (void) argv; + + // That bitch zle closes stdin before exec without redirection + (void) close (STDIN_FILENO); + if (open ("/dev/tty", O_RDWR)) { + cerr << "cannot open tty" << endl; + return 1; + } + + // Save the original stdout and force ncurses to use the terminal directly + auto output_fd = dup (STDOUT_FILENO); + dup2 (STDIN_FILENO, STDOUT_FILENO); + + if ((g.inotify_fd = inotify_init1 (IN_NONBLOCK)) < 0) { + cerr << "cannot initialize inotify" << endl; + return 1; + } + + locale::global (locale ("")); + if (!initscr () || cbreak () == ERR || noecho () == ERR || nonl () == ERR + || halfdelay (1) == ERR || keypad (stdscr, TRUE) == ERR) { + cerr << "cannot initialize screen" << endl; + return 1; + } + + reload (); + auto start_dir = g.cwd; + + wint_t c; + while (1) { + inotify_check (); + int res = get_wch (&c); + if (res != ERR && !handle (c, res == OK)) + break; + } + endwin (); + + // Presumably it is going to end up as an argument, so quote it + if (!g.chosen.empty ()) + g.chosen = shell_escape (g.chosen); + + // We can't portably create a standard stream from an FD, so modify the FD + dup2 (output_fd, STDOUT_FILENO); + + if (g.chosen_full) { + auto full_path = g.cwd + "/" + g.chosen; + cout << "local insert=" << shell_escape (full_path) << endl; + return 0; + } + if (g.cwd != start_dir) + cout << "local cd=" << shell_escape (g.cwd) << endl; + if (!g.chosen.empty ()) + cout << "local insert=" << shell_escape (g.chosen) << endl; + return 0; +} |