yuki-yano/fzf-preview.vimみたいなことをしたかった
プラグインを入れずvimスクリプトのみでやったりました。
いきなりですがレポとソースはこちら(長いので折りたたみ)
vimrcで呼べる汎用fzf関数
" ===================================================================
" junegunn/fzf.vim
" ===================================================================
" {{{
" usage
" let arguments_def = #{
" \ title: popup title as String,
" \ list: search targets as List,
" \ type: format in list. f, lm, flm (file, line, msg),
" \ eprfx: enter zone prefix text as String (0 to default value),
" \ }
" and call like this.
" cal s:fzsearch.popup(arguments_def)
let s:fzsearch = #{bwid: 0, ewid: 0, rwid: 0, pwid: 0, tid: 0, res: [],
\ ffdict: #{vimrc: 'vim', zshrc: 'sh', js: 'javascript', py: 'python', md: 'markdown',
\ ts: 'typescript', tsx: 'typescriptreact',
\ },
\ }
let s:fzsearch.allow_exts = glob($VIMRUNTIME.'/ftplugin/*.vim')->split('\n')
\ ->map({_,v->matchstr(v,'[^/]\+\.vim')->split('\.')[0]})
fu! s:fzsearch.popup(v) abort
if empty(a:v.list) || (len(a:v.list) == 1 && empty(a:v.list[0]))
cal s:echoE('no data')
retu
endif
let self.list = a:v.list
let self.type = a:v.type
" TODO depends on type
let self.prr = a:v.eprfx
let self.wd = ''
let self.wda = []
let self.ridx = 0
let self.max = 30
let self.mode = 'Fuzzy'
let self.pr = '['.self.mode.']'.(self.prr ?? '>>')
let self.res = a:v.list[0:self.max]
let self.bwid = popup_create([], #{title: ' '.a:v.title.' ',
\ zindex: 50, mapping: 0, scrollbar: 0,
\ border: [], borderchars: ['─','│','─','│','╭','╮','╯','╰'], borderhighlight: ['FzBWin'],
\ minwidth: &columns*9/12, maxwidth: &columns*9/12,
\ minheight: &lines/2+6, maxheight: &lines/2+6,
\ line: &lines/4-2, col: &columns/8+1,
\ })
let self.ewid = popup_create(self.pr, #{title: ' Search Mode: <Tab> | ClearText: <C-w> ',
\ zindex: 100, mapping: 0,
\ border: [], borderchars: ['─','│','─','│','╭','╮','╯','╰'], borderhighlight: ['FzEWin'],
\ minwidth: &columns/3, maxwidth: &columns/3,
\ minheight: 1, maxheight: 1,
\ line: &lines*3/4+2, col: &columns/7+1,
\ filter: function(self.fil, [#{wd: []}]),
\ })
cal matchaddpos('FzEWin', [[1, 1, len(self.pr)]], 16, -1, #{window: self.ewid})
cal matchadd('DarkRed', '\[.*\]', 17, -1, #{window: self.ewid})
let self.rwid = popup_menu(self.res, #{title: ' Choose: <C-n/p> <CR> | QuickFix: <C-q> ',
\ zindex: 99, mapping: 0, scrollbar: 1,
\ border: [], borderchars: ['─','│','─','│','╭','╮','╯','╰'], borderhighlight: ['FzWin'],
\ minwidth: &columns/3, maxwidth: &columns/3,
\ minheight: &lines/2, maxheight: &lines/2,
\ pos: 'topleft', line: &lines/4, col: &columns/7,
\ callback: 'Fzsearch_confirm',
\ filter: function(self.jk, [0]),
\ })
" result list syntax
if self.type == 'lm'
cal setbufvar(winbufnr(self.rwid), '&filetype', &filetype)
elseif self.type == 'flm'
cal setbufvar(winbufnr(self.rwid), '&filetype', 'txt')
endif
" get preview file
let path = bufname('%')
if self.type =~ 'flm'
let path = split(self.res[0], '|')[0]
elseif self.type =~ 'f'
let path = substitute(self.res[0], '\~', $HOME, 'g')
endif
let read = ['Cannot open file.', 'please check this file path.', path]
try
let read = readfile(path)
catch
endtry
" preview
let self.pwid = popup_menu(read, #{title: ' File Preview | Scroll: <C-d/u> ',
\ zindex: 98, mapping: 0, scrollbar: 1,
\ border: [], borderchars: ['─','│','─','│','╭','╮','╯','╰'], borderhighlight: ['FzWin'],
\ minwidth: &columns/3, maxwidth: &columns/3,
\ minheight: &lines/2+3, maxheight: &lines/2+3,
\ pos: 'topleft', line: &lines/4, col: &columns/2+1,
\ firstline: 1,
\ filter: function(self.pv, [0]),
\ })
" get preview ext for syntax highlight
let ext = matchstr(path, '[^\.]\+$')
let ext = ext !~ '^[a-zA-Z0-9]\+$' ? '' : ext
let ext = get(self.ffdict, ext, ext)
cal setbufvar(winbufnr(self.pwid), '&filetype', self.type =~ 'f' ? ext : &filetype)
" first line
let lnm = 1
if self.type == 'flm'
let sep = split(self.res[0], '|')
let lnm = len(sep) < 2 ? 1 : split(sep[1], ' ')[0]
elseif self.type == 'lm'
let lnm = split(self.res[0], ':')[0]
endif
" cursor line top from 10row
cal popup_setoptions(self.pwid, #{firstline: (lnm-10 > 0 ? lnm-10 : 1)})
cal win_execute(self.pwid, 'exe '.lnm)
endf
" on confirm
fu! Fzsearch_confirm(wid, idx) abort
if a:idx == -1
cal s:echoE('cancel')
cal s:fzsearch.finalize()
retu
endif
let result = s:fzsearch.res[a:idx-1]
if s:fzsearch.type == 'f'
exe 'e '.result
elseif s:fzsearch.type == 'lm'
let l = split(result, ':')[0]
cal s:echoI('jump to '.l)
exe l
if s:fzsearch.mode == 'Simply'
let @/ = s:fzsearch.wd
norm! n
cal s:quickhl.set()
endif
elseif s:fzsearch.type == 'flm'
let sep = split(result, '|')
let fnm = substitute(sep[0], $HOME, '~', 'g')
let lnm = len(sep) < 2 ? 1 : split(sep[1], ' ')[0]
" if quickfix window
if expand('%')->empty()
wincmd w
endif
exe 'e '.fnm
exe lnm
cal s:echoI('jump to '.fnm.' line:'.lnm)
endif
cal s:fzsearch.finalize()
endf
" create quickfix
fu! s:fzsearch.quickfix() abort
let tmp = &errorformat
let resource = self.res
let ef = '%f: %l: %m'
if self.type == 'lm'
let fnm = expand('%')
let resource = deepcopy(self.res)->map({_,v->fnm.': '.v})
elseif self.type == 'f'
let resource = deepcopy(self.res)->map({_,v->v.': 1: open'})
elseif self.type == 'flm'
" sample
" filename|100 col 10-15|message
" want-> filename, 100, message
let resource = deepcopy(self.res)->map({_,v
\ ->split(v,'|')[0].': '
\ .split(split(v,'|')[1], ' ')[0].': '
\ .join(split(v,'|')[2:], '')})
endif
let &errorformat = ef
cgetexpr resource | cw
let &errorformat = tmp
cal s:fzsearch.finalize()
endf
" finalize
fu! s:fzsearch.finalize() abort
let s:fzsearch.list = []
let s:fzsearch.res = []
cal s:runcat.stop()
cal popup_clear()
endf
" preview scroll
fu! s:fzsearch.scroll(wid, vector) abort
if self.tid
retu
endif
cal timer_stop(self.tid)
let vec = a:vector ? "\<C-n>" : "\<C-p>"
let self.tid = timer_start(10, { -> popup_filter_menu(a:wid, vec) }, #{repeat: -1})
let delay = 600
cal timer_start(delay, self.scstop)
endf
fu! s:fzsearch.scstop(_) abort
cal timer_stop(self.tid)
let self.tid = 0
endf
" preview draw update
fu! s:fzsearch.pvupd() abort
let win = winbufnr(self.pwid)
if empty(self.res) && self.type == 'lm'
retu
endif
if self.type == 'lm'
let lnm = split(self.res[self.ridx], ':')[0]
cal popup_setoptions(self.pwid, #{firstline: (lnm-10 > 0 ? lnm-10 : 1)})
cal win_execute(self.pwid, 'exe '.lnm)
retu
endif
" del
sil! cal deletebufline(win, 1, getbufinfo(win)[0].linecount)
" put
if empty(self.res)
retu
endif
let path = self.type == 'flm'
\ ? split(self.res[self.ridx], '|')[0]
\ : substitute(self.res[self.ridx], '\~', $HOME, 'g')
let read = ['Cannot open file.', 'please check this file path.', path]
try
let read = readfile(path)
catch
endtry
cal setbufline(win, 1, read)
" syntax
let ext = matchstr(path, '[^\.]\+$')
let ext = ext !~ '^[a-zA-Z0-9]\+$' ? '' : ext
let ext = get(self.ffdict, ext, ext)
cal setbufvar(win, '&filetype', ext)
" line
let sep = split(self.res[self.ridx], '|')
let lnm = len(sep) < 2 ? 1 : split(sep[1], ' ')[0]
cal popup_setoptions(self.pwid, #{firstline: (lnm-10 > 0 ? lnm-10 : 1)})
cal win_execute(self.pwid, 'exe '.lnm)
endf
" search result update
fu! s:fzsearch.list_upd() abort
" upd match result list
let filterd = empty(self.wd) ? self.list :
\ self.mode == 'Fuzzy' ? matchfuzzy(self.list, self.wd)
\ : copy(self.list)->filter({_,v->match(v, self.wd)!=-1})
""let filterd = copy(self.list)->filter({_,v->match(v, self.wd)!=-1})
let self.res = filterd[0:self.max]
let self.ridx = len(self.res)-1 < self.ridx ? len(self.res)-1 : self.ridx
cal setbufline(winbufnr(self.ewid), 1, self.pr . self.wd)
cal deletebufline(winbufnr(self.rwid), 1, self.max)
echo ''
cal setbufline(winbufnr(self.rwid), 1, self.res)
" highlight match char
cal clearmatches(self.rwid)
if !empty(self.wd)
cal matchadd('FzMatch', self.mode == 'Fuzzy'
\ ? printf('\c[%s]', escape(self.wd, '\[\]\-\.\*'))
\ : '\c'.self.wd,
\ 16, -1, #{window: self.rwid})
endif
" upd preview
cal self.pvupd()
endf
fu! s:fzsearch.togglemode() abort
let self.mode = self.mode == 'Fuzzy' ? 'Simply' : 'Fuzzy'
let self.pr = '['.self.mode.']'.(self.prr ?? '>>')
cal clearmatches(self.ewid)
cal matchaddpos('FzEWin', [[1, 1, len(self.pr)]], 16, -1, #{window: self.ewid})
cal matchadd('DarkRed', '\[.*\]', 17, -1, #{window: self.ewid})
cal self.list_upd()
endf
" enter search word
fu! s:fzsearch.fil(ctx, wid, key) abort
if a:key is# "\<Esc>"
cal popup_close(self.bwid)
cal popup_close(self.pwid)
cal popup_close(self.ewid)
cal feedkeys("\<C-c>")
retu 1
elseif a:key is# "\<C-n>" || a:key is# "\<C-p>" || a:key is# "\<CR>"
" only largest zindex window is active.
cal popup_setoptions(self.rwid, #{zindex: 100})
cal popup_setoptions(self.ewid, #{zindex: 99})
cal popup_setoptions(self.pwid, #{zindex: 98})
cal feedkeys(a:key)
retu 1
elseif a:key is# "\<C-d>" || a:key is# "\<C-u>"
cal popup_setoptions(self.pwid, #{zindex: 100})
cal popup_setoptions(self.ewid, #{zindex: 99})
cal popup_setoptions(self.rwid, #{zindex: 98})
cal feedkeys(a:key)
retu 1
elseif a:key is# "\<Tab>"
cal self.togglemode()
retu 1
elseif a:key is# "\<C-q>"
cal self.quickfix()
cal feedkeys("\<Esc>")
retu 1
" TODO fzf.vim copy shold D-v only or Shift Insert. C-v want split
" TODO fzf.vim add feature: refresh cache (or re search)
elseif a:key is# "\<C-v>" || a:key is# "\<D-v>"
for i in range(0, strlen(@+)-1)
cal add(self.wda, strpart(@+, i, 1))
endfor
" TODO see self
elseif a:key is# "\<BS>" && !empty(self.wda)
unlet self.wda[len(self.wda)-1]
elseif a:key is# "\<BS>" && len(self.wda) == 0
" noop
retu 1
elseif a:key is# "\<C-w>"
let self.wda = []
elseif strtrans(a:key) == "<80><fd>`"
" noop (for polyglot bug adhoc)
retu 1
else
cal add(self.wda, a:key)
endif
let self.wd = join(self.wda, '')
cal self.list_upd()
retu a:key is# "x" || a:key is# "\<Space>" ? 1 : popup_filter_menu(a:wid, a:key)
endf
" choose result
fu! s:fzsearch.jk(_, wid, key) abort
if a:key is# "\<Esc>"
cal popup_close(self.bwid)
cal popup_close(self.pwid)
cal popup_close(self.ewid)
cal feedkeys("\<C-c>")
elseif a:key is# "\<C-n>"
let self.ridx = self.ridx == len(self.res)-1 ? len(self.res)-1 : self.ridx+1
cal self.pvupd()
retu popup_filter_menu(a:wid, a:key)
elseif a:key is# "\<C-p>"
let self.ridx = self.ridx ? self.ridx-1 : 0
cal self.pvupd()
retu popup_filter_menu(a:wid, a:key)
elseif a:key is# "\<CR>"
cal popup_close(self.ewid)
cal popup_close(self.pwid)
cal popup_close(self.bwid)
retu popup_filter_menu(a:wid, empty(self.res) ? "\<C-c>" : a:key)
elseif a:key is# "\<C-d>" || a:key is# "\<C-u>"
cal popup_setoptions(self.pwid, #{zindex: 100})
cal popup_setoptions(self.ewid, #{zindex: 99})
cal popup_setoptions(self.rwid, #{zindex: 98})
cal feedkeys(a:key)
else
cal popup_setoptions(self.ewid, #{zindex: 100})
cal popup_setoptions(self.rwid, #{zindex: 99})
cal popup_setoptions(self.pwid, #{zindex: 98})
cal feedkeys(a:key)
endif
retu 1
endf
" scroll preview
fu! s:fzsearch.pv(_, wid, key) abort
if a:key is# "\<Esc>"
cal popup_close(self.bwid)
cal popup_close(self.pwid)
cal popup_close(self.ewid)
cal feedkeys("\<C-c>")
elseif a:key is# "\<C-d>"
cal self.scroll(a:wid, 1)
elseif a:key is# "\<C-u>"
cal self.scroll(a:wid, 0)
elseif a:key is# "\<C-n>" || a:key is# "\<C-p>" || a:key is# "\<CR>"
cal popup_setoptions(self.rwid, #{zindex: 100})
cal popup_setoptions(self.ewid, #{zindex: 99})
cal popup_setoptions(self.pwid, #{zindex: 98})
cal feedkeys(a:key)
else
cal popup_setoptions(self.ewid, #{zindex: 100})
cal popup_setoptions(self.rwid, #{zindex: 99})
cal popup_setoptions(self.pwid, #{zindex: 98})
cal feedkeys(a:key)
endif
retu 1
endf
" =====================
fu! s:fzf_histories()
cal s:fzsearch.popup(#{title: 'Histories', list: execute('ol')->split('\n')->map({_,v -> split(v, ': ')[1]}),
\ type: 'f', eprfx: '['.substitute(getcwd(), $HOME, '~', 'g').']>>'})
endf
fu! s:fzf_buffers()
cal s:fzsearch.popup(#{title: 'Buffers', list: execute('ls')->split('\n')->map({_,v -> split(v, '"')[1]})
\ ->filter({_,v -> v != '[No Name]' && v != '[無名]' && v !~ '!.*'}),
\ type: 'f', eprfx: '['.substitute(getcwd(), $HOME, '~', 'g').']>>'})
endf
" =====================
aug FzColor
au!
au ColorScheme * hi FzMatch cterm=BOLD cterm=underline ctermfg=196 ctermbg=237
au ColorScheme * hi FzWin ctermfg=114 ctermbg=237
au ColorScheme * hi FzBWin cterm=BOLD ctermfg=145 ctermbg=238
au ColorScheme * hi FzEWin ctermfg=39 ctermbg=237
aug END
noremap <silent><Plug>(fzf-histories) :<C-u>cal <SID>fzf_histories()<CR>
noremap <silent><Plug>(fzf-buffers) :<C-u>cal <SID>fzf_buffers()<CR>
" }}}
使い方
検索にはSimpleモードとFuzzyモードがあり
Simpleの場合は入力文字そのままを、Fuzzyの場合はあいまい検索します。
Gitのls-filesやヒストリー、バッファリスト、バッファ内文字列やQuickFix内など
とにかく文字列のリストを渡せばこのポップアップで検索、プレビューできます。
(batコマンドがなくとも色がつきます。vimのバッファをただ開いているだけだから。)
検索結果をQuickFixに渡し、取っておくことも可能。ポップアップの弱点を解消してます。
また、QuickFix内をポップアップで検索してQuickFixにもどしてt、無限ループもできます。
バッファ内、もしくはQuickFixのバッファ内を検索する
" Fuzzy search current file {{{
fu! s:currentSearch() abort
let buf = getline(1, line('$'))
" quickfix currentSearch
" empty buffer -> no data
if expand('%')->empty()
cal s:fzsearch.popup(#{title: 'QuickFix', list: buf, type: 'flm', eprfx: 0})
retu
endif
" buffer currentSearch
cal s:fzsearch.popup(#{title: 'Current', list: map(buf, {i,v->i+1.': '.v}), type: 'lm', eprfx: 0})
endf
noremap <silent><Plug>(current-search) :<C-u>cal <SID>currentSearch()<CR>
" }}}
ヒストリーを検索
fu! s:fzf_histories()
cal s:fzsearch.popup(#{title: 'Histories', list: execute('ol')->split('\n')->map({_,v -> split(v, ': ')[1]}),
\ type: 'f', eprfx: '['.substitute(getcwd(), $HOME, '~', 'g').']>>'})
endf
バッファを検索
fu! s:fzf_buffers()
cal s:fzsearch.popup(#{title: 'Buffers', list: execute('ls')->split('\n')->map({_,v -> split(v, '"')[1]})
\ ->filter({_,v -> v != '[No Name]' && v != '[無名]' && v !~ '!.*'}),
\ type: 'f', eprfx: '['.substitute(getcwd(), $HOME, '~', 'g').']>>'})
endf
ファイル、Git ls-filesを検索
これには工夫が必要。(ちょっとTODO残ってますが。。。)
gitのリポジトリフォルダである場合はgit ls-files
結果を渡せばいいですが
そうでない場合は、つまりfzfコマンドをやりたい場合は難しいです。
今回はどうしてもfindコマンドでやりたかったので(プラグイン不要だから)、
最下層までの検索をあきらめ、1階層ずつ非同期プロセスで検索し、結果を逐次渡してます。
gitプロジェクト以外で使わなきゃいいだけの話。
let s:fzf = #{cache: [], maxdepth: 4, gcache: [],
\ not_path_arr: [
\'"*/.**/*"',
\'"*node_modules/*"', '"*target/*"'
\'"*Applications/*"', '"*AppData/*"', '"*Library/*"',
\'"*Music/*"', '"*Pictures/*"', '"*Movies/*"', '"*Videos/*"'
\'"*OneDrive/*"',
\ ],
\}
" TODO fzf delete
fu! TestFzfMaxDepth(n) abort
let s:fzf.maxdepth = a:n
endf
" TODO fzf maxdepth かえるコマンド作るか
let s:fzf.postfix = ' -type f -not -path '.join(s:fzf.not_path_arr, ' -not -path ')
let s:fzf.searched = getcwd()
let s:fzf.get_gitls = {-> system('git ls-files -c')->split('\n')->filter({_,v->!empty(v)}) }
let s:fzf.get_file_d1 = {-> system('find . -mindepth 1 -maxdepth 1'.s:fzf.postfix)->split('\n')->filter({_,v->!empty(v)}) }
let s:fzf.get_file = { v -> 'find . -mindepth '.v.' -maxdepth '.v.s:fzf.postfix }
fu! s:fzf.files() abort
let pwd = getcwd()
let chk = system('git status')
let self.is_git = v:shell_error ? 0 : 1
" moved or first
if stridx(pwd, self.searched) == -1
"\ || (self.is_git && empty(self.gcache))
\ || (self.is_git)
\ || (!self.is_git && empty(self.cache))
let self.searched = pwd
if self.is_git
" TODO fzf no need cache?
let self.gcache = self.get_gitls()
cal s:echoI('cache: git ls-files -c')
else
let self.cache = self.get_file_d1()
cal self.asyncfind()
endif
endif
cal s:fzsearch.popup(#{title: self.is_git ? 'Project Files' : 'Files',
\ list: self.is_git ? self.gcache : self.cache,
\ type: 'f', eprfx: '['.substitute(pwd, $HOME, '~', 'g').']>>'})
endf
fu! s:fzf.asyncfind() abort
cal s:runcat.start()
let self.notwid = popup_notification('find files in ['.s:fzf.searched.'] and caching ...', #{zindex: 51, line: &lines, col: 5})
let self.jobcnt = self.maxdepth-1
let self.endjobcnt = 0
for depth in range(2, self.maxdepth)
cal job_start(self.get_file(depth), #{out_cb: self.asyncfind_start, close_cb: self.asyncfind_end})
endfor
endf
fu! s:fzf.asyncfind_start(ch, msg) abort
cal add(self.cache, a:msg)
endf
fu! s:fzf.asyncfind_end(ch) abort
let s:fzsearch.list = self.cache
cal s:fzsearch.list_upd()
let self.endjobcnt += 1
if self.endjobcnt == self.jobcnt
cal s:runcat.stop()
cal popup_close(self.notwid)
cal popup_notification('find files cached !', #{zindex: 51, line: &lines, col: 5})
endif
endf
fu! s:fzfexe() abort
cal s:fzf.files()
endf
noremap <silent><Plug>(fzf-smartfiles) :<C-u>cal <SID>fzfexe()<CR>
仕組み
- ポップアップを4枚つくる(1枚は下敷き)
- 入力文字を描画し、検索結果を絞り、ヒット文字を赤くする
- 入力キーに応じて操作ポップアップを変えるため、ポップアップのzindexを変える
(C-n/pで検索結果ウィンドウ、C-d/uでプレビューウィンドウ、その他全てで入力ウィンドウ) - 検索対象がファイルなのか、行に飛ぶ物なのか(マーク一覧、バッファ内)で分岐
- ファイル検索の場合、ファイル名の拡張子に応じてシンタックスハイライトをプレビューに
- 行検索の場合、検索結果とプレビュー両方にシンタックスハイライト
- 決定されたらジャンプ。これもファイルなのか行なのかでコマンドを変える
ざっくり説明ですがこんな感じです。
QuickFixに対応するため、検索結果リストは'%f: %l: %m'
のフォーマットで
ファイル名:行番号:テキスト
のリストとなるように統一しました。