Last active
September 13, 2020 01:08
-
-
Save adscriven/71b932ea1d87689379bc3c164c0ac1b9 to your computer and use it in GitHub Desktop.
Interactive file search (Vim)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
" Way out of date now ... | |
" findfile.vim -- interactive file search | |
" Experimental; caveat emptor. | |
" Documentation is at the end. | |
" Public Domain. | |
let s:cpo = &cpo | |
set cpo&vim | |
" The script-local variabless are considered to be constants. | |
let s:ei = &ei | |
" Options | |
let s:canhighlightregexp = 1 | |
let s:defmode = 'basic' | |
let s:canpreview = 1 | |
let s:canhighlightsingleitem = 1 | |
let s:delay = { | |
\ 'showpreview': 100, | |
\ 'cursormoved': 350, | |
\ 'changeinput': 900 | |
\ } | |
let s:thisfile = expand('<sfile>:t') | |
let s:cacheprefix = expand('~/findcache') | |
let s:unavailprv = expand('~/[not\ available]') | |
let s:modes = ['basic', 'advanced', 'regexp'] | |
let s:modesym = ' +/' | |
let s:infolnum = 1 | |
let s:inputlnum = 2 | |
let s:item1lnum = 3 | |
fun! s:getmodel() | |
return get(b:, 'find', {}) | |
endfun | |
fun! s:err(msg) | |
echohl errormsg | |
echo '(' . s:thisfile . ') ' . a:msg | |
echohl None | |
endfun | |
" msg -> update -> task | |
fun! s:do(id, f) | |
let m = s:getmodel() | |
set ei=all | |
call a:f() | |
let &ei = s:ei | |
endfun | |
fun! s:later(t, id, f) | |
let m = s:getmodel() | |
call timer_stop(get(m.timer, a:id, 0)) | |
let id = timer_start(a:t, {t_id -> s:do(a:id, a:f)}, {'repeat': 1}) | |
let m.timer[a:id] = id | |
endfun | |
" 'changeinput' msgs are a special case, because | |
" we want to implement a variable delay for those. | |
fun! s:msg(name, in) | |
let m = s:getmodel() | |
let out = s:u_{a:name}(m, a:in) | |
let t = get(s:delay, a:name, 0) | |
if a:name == 'changeinput' && t | |
let t = m.nfilt / t | |
endif | |
call s:later(t, a:name, {-> s:t_{a:name}(out)}) | |
endfun | |
fun! s:q_isoneitem(m) | |
return a:m.nfilt == 1 | |
endfun | |
fun! s:q_oninfoline() | |
let m = s:getmodel() | |
return m.line == s:infolnum | |
endfun | |
fun! s:q_oninputline() | |
let m = s:getmodel() | |
return m.line == s:inputlnum | |
endfun | |
fun! s:q_onitems() | |
let m = s:getmodel() | |
return m.line > s:inputlnum | |
endfun | |
fun! s:restorecursor(m) | |
call setpos('.', a:m.pos) | |
endfun | |
fun! s:u_cleanup(m, in) | |
return a:m | |
endfun | |
fun! s:t_cleanup(model) | |
let &cpo = s:cpo | |
hide pclose | |
call clearmatches() | |
call timer_stopall() | |
stopinsert | |
" set stl< | |
endfun | |
fun! s:u_renderinfo(m, in) | |
let a:m.uiinfo = '[' . a:m.modesym . '] ' . | |
\ a:m.info . ': ' . a:m.nfilt | |
return a:m | |
endfun | |
fun! s:t_renderinfo(m) | |
call setline(s:infolnum, a:m.uiinfo) | |
endfun | |
fun! s:u_renderitems(m, in) | |
let matches = a:m.filt[0:99] | |
if a:m.nfilt > 100 | |
call add(matches, '...') | |
endif | |
let a:m.matches = matches | |
return a:m | |
endfun | |
fun! s:t_renderitems(m) | |
silent! exe s:item1lnum . ',$d' | |
" Everything matched; don't bother displaying results. | |
if a:m.nfilt == a:m.nitems || a:m.nfilt == 0 | |
return | |
endif | |
if line('$') == 1 | |
put='' | |
endif | |
" append causes redraw problems | |
put=a:m.matches | |
call clearmatches() | |
call s:msg('highlightregexp', 0) | |
if s:canhighlightsingleitem | |
call matchadd('search', '\%' . s:item1lnum . 'l.*\%$', -1) | |
endif | |
call s:restorecursor(a:m) | |
endfun | |
fun! s:u_renderinput(m, in) | |
return a:m | |
endfun | |
fun! s:t_renderinput(m) | |
if line('$') == 1 | |
norm!o | |
endif | |
sign unplace 2 | |
exe 'sign place 2 line=' . s:inputlnum . | |
\ ' name=find buffer=' . a:m.bufnr | |
call setline(s:inputlnum, a:m.input) | |
call s:restorecursor(a:m) | |
endfun | |
fun! s:u_renderui(m, in) | |
return a:m | |
endfun | |
fun! s:t_renderui(m) | |
call s:msg('renderinfo', 0) | |
call s:msg('renderinput', 0) | |
call s:msg('renderitems', 0) | |
call s:msg('showpreview', 0) | |
echo '' | |
endfun | |
fun! s:u_reset(m, in) | |
let a:m.showpreview = 0 | |
let a:m.input = '' | |
let a:m.filt = a:m.items | |
let a:m.nfilt = a:m.nitems | |
let a:m.pos = [0, 2, 1, 0, 1] | |
return a:m | |
endfun | |
fun! s:t_reset(m) | |
call s:msg('renderui', 0) | |
endfun | |
fun! s:u_initwin(m, in) | |
let a:m.bufnr = bufnr('%') | |
let a:m.inittick = a:in.inittick | |
let a:m.dir = a:in.dir | |
let a:m.info = '#' . a:in.count . ' ' . a:in.dir | |
let a:m.items = a:in.items | |
let a:m.nitems = len(a:in.items) | |
let a:m.filt = a:m.items | |
let a:m.nfilt = a:m.nitems | |
let a:m.count = a:in.count | |
let a:m.modeidx = index(s:modes, s:defmode) | |
let a:m.mode = s:modes[a:m.modeidx] | |
let a:m.prevmode = '' | |
let a:m.modesym = s:modesym[a:m.modeidx] | |
let a:m.pos = [0, 2, 1, 0, 1] | |
let a:m.timer = {} | |
let a:m.line = 2 | |
let a:m.uiinput = '' | |
return a:m | |
endfun | |
fun! s:t_initwin(data) | |
sign define find linehl=specialkey texthl=specialkey text=> | |
let &l:stl = '> ' . a:data.info . '%= %l,%v %P%<' | |
setl noswf bt=nofile nobl cul noudf scl=yes bh=wipe | |
set cpo-=v cpo-=$ | |
au! bufenter <buffer> set cpo-=$ cpo-=v | |
au! bufwinleave <buffer> call s:t_cleanup({}) | |
au! textchanged <buffer> call s:msg('textchanged', 0) | |
au! textchangedi <buffer> call s:msg('textchanged', 0) | |
au! cursormoved <buffer> silent call s:msg('cursormoved', 0) | |
au! cursormovedi <buffer> silent call s:msg('cursormoved', 0) | |
ino <buffer> <silent> <CR> <C-R>=<SID>cr()<CR> | |
nno <buffer> <silent> <CR> :<C-U>call <SID>cr()<CR> | |
ino <buffer> <silent> <BS> <C-R>=<SID>bs()<CR> | |
nno <buffer> <silent> <BS> :<C-U>call <SID>bs()<CR> | |
call s:msg('reset', 0) | |
endfun | |
fun! s:u_showpreview(m, in) | |
if s:q_oninputline() && !s:q_isoneitem(a:m) || s:q_oninfoline() | |
let a:m.showpreview = 0 | |
return a:m | |
endif | |
let a:m.hasroomforpreview = a:m.showpreview || | |
\ winheight(0) > (&previewheight + 1 + 3) | |
let a:m.previewfile = s:q_isoneitem(a:m) | |
\ ? a:m.filt[0] | |
\ : getline('.') | |
if a:m.previewfile[0:1] == '//' || | |
\ !filereadable(a:m.previewfile) | |
let a:m.previewfile = s:unavailprv | |
endif | |
let a:m.showpreview = 1 | |
return a:m | |
endfun | |
fun! s:t_showpreview(m) | |
if !has('quickfix') | |
return | |
endif | |
if !a:m.showpreview | |
hide pclose | |
return | |
endif | |
let view = winsaveview() | |
silent! exe 'pedit ' . fnameescape(a:m.previewfile) | |
call winrestview(view) | |
endfun | |
fun! s:u_highlightregexp(m, data) | |
let a:m.highlightregexp = a:m.mode == 'regexp' | |
return a:m | |
endfun | |
fun! s:t_highlightregexp(data) | |
if s:canhighlightregexp | |
if a:data.highlightregexp | |
call matchadd('search', a:data.input, -1) | |
endif | |
endif | |
endfun | |
fun! s:u_filtered(m, filt) | |
let a:m.filt = a:filt | |
let a:m.nfilt = len(a:filt) | |
return a:m | |
endfun | |
fun! s:t_filtered(m) | |
call s:msg('renderui', 0) | |
endfun | |
fun! s:bs() | |
if s:q_oninputline() && mode() == 'n' || | |
\ s:q_oninputline() && mode() == 'i' && b:find.uiinput == '' | |
call s:msg('cyclemode', 0) | |
return '' | |
else | |
if mode() == 'i' | |
return "\<BS>" | |
else | |
exe "norm!\<BS>" | |
endif | |
endif | |
endfun | |
fun! s:u_cyclemode(m, in) | |
let a:m.modeidx = (a:m.modeidx + 1) % len(s:modes) | |
let a:m.prevmode = a:m.mode | |
let a:m.mode = s:modes[a:m.modeidx] | |
let a:m.modesym = s:modesym[a:m.modeidx] | |
let a:m.filt = a:m.items | |
call s:msg('changeinput', a:m.uiinput) | |
return a:m | |
endfun | |
fun! s:t_cyclemode(data) | |
endfun | |
fun! s:filterord(input, list) | |
let input = substitute(a:input, '^\s\+\|\s\+$', '', 'g') | |
if input == '' | |
return a:list | |
endif | |
let terms = '\%#=2\V' . | |
\ substitute(escape(input, '\'), '\s\+', '\\.\\*', 'g') | |
return input =~# '\u' | |
\ ? filter(copy(a:list), {i, item -> item =~# terms}) | |
\ : filter(copy(a:list), {i, item -> item =~? terms}) | |
endfun | |
fun! s:filterunord(input, list) | |
let input = substitute(a:input, '^\s\+\|\s\+$', '', 'g') | |
if input == '' | |
return a:list | |
endif | |
let terms = split(input, ' ') | |
call filter(terms, {i, term -> term != '!'}) | |
if empty(terms) | |
return a:list | |
endif | |
call map(terms, {i, term -> escape(term, '\')}) | |
let exact = input =~# '\u' | |
let list = terms[0][0] == '!' | |
\ ? exact | |
\ ? filter(copy(a:list), {i, item -> item !~# '\V' . | |
\ terms[0][1:]}) | |
\ : filter(copy(a:list), {i, item -> item !~? '\V' . | |
\ terms[0][1:]}) | |
\ : exact | |
\ ? filter(copy(a:list), {i, item -> item =~# '\V' . terms[0]}) | |
\ : filter(copy(a:list), {i, item -> item =~? '\V' . terms[0]}) | |
if len(terms) > 1 | |
let filt = [] | |
for item in list | |
for term in terms[1:] | |
let match = term[0] == '!' | |
\ ? exact | |
\ ? item !~# '\V' . term[1:] | |
\ : item !~? '\V' . term[1:] | |
\ : exact | |
\ ? item =~# '\V' . term | |
\ : item =~? '\V' . term | |
if !match | |
break | |
endif | |
endfor | |
if match | |
call add(filt, item) | |
endif | |
endfor | |
else | |
let filt = list | |
endif | |
return filt | |
endfun | |
fun! s:u_changeinput(m, input) | |
let a:m.shouldchangeinput = 0 | |
if a:m.input == a:input && a:m.mode == a:m.prevmode | |
return a:m | |
endif | |
let a:m.shouldchangeinput = 1 | |
let a:m.previnput = a:m.input | |
let a:m.input = getline(s:inputlnum) | |
return a:m | |
endfun | |
fun! s:srchregexp(data) | |
let input = a:data.input | |
let filt = copy(a:data.items) | |
if input =~# '\u' | |
call filter(filt, {i, item -> item =~# input}) | |
else | |
call filter(filt, {i, item -> item =~? input}) | |
endif | |
return filt | |
endfun | |
fun! s:srchbasic(data) | |
let previnput = a:data.previnput | |
let curinput = a:data.input | |
let items = previnput != '' && len(s:filterord(previnput, [curinput])) | |
\ ? a:data.filt | |
\ : a:data.items | |
return s:filterord(curinput, items) | |
endfun | |
fun! s:srchadv(data) | |
" If the search type hasn't changed, and input is a subset of | |
" b:find.input, filter the previous filtered list, rather than | |
" starting from scratch. | |
" XXX: not good enough now that ! terms can result in widening | |
" the search. We need to keep track of the b:find.filt list for | |
" the previous set of terms and use that as the basis of the | |
" filtering operation when appending to a ! term. | |
let previnput = a:data.previnput | |
let curinput = a:data.input | |
let items = previnput != '' && len(s:filterord(previnput, [curinput])) | |
\ ? a:data.filt | |
\ : a:data.items | |
return s:filterunord(curinput, items) | |
endfun | |
fun! s:t_changeinput(m) | |
if !a:m.shouldchangeinput | |
return | |
endif | |
let input = a:m.input | |
let mode = a:m.mode | |
if input == '' | |
let filt = a:m.items | |
else | |
echo 'Searching ...' | |
if mode == 'regexp' | |
let filt = s:srchregexp(a:m) | |
elseif mode == 'advanced' | |
let filt = s:srchadv(a:m) | |
elseif mode == 'basic' | |
let filt = s:srchbasic(a:m) | |
endif | |
endif | |
call s:msg('filtered', filt) | |
endfun | |
fun! s:cr() | |
if mode() == 'i' && s:q_onitems() | |
return "\<CR>" | |
endif | |
call s:msg('pressedcr', 0) | |
return '' | |
endfun | |
fun! s:u_pressedcr(m, in) | |
let a:m.shouldopenfile = 0 | |
if s:q_oninputline() && | |
\ a:m.mode == 'regexp' && | |
\ a:m.input != a:m.uiinput | |
call s:msg('changeinput', a:m.uiinput) | |
elseif s:q_oninputline() && s:q_isoneitem(a:m) || s:q_onitems() | |
let a:m.shouldopenfile = 1 | |
stopinsert | |
let a:m.filetoopen = s:q_oninputline() | |
\ ? getline('$') | |
\ : getline('.') | |
endif | |
return a:m | |
endfun | |
fun! s:openfile(file) | |
if !isdirectory(a:file) && !filereadable(a:file) | |
call s:err('Can''t read file: ' . a:file) | |
return | |
endif | |
call s:t_cleanup({}) | |
let &ei = s:ei | |
if bufexists(a:file) | |
" Don't mess about if the buffer is already open. You could be | |
" on a slow network. | |
exe 'b ' . fnameescape(a:file) | |
else | |
exe 'e ' . fnameescape(a:file) | |
endif | |
doau bufenter,bufread | |
stopinsert | |
endfun | |
fun! s:t_pressedcr(data) | |
if a:data.shouldopenfile | |
call s:openfile(a:data.filetoopen) | |
return | |
endif | |
endfun | |
fun! s:u_back(m, in) | |
let a:m.pos = a:m.prevpos | |
return a:m | |
endfun | |
fun! s:t_back(m) | |
call s:msg('renderui', 0) | |
endfun | |
fun! s:u_textchanged(m, in) | |
let a:m.changedtick = b:changedtick | |
let a:m.uiinput = getline(s:inputlnum) | |
let a:m.uiitem1 = getline(s:item1lnum) | |
let a:m.oninputline = s:q_oninputline() | |
let a:m.oninfoline = s:q_oninfoline() | |
return a:m | |
endfun | |
fun! s:t_textchanged(m) | |
" No change since we started, but the autocmd can fire anyway during | |
" initialisation. | |
if a:m.changedtick == a:m.inittick | |
return | |
endif | |
let trashedbuf = line('$') == 1 | |
let trashedinfo = a:m.oninfoline && a:m.uiinfo != getline(s:infolnum) | |
let trashedinput = a:m.oninputline && | |
\ a:m.nfilt < a:m.nitems && | |
\ get(a:m.filt, 0, '') != a:m.uiitem1 | |
if trashedbuf || trashedinfo | |
call s:msg('back', 0) | |
return | |
endif | |
if !a:m.oninputline | |
return | |
endif | |
" Regexp: wait for CR | |
if a:m.mode == 'regexp' && a:m.uiinput != '' | |
return | |
endif | |
call s:msg('changeinput', a:m.uiinput) | |
endfun | |
fun! s:u_cursormoved(m, in) | |
let a:m.prevline = a:m.line | |
let a:m.line = line('.') | |
let a:m.prevpos = a:m.pos | |
let a:m.pos = getcurpos() | |
return a:m | |
endfun | |
fun! s:t_cursormoved(m) | |
if a:m.line != a:m.prevline | |
call s:msg('showpreview', 0) | |
endif | |
endfun | |
fun! s:find(count, bang, mods, dir) | |
let s:logitems = {} | |
let mod = &mod && len(getbufinfo('%')[0].windows) == 1 | |
if mod && !&hidden && a:mods !~# '\<hide\>' | |
call s:err('Buffer modified. :w the buffer, :set hidden, ' . | |
\ 'or use :hide Find') | |
return | |
endif | |
exe a:mods . ' enew' | |
let b:find = {} | |
let cache = s:cacheprefix . a:count | |
if a:dir != '' | |
let append = a:bang == '' | |
let dir = fnamemodify(a:dir, ':p') | |
let out = systemlist('find ' . dir) | |
silent! let cached = append ? readfile(cache) : [] | |
if !empty(cached) | |
let dir .= ', ' . cached[0] | |
let cached = cached[1:] | |
endif | |
let lines = [dir] + cached + out | |
call writefile(lines, cache) | |
endif | |
let cached = a:dir == '' ? readfile(cache) : lines | |
let dir = cached[0] | |
let items = cached[1:] | |
" before any msgs are sent | |
startinsert | |
call s:msg('initwin', { | |
\ 'count': a:count, | |
\ 'dir': dir, | |
\ 'items': items, | |
\ 'inittick': b:changedtick | |
\ }) | |
endfun | |
fun! s:init() | |
com! -range=0 -bang -nargs=? Find | |
\ call s:find(<count>, <q-bang>, <q-mods>, <q-args>) | |
endfun | |
call s:init() | |
let &cpo = s:cpo | |
finish | |
REQUIREMENTS | |
Vim 8. | |
find(1) for generating file and directory lists. | |
INVOCATION | |
:[N] Find [!] [DIR] | |
N Optional cache number. Default: 0. | |
:[N] Find Reads the initial list of files from ~/findcacheN, | |
and makes the current window a Find File window. | |
Insert mode is started in the Search Input Line. | |
:[N] Find! Same as :Find. | |
:[N] Find DIR Uses find(1) to find files and directories | |
starting at DIR. The list is appended to | |
~/findcacheN. Otherwise the same as for :Find. | |
:[N] Find! DIR Uses find(1) to find files and directories | |
starting at DIR. ~/findcacheN is overwritten | |
with this list. Otherwise the same as :Find. | |
:Find may be used with :hide | |
:Find may not be followed with '|'. | |
FIND FILE WINDOW | |
The local status line is '> Find file (N)', where N is the cached | |
list in use. | |
The first line is the Search Input Line. It has a '>' marker in the | |
sign column. | |
The remaining lines display a maximum of a hundred found files. | |
If more files are found, '...' is displayed in the last line, along | |
with a total count. | |
<CR> in a Find File window act as follows: | |
Search Input Line, Insert mode: | |
If there is more than one file found, leave Insert mode. | |
If there is one file found, open it. | |
Search Input Line, Normal mode: | |
If there is more than one file found, do nothing. | |
If there is one file found, open it. | |
Other lines, Insert mode: | |
<CR> | |
Other lines, Normal mode: | |
Open the file. | |
Preview | |
If there is a single found file, the preview window displays its | |
contents. If there is more than one file found, move the cursor | |
over a filename to display it in the preview window. If a file | |
is unreadable the preview window is empty. | |
The preview window will be closed when leaving the Find File | |
window. | |
Typical usage | |
To open a file, with a mapping for :Find: | |
\o | |
start typing stuff | |
hit <CR> when there is one match left | |
SEARCHING | |
Three search modes are available: regular expressions, ordered | |
search terms, and unordered search terms. They all work as if | |
'ignorecase' and 'smartcase' are set. | |
1. Regular expressions | |
Requires '/' as the first character of the search. | |
Press <CR> to perform the search. | |
2. Ordered search terms | |
This is the default. They are separated by spaces. They are | |
searched for literally, and must appear in matching file paths | |
in the same order as typed. E.g. | |
> foo bar baz | |
will match '/foofoo/aaa/barbar/bbb/bazbaz', but not | |
'/foofoo/aaa/bazbaz/bbb/barbar' | |
Search results update as you type. | |
3. Unordered search terms | |
Requires '?' as the first character of the search. Like (2) they | |
are separated by spaces, but may appear matching file paths in | |
any order. E.g. | |
> ?baz foo bar | |
will match '/foofoo/aaa/barbar/bbb/bazbaz'. | |
Search terms can be negated by prefixing with '!'. E.g. | |
> ?bar !foo | |
will match '/bar/baz', | |
but not '/foo/bar/baz'. | |
Search results update as you type. | |
TASKS | |
[*] The filtering optimisation for unordered terms is no longer | |
correct since adding negation. See the code. It will still work | |
for single character negations. | |
[.] Docmentation. | |
[.] A ~73k line file list, albeit larger than usual, will cause | |
~32MB memory usage (Windows)! Not a problem for me, but this | |
seems excessive. The lists don't seem to get garbage-collected. | |
This becomes a problem if you have multiple Find File windows | |
open. This isn't normal, but is still a potential issue. | |
[ ] Add required features to REQUIREMENTS. | |
[ ] Exclude certain patterns from the search results? E.g. undo | |
files and other junk. (Change the find(1) command.) | |
[ ] Unordered search allows overlapping terms. Maybe this is | |
a feature. | |
[ ] Restore the preview window if it was already open. It's ot an | |
issue for me personally, but might be nice. It could take some | |
effort though. | |
[ ] Is there a better way to display an empty preview window than | |
this? | |
exe 'pedit ' . s:unavailprv | |
[ ] is there a way to detect an active VPN connection? | |
[ ] Undo on input line should invoke s:textchanged | |
[ ] Autoload. Don't personally care, but probably ought to. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
An out-of-date experiment. Unused for ages (I implemented a much faster fuzzy finder that didn't need asynchronicity, used input(), used :echo to display the UI, and provided a much better UX -- and then I stopped using that).
This is interesting only because it implements a simple aysynchronous 'reactive' model. Also because it uses a buffer as the UI.
q_ functions query the model. u_ functions update the model. t_ functions are tasks that run asynchronously after the model has been updated.
Handling input asynchronously improves perceived response time and reduces the number of filter operations.
Incidentally you can improve on this immensely -- :read [!] a list of files into a buffer, then :g/RE/d or :v/RE/d to filter them.
gf to open a file. This is extremely simple, convenient with a few well-chosen mappings, and is a general technique which can be applied to various situations. Other benefits are saving/editing/reopening filtered buffers, searching them with /, and being able to undo filter operations.