diff --git a/.gitmodules b/.gitmodules index 2c8f25c..e69de29 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +0,0 @@ -[submodule ".qfc"] - path = .qfc - url = https://github.com/pindexis/qfc - branch = master diff --git a/.qfc b/.qfc deleted file mode 160000 index 3ed9e53..0000000 --- a/.qfc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3ed9e530af70a1ce9c6b23abb10c5f7617217494 diff --git a/.qfc/README.md b/.qfc/README.md new file mode 100644 index 0000000..5c56a1b --- /dev/null +++ b/.qfc/README.md @@ -0,0 +1,50 @@ +# qfc +Quick Command-line File Completion + +![qfc](https://cloud.githubusercontent.com/assets/2557967/8640880/582cb8fe-28ff-11e5-9753-41464dda938e.gif) + +qfc is a shell auto-complete alternative which features real-time multi-directories matching: It provides results while you type against files in the current directory and its sub-directories. +This is useful, to avoid the burden of writing the whole path whenever you want to `cd` or `vim` a file, which is frequent especially if you use the terminal as your IDE(The terminal is the best IDE, remember! :-) ). + + +## Features: +- Real-time matching: Results are displayed while you type. +- Multi-directories && Context relevant matching: if you're in a cvs(git,mercurial) managed directory, qfc will matches against your tracked(or new) files only. This is very useful to avoid 10000+ of dependency files cluttering up the results. for unmanaged dirs, qfc looks for unhidden files up to a maximum depth(set to 3). +- Enhanced Filtering/Sorting of matches. +- No dependencies. + + +## Requirements +- python (2.7+ or 3.0+) +- Bash-4.0+ or Zshell. +- Linux Or OSX +In OSX, it seems like Bash 3.x is the default shell which is not supported. you have to [update your Bash to 4.0+](http://apple.stackexchange.com/a/24635) or [change your shell to zshell](http://stackoverflow.com/a/1822126/1117720). + + +## Installation: +- `git clone https://github.com/pindexis/qfc $HOME/.qfc` +- Add the following line to your *rc (.zshrc, .bashrc, .bash_profile in OSX): + `[[ -s "$HOME/.qfc/bin/qfc.sh" ]] && source "$HOME/.qfc/bin/qfc.sh"` + + +## Usage: +- `Ctrl-f` : complete the word under cursor using qfc +- while qfc is open: + - `TAB`: Append the selected match to the current path. + - `ENTER`: Append the selected match to the current path and returns the result. + - `Ctrl-f`: Returns the current path. + - `Arrow keys`: Navigation between files. + + +## Even more Productivity: +If you're using zshell or Bash 4.3+, You can combine qfc with commands you frequently use to get one key-stroke experience. For example, I have the following lines in my .zshrc: +``` +qfc_quick_command 'cd' '\C-b' 'cd $0' +qfc_quick_command 'vim' '\C-p' 'vim $0' +``` +This allows me to switch directories by just pressing Ctrl-b(or editing a file by pressing Ctrl-p). +![qfc](https://cloud.githubusercontent.com/assets/2557967/8654777/78534320-2984-11e5-8684-f18709af0748.gif) + +`qfc_quick_command` expects an `id`, `a shortcut`, and a command with `$0` placeholder(which will be replaced with the completion path). +It's recommended to choose a 2-5 length letters only `id`(else you may encounter issues). +Also, be careful with what keyboard shortcuts to choose(mapping some keys can prevent the terminal from working correctly). diff --git a/.qfc/bin/qfc b/.qfc/bin/qfc new file mode 100755 index 0000000..bc47296 --- /dev/null +++ b/.qfc/bin/qfc @@ -0,0 +1,33 @@ +#!/usr/bin/env python +import sys +import argparse +import os +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)),'..')) +from qfc import core + +def parse_arguments(): + parser = argparse.ArgumentParser(description="Qfc: Quick command-line file competion") + + parser.add_argument("--search", type=str, help="Search string") + parser.add_argument("--stdout", type=str, help="Where to store result(defaut to stdout)") + parser.set_defaults(func=cmd_get) + + args = parser.parse_args() + return args.func(args) + +def cmd_get(args): + output = core.get_selected_command_or_input(args.search) + if not output: + output = "" + if args.stdout: + with open(args.stdout,'w+') as save_file: + # the newline character is to make sure 'wc -l' executes correctly + if output: + output+="\n" + save_file.write(output) + else: + print(output) + + +if __name__ == "__main__": + parse_arguments() diff --git a/.qfc/bin/qfc.sh b/.qfc/bin/qfc.sh new file mode 100755 index 0000000..f6b361e --- /dev/null +++ b/.qfc/bin/qfc.sh @@ -0,0 +1,126 @@ +# default key bindings +complete_shortcut="${qfc_complete_SHORTCUT:-\C-f}" + +function get_cursor_position(){ + # based on a script from http://invisible-island.net/xterm/xterm.faq.html + exec < /dev/tty + oldstty=$(stty -g) + stty raw -echo min 0 + # on my system, the following line can be replaced by the line below it + echo -en "\033[6n" > /dev/tty + # tput u7 > /dev/tty # when TERM=xterm (and relatives) + IFS=';' read -r -d R row col + stty $oldstty + # change from one-based to zero based so they work with: tput cup $row $col + row=$((${row:2} - 1)) # strip off the esc-[ + col=$((${col} - 1)) + echo "$row $col" +} + +if [[ -d ~/.qfc/ ]]; then + export PATH=~/.qfc/bin:"${PATH}" +fi + +if [[ -n "$ZSH_VERSION" ]]; then + # zshell + function qfc_complete { + # Add a letter and remove it from the buffer. + # when using zsh autocomplete(pressing Tab), then running qfc, the BUFFER(qfc input) won't contain the trailing forward slash(which should happen when using zsh autocomplete for directories). + # pressing a character then removing it makes sure that BUFFER contains what you see on the screen. + BUFFER=${BUFFER}'a' + BUFFER=${BUFFER[0,-2]} + # get the cursor offset within the user input + offset=${CURSOR} + zle beginning-of-line + # get the offset from the start of comandline prompt + col=$(echo $(get_cursor_position) | cut -f 2 -d " ") + # place the cursor at the next line + = 32: + state.set_input(state.input + chr(c)) + ui.refresh(state) + + +class State(object): + ''' The Current User state, including user written characters, matched commands, and selected one ''' + + def __init__(self, default_input): + self._selected_command_index = 0 + self.matches = [] + self.default_input = default_input + self.set_input(default_input) + + def get_matches(self): + return self.matches + + def reset_input(self): + self.input = self.default_input + + def set_input(self, input): + self.input = input if input else "" + self._update() + + def append_match_to_input(self): + self.set_input(os.path.join(os.path.dirname(self.input), self.get_selected_match())) + + def go_back(self): + isdir = is_dir(self.input) + input_stripped = self.input.rstrip(os.sep) + if not input_stripped: + return + input_splitted = input_stripped.split(os.sep) + entry_name = input_splitted[-1] + if isdir: + entry_name += os.sep + new_input = os.sep.join(input_splitted[0:-1]) + if new_input: + new_input += os.sep + self.set_input(new_input) + self.set_selected_entry(entry_name) + + def clear_input(self): + self.set_input("") + + def clear_selection(self): + self._selected_command_index = 0 + + def select_next(self): + self._selected_command_index = (self._selected_command_index + 1) % len(self.matches) if len(self.matches) else 0 + + def select_previous(self): + self._selected_command_index = (self._selected_command_index - 1) % len(self.matches) if len(self.matches) else 0 + + def _update(self): + self.matches = get_matches(os.getcwd(),self.input) + self._selected_command_index = 0 + + def get_output(self): + return self.input + + def get_selected_match(self): + if len(self.matches): + return self.matches[self._selected_command_index] + else: + raise Exception('No matches found') + + def set_selected_entry(self, entry): + if not (entry in self.matches): + return + self._selected_command_index = self.matches.index(entry) + + +def get_matches(root_dir, user_input): + start_dir = join_paths(root_dir, os.path.dirname(user_input)) + search_str = os.path.basename(user_input) + if not os.path.isdir(start_dir): + return [] + source_files = dirhandler.get_source_files(start_dir) + filtered_files = filter_files(source_files, search_str) + sorted_files = sort_matches(filtered_files, search_str) + return sorted_files + + +def filter_files(files, search_str): + """ Filter a list of files based on a search string + :param files: list of files to filter (ie ['/a','/a/b', '/a/b/c', '/b']), the order doesn't matter. 'a' and 'a/' are considered different. + :param search_str, the filtering string (ie 'a') + + This function return only first level files/dirs that match the given search string. That is, filtering ['/a','/a/b','/a/b/c'] with the string 'a' returns only ['/a'], + This is to avoid polluting the screen with all files inside a directory when a user look for that directory + """ + matched = set() + if not search_str: + for f in files: + # right strip to the first seperator(ie '/') + f = f[:_index_or_len(f, os.sep)+1] + matched.add(f) + else: + search_str = search_str.lower() + for f in files: + if search_str in f.lower(): + index = f.lower().index(search_str) + trail = f[index:] + f = f[:index + _index_or_len(trail, os.sep)+1] + matched.add(f) + return matched + +def sort_matches(matches, string): + """ Sort paths according to a string """ + files = sorted(matches, key=lambda s: s.lower()) + return sorted(files, key=lambda p:get_weight(p, string)) + +def get_weight(path, string): + """ calculate how much a path matches a given string on a scale of 0->10000, a lower number means a better match. + The string should be present in the path, or this function will fail + + The weight is calculated using the following formula: + 0 00 0 + | | | + Number of elements in the path <----------------| | |--------------------------> is a directory(0) or not(1) + (ie 2 for 'aa/bb' and 3 for 'aa/bb/cc') | + | + v + There is a word in the matched path element that exactly match the given string? ('aa_bb' matches user input 'aa' but doesn't match 'a') + | + No <--------------------|-----------------------------> yes + | | + | | + | | + v v + path element starts with the user input? There is only one word in the matched path element ('aa' but not 'aa_bb'), + | which means the path elements exactly match the string + | | + No <-------|-----Yes(2) No -----|----- Yes (0) + | | + v v +index of the string in the path element + 10 (index of the matched word within path element words + 1) + + + Maximums are added to make sure things doesn't overlap + """ + weight = 0 + if not is_dir(path): + weight += 1 + + string = string.lower() + p = path.rstrip(os.sep).lower() + path_elems = p.split(os.sep) + weight += min(len(path_elems) * 1000, 10000) # Max 10 elements in path(set it to 10 for longer paths) + + if not string: + return weight + + elm = next(e for e in path_elems if string in e) + elm_words = [_f for _f in re.split('[\W_]+', elm) if _f] + if string in elm_words: + if len(elm_words) > 1: + weight += 10 * (min(elm_words.index(string),8) + 1) # Max 8 words per path entry + else: + if elm.startswith(string): + weight += 10 * 2 + else: + weight += 10 * (10 + min(elm.index(string), 89)) # Max path element with 89 characters + return weight + +# Helper functions: + +def join_paths(p1, p2): + p1 = (os.path.expandvars(os.path.expanduser(p1))) + p2 = (os.path.expandvars(os.path.expanduser(p2))) + return os.path.normpath(os.path.join(p1, p2)) + +def _index_or_len(s, c): + if c in s: + return s.index(c) + else: + return len(s) + +def is_dir(p): + if p.endswith(os.sep): + return True + return False diff --git a/.qfc/qfc/dirhandler.py b/.qfc/qfc/dirhandler.py new file mode 100644 index 0000000..a08d67f --- /dev/null +++ b/.qfc/qfc/dirhandler.py @@ -0,0 +1,114 @@ +import os +import subprocess +import sys + +class CVSHandler(): + """ Handler of CVS (fir, mercurial...) directories, + The main purpose of this class is to cache external cvs command output, and determine the appropriate files to yield when navigating to a subdirectory of a project. + This basically means that the external command is run once (ie git ls-files), cached, and when calling get_source_files on a subdirectory of the project root (ie project-root/subdir), + filtering from all project files of is done here. + """ + def __init__(self, cvs): + self._roots_cache = {} + self._not_tracked_cache = set() + self.cvs = cvs + + def _get_root_from_cache(self, directory): + """ a directory is considered cached if it's the project root or a subdirectory of that project root. + returns the project root dir, or None if the directory is not cached. + """ + if directory in self._roots_cache: + return directory + if os.path.dirname(directory) == directory: + return None + return self._get_root_from_cache(os.path.dirname(directory)) + + def get_source_files(self, directory): + if directory in self._not_tracked_cache: + return None + + root_dir = self._get_root_from_cache(directory) + if not root_dir: + try: + # check if it's a tracked cvs dir, if yes, get the project root and the source files + root_dir = self.cvs._get_root(directory) + self._roots_cache[root_dir] = self.cvs._get_tracked_files(root_dir) + except Exception as e: + # not a cvs tracked dir, save it to not issue that command again + self._not_tracked_cache.add(directory) + return None + + files = self._roots_cache[root_dir] + # the passed directory argument is a subdirectory of the project root + if directory != root_dir: + rel_dir = os.path.relpath(directory, root_dir) + files = [f[len(rel_dir)+1:] for f in files if f.startswith(rel_dir)] + return files + + +class Git(): + @staticmethod + def _get_root(directory): + return run_command("cd %s && git rev-parse --show-toplevel" % directory).strip() + @staticmethod + def _get_tracked_files(directory): + return run_command("cd %s && git ls-files && git ls-files --others --exclude-standard" % directory).strip().split('\n') + +class Mercurial(): + @staticmethod + def _get_root(directory): + return run_command("cd %s && hg root" % directory).strip() + @staticmethod + def _get_tracked_files(directory): + return run_command("cd %s && (hg status -marcu | cut -d' ' -f2)" % directory).strip().split('\n') + +class DefaultDirHandler(): + """ The default directory handler uses the 'find' external program to return all the files inside a given directory up to MAX_depth depth (ie, if maxdepth=2, returns all files inside that dir, and all files in a subdir of that directory)""" + + def __init__(self): + self._cache = {} + self.MAX_DEPTH = 3 + + def _walk_down(self, start_dir): + try: + out = run_command("find %s -maxdepth %s -type f -not -path '*/\.*'" % (start_dir, self.MAX_DEPTH)) + except subprocess.CalledProcessError as e: + # Find returns a non 0 exit status if listing a directory fails (for example, permission denied), but still output all files in other dirs + # ignore those failed directories. + out = e.output + if sys.version_info >= (3, 0): + out = out.decode('utf-8') + if not out: + return [] + files = out.split('\n') + return [os.path.relpath(f, start_dir) for f in files if f] + + def get_source_files(self, start_dir): + if not start_dir in self._cache: + self._cache[start_dir] = self._walk_down(start_dir) + return self._cache[start_dir] + +def run_command(string): + ''' fork a process to execute the command string given as argument, returning the string written to STDOUT ''' + DEVNULL = open(os.devnull, 'wb') + out = subprocess.check_output(string, stderr=DEVNULL, shell=True) + if sys.version_info >= (3, 0): + return out.decode('utf-8') + return out + + +git = CVSHandler(Git) +hg = CVSHandler(Mercurial) +default = DefaultDirHandler() + +def get_source_files(directory): + """ check first if the given directory is inside a git tracked project, if no, check with mercurial, if no, fallback to the default handler """ + files = git.get_source_files(directory) + # if the returned files list is empty, it's considered not a tracked directory + if files: + return files + files = hg.get_source_files(directory) + if files: + return files + return default.get_source_files(directory) + diff --git a/.qfc/qfc/keys.py b/.qfc/qfc/keys.py new file mode 100644 index 0000000..0568314 --- /dev/null +++ b/.qfc/qfc/keys.py @@ -0,0 +1,19 @@ +CTRL_C = 3 # Ctrl-c +CTRL_H = 8 # ctrl-h +CTRL_J = 10 # ctrl-h +CTRL_K = 11 # ctrl-h +CTRL_L = 12 # ctrl-h +CTRL_F = 6 # ctrl-f +ENTER = 13 # Enter +CTRL_U = 21 # Ctrl+u +ESC = 27 # Escape +BACKSPACE = 127 # Backspace +TAB = 9 # Tab +RIGHT = -1 # FAKE CODE to abstract away the fact that a multibyte string is needed to represent arrow keys +DOWN = -2 # same +UP = -3 # same +LEFT = -4 # same +SHIFTTAB = -5 # same +SHIFTENTER = -6 +SPACE = 32 +ANTISLASH = 47 diff --git a/.qfc/qfc/readchar.py b/.qfc/qfc/readchar.py new file mode 100644 index 0000000..d6c81a7 --- /dev/null +++ b/.qfc/qfc/readchar.py @@ -0,0 +1,65 @@ +import sys +import tty +import termios +import fcntl +import os +from . import keys + + +def get_symbol(): + ''' Read a symbol, which can be a single byte character or a multibyte string''' + ch = read_char() + ch_code = ord(ch) + # check for multibyte string + if ch_code == keys.ESC: + ch = read_char_no_blocking() + if ch == '': + # ESC key pressed + return keys.ESC + elif ch != 'O' and ch != '[': + return ord(ch) + else: + ch = read_char_no_blocking() + if ch == 'A': + return keys.UP + elif ch == 'B': + return keys.DOWN + elif ch == 'C': + return keys.RIGHT + elif ch == 'D': + return keys.LEFT + elif ch == 'Z': + return keys.SHIFTTAB + return ch_code + + +def read_char(): + ''' Read a character ''' + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd, termios.TCSADRAIN) + ch = sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + return ch + + +def read_char_no_blocking(): + ''' Read a character in nonblocking mode, if no characters are present in the buffer, return an empty string ''' + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + old_flags = fcntl.fcntl(fd, fcntl.F_GETFL) + try: + tty.setraw(fd, termios.TCSADRAIN) + fcntl.fcntl(fd, fcntl.F_SETFL, old_flags | os.O_NONBLOCK) + return sys.stdin.read(1) + except IOError as e: + ErrorNumber = e[0] + # IOError with ErrorNumber 11(35 in Mac) is thrown when there is nothing to read(Resource temporarily unavailable) + if (sys.platform.startswith("linux") and ErrorNumber != 11) or (sys.platform == "darwin" and ErrorNumber != 35): + raise + return "" + finally: + fcntl.fcntl(fd, fcntl.F_SETFL, old_flags) + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) diff --git a/.qfc/qfc/ui.py b/.qfc/qfc/ui.py new file mode 100644 index 0000000..c2a24ef --- /dev/null +++ b/.qfc/qfc/ui.py @@ -0,0 +1,60 @@ +from . import ansi +import os +import math + +def _get_terminal_columns(): + ''' get the number of terminal columns, used to determine spanned lines of a mark(reqansired for cursor placement) ''' + _, columns = os.popen('stty size', 'r').read().split() + return int(columns) + +def erase(): + ''' the commandline cursor is always at the first line (user prompt) + Therefore, erasing the current and following lines clear all output + ''' + ansi.move_cursor_line_beggining() + ansi.erase_from_cursor_to_end() + +def refresh(state): + ''' Redraw the output, this function will be triggered on every user interaction(key pressed)''' + erase() + lines, num_rows = _construct_output(state) + for line in lines: + print(line) + # go up + ansi.move_cursor_previous_lines(num_rows) + # palce the cursor at the end of first line) + ansi.move_cursor_horizental(len(lines[0])+1) + ansi.flush() + +def _construct_output(state): + columns = _get_terminal_columns() + def number_of_rows(line): + return int(math.ceil(float(len(line))/columns)) + displayed_lines = [] + # Number of terminal rows spanned by the output, used to determine how many lines we need to go up(to place the cursor after the prompt) after displaying the output + num_rows = 0 + prompt_line = 'Path: ' + state.input + displayed_lines.append(prompt_line) + num_rows += number_of_rows(prompt_line) + matches = state.get_matches() + if matches: + # display commands from Max(0,selected_command_index - 10 +1 ) to Max(10,SelectedCommandIndex + 1) + selected_command_index = matches.index(state.get_selected_match()) + matches_to_display = matches[max(0, selected_command_index - 10 + 1):max(10, selected_command_index + 1)] + for index, m in enumerate(matches_to_display): + fm = ' ' + m + num_rows += number_of_rows(fm) + # Formatting text(make searched word bold) + for w in state.input.split(' '): + if w: + fm = fm.replace(w, ansi.bold_text(w)) + # highlighting selected command + if m == state.get_selected_match(): + fm = ansi.select_text(fm) + displayed_lines.append(fm) + else: + not_found_line = 'Nothing found' + displayed_lines.append(not_found_line) + num_rows += number_of_rows(not_found_line) + return displayed_lines, num_rows + diff --git a/.qfc/tests/test_match.py b/.qfc/tests/test_match.py new file mode 100644 index 0000000..6759534 --- /dev/null +++ b/.qfc/tests/test_match.py @@ -0,0 +1,48 @@ +import sys +import os +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)),'..')) +from qfc.core import filter_files, get_weight + +def _equals(marks_list1, marks_list2): + l1 = sorted(marks_list1) + l2 = sorted(marks_list2) + if len(l1) != len(l2): + return False + for i,_ in enumerate(l1): + if l1[i] != l2[i]: + return False + return True + +def test_filter_files(): + files = [ + '/', + '/a/', + '/b/', + '/a/b', + '/a/b/c', + '/b/a/', + '/b/a/c', + 'd', + 'da' + ] + assert(_equals(filter_files(files,''), ['/','d','da'])) + assert(_equals(filter_files(files,'/'), ['/'])) + assert(_equals(filter_files(files,'a'), ['/a/', '/b/a/', 'da'])) + + + +def test_weight(): + assert(get_weight('a','') == 1001) + assert(get_weight('a/','') == 1000) + assert(get_weight('a/b/','') == 2000) + assert(get_weight('a/b/c','') == 3001) + assert(get_weight('a','a') == 1001) + assert(get_weight('ab','a') == 1021) + assert(get_weight('bab','a') == 1111) + assert(get_weight('a_b','a') == 1011) + assert(get_weight('root/a_b','a') == 2011) + assert(get_weight('root/a_b_c_d_e_f_g_h_i_j_k','k') == 2091) + assert(get_weight('a/b/c/d/e/f/g/h/i/j/k','k') == 10001) + assert(get_weight('a/B/','b') == 2000) + +