dotfiles/.vim/tree.nvim/lua/tree.lua
2021-11-13 19:24:20 +01:00

808 lines
22 KiB
Lua
Executable File

-- vim: set sw=2 sts=4 et tw=78 foldmethod=indent:
-- :luafile %
local a = vim.api
local inspect = vim.inspect
local fn = vim.fn
local eval = vim.api.nvim_eval
local C = vim.api.nvim_command
local cmd = vim.api.nvim_command
local buf_is_loaded = vim.api.nvim_buf_is_loaded
local call = vim.api.nvim_call_function
local is_windows = fn.has('win32') == 1 or fn.has('win64') == 1
local is_macos = not is_windows and fn.has('win32unix') == 0 and fn.has('macunix') == 1
local is_linux = fn.has('unix') == 1 and fn.has('macunix') == 0 and fn.has('win32unix') == 0
local info = debug.getinfo(1, "S")
local sfile = info.source:sub(2) -- remove @
local project_root = fn.fnamemodify(sfile, ':h:h')
local custom = require('tree/custom')
-- https://gist.github.com/cwarden/1207556
function catch(what)
return what[1]
end
function try(what)
status, result = pcall(what[1])
if not status then
what[2](result)
end
return result
end
local M = {}
local default_etc_options = {
winheight=30,
winwidth=50,
split='no', -- {"vertical", "horizontal", "no", "tab", "floating"}
winrelative='editor',
buffer_name='default',
direction='',
search='',
new=false,
}
--- Resume tree window.
-- If the window corresponding to bufnrs is available, goto it;
-- otherwise, create a new window.
-- @param bufnrs table: trees bufnrs ordered by recently used.
-- @return nil.
function M.resume(bufnrs, cfg)
if bufnrs == nil then
return
end
if type(bufnrs) == 'number' then
bufnrs = {bufnrs}
end
-- check bufnrs
local deadbufs = {}
local treebufs = {}
for i, bufnr in pairs(bufnrs) do
loaded = buf_is_loaded(bufnr)
if loaded then
table.insert(treebufs, bufnr)
else
table.insert(deadbufs, bufnr)
end
end
-- print("treebufs:", vim.inspect(treebufs))
local find = false
-- TODO: send delete notify when -1.
for i, bufnr in pairs(treebufs) do
local winid = call('bufwinid', {bufnr})
if winid > 0 then
print('goto winid', winid)
call('win_gotoid', {winid})
find = true
return
end
end
local bufnr = treebufs[1]
local etc = M.etc_options[bufnr]
local resize_cmd, str
-- local no_split = false
-- if cfg.split == 'no' or cfg.split == 'tab' or cfg.split == 'floating' then
-- no_split = true
-- end
local vertical = ''
local command = 'sbuffer'
if etc.split == 'tab' then
cmd 'tabnew'
end
if etc.split == 'vertical' then
vertical = 'vertical'
resize_cmd = string.format('vertical resize %d', etc.winwidth)
elseif etc.split == 'horizontal' then
resize_cmd = string.format('resize %d', etc.winheight)
elseif etc.split == 'floating' then
local winid = a.nvim_open_win(bufnr, true, {
relative='editor',
anchor='NW',
row=0, -- etc.winrow
col=0, -- etc.wincol
width=etc.winwidth,
height=etc.winheight,
})
else
command = 'buffer'
end
if etc.split ~= 'floating' then
local direction = 'topleft'
if etc.direction == 'botright' then
direction = 'botright'
end
str = string.format("silent keepalt %s %s %s %d", direction, vertical, command, bufnr)
if not find then
cmd(str)
end
cmd(resize_cmd)
end
cmd "se nonu"
cmd "se nornu"
cmd "se nolist"
cmd "se signcolumn=no"
a.nvim_win_set_option(winid, 'wrap', false)
end
--- Drop file.
--- If the window corresponding to file is available, goto it;
--- otherwise, goto prev window and edit file.
--@param file string: file absolute path
--@return nil
function M.drop(args, file)
local arg = args[1] or 'edit'
local bufnr = call('bufnr', {file})
local winids = call('win_findbuf', {bufnr})
-- print(vim.inspect(winids))
if #winids == 1 then
call('win_gotoid', {winids[1]})
else
local prev_winnr = call('winnr', {'#'})
local prev_winid = call('win_getid', {prev_winnr})
call('win_gotoid', {prev_winid})
local str = string.format("%s %s", arg, file)
cmd(str)
end
end
--- Used to process files with the same name
-- def check_overwrite(view: View, dest: Path, src: Path) -> Path:
-- dest/src: {mtime=, path=, size=}
function M.pre_paste(pos, dest, src)
-- print(vim.inspect(dest))
local d_mtime = dest.mtime
local s_mtime = src.mtime
local slocaltime = os.date("%Y-%m-%d %H:%M:%S", s_mtime)
local dlocaltime = os.date("%Y-%m-%d %H:%M:%S", d_mtime)
-- time.strftime("%c", time.localtime(s_mtime))
local msg1 = string.format(' src: %s %d bytes\n', src.path, src.size)
local msg2 = string.format(' %s\n', slocaltime)
local msg3 = string.format('dest: %s %d bytes\n', dest.path, dest.size)
local msg4 = string.format(' %s\n', dlocaltime)
local msg = msg1..msg2..msg3..msg4
-- print_message(msg)
local msg = msg..string.format('%s already exists. Overwrite?', dest.path)
local choice = call('confirm', {msg, '&Force\n&No\n&Rename\n&Time\n&Underbar', 0})
local ret = ''
if choice == 0 then
return
elseif choice == 1 then
ret = dest.path
elseif choice == 2 then
ret = ''
elseif choice == 3 then
-- ('dir' if src.is_dir() else 'file')
local msg = string.format('%s -> ', src.path)
ret = call('input', {msg, dest.path, 'file'})
elseif choice == 4 and d_mtime < s_mtime then
ret = src.path
elseif choice == 5 then
ret = dest.path .. '_'
end
-- TODO: notify ret to server --
rpcrequest('function', {"paste", {pos, src.path, ret}}, true)
end
--- Confirm remove files.
--@param bufnr Number of tree buffer
--@param rmfiles List of remove files
--@return nil
function M.pre_remove(bufnr, rmfiles)
-- print(vim.inspect(info))
local cnt = #rmfiles
local msg = string.format('Are you sure to remove %d files?\n', cnt)
for _, f in ipairs(rmfiles) do
msg = msg .. f .. '\n'
end
local choice = call('confirm', {msg, '&Yes\n&No\n&Cancel', 0})
if choice == 1 then
rpcrequest('function', {"remove", {bufnr, choice}}, true)
end
end
function M.buf_attach(buf)
a.nvim_buf_attach(buf, false, { on_detach = function()
rpcrequest('function', {"on_detach", buf}, true)
M.alive_buf_cnt = M.alive_buf_cnt - 1
M.etc_options[buf] = nil
end })
end
-- [first, last]
function table.slice(tbl, first, last, step)
local sliced = {}
for i = first or 1, last or #tbl, step or 1 do
sliced[#sliced+1] = tbl[i]
end
return sliced
end
-------------------- start of util.vim --------------------
--- keymap is shared for all tree buffer
-- `:map <buffer>` to show keymap
keymap = ''
M.callback = {}
function M.keymap(lhs, ...)
-- TODO: call directly uses lua callback
local action_set = {
copy=true, call=true, cd=true, drop=true, debug=true, execute_system=true,
['goto']=true, multi=true, move=true, new_file=true, print=true, paste=true,
open_or_close_tree=true, open_tree_recursive=true, open=true, rename=true, redraw=true, remove=true,
toggle_select=true, toggle_ignored_files=true, toggle_select_all=true, view=true, yank_path=true
}
local action_list = {...}
local autocmd = [[augroup tree_keymap
autocmd!
autocmd FileType tree call Tree_set_keymap()
augroup END
func! Tree_set_keymap() abort
]]
local head = [[nnoremap <silent><buffer> ]]..lhs..' '
local str = ''
local expr = false
for i, action in ipairs(action_list) do
local op, args
if type(action) == 'table' then
op = action[1]
args = table.slice(action, 2)
else
op = action
args = {}
end
for i, arg in ipairs(args) do
if type(arg) == 'function' then
M.callback[lhs] = arg
expr = true
-- NOTE: When the parameter of action is function, it should be evaluated every time
-- print(string.format('arg: %s is function', vim.inspect(arg)))
end
end
-- print(i, vim.inspect(action))
if action_set[op] then
if op == 'call' then
str = str .. string.format([[:<C-U>lua tree.call(tree.callback["%s"])<CR>]], vim.fn.escape(lhs, '\\'))
else
if expr then
str = str .. string.format([[:<C-u>call v:lua.call_async_action(%s, luaeval('tree.callback["%s"]()'))<CR>]], fn.string(op), vim.fn.escape(lhs, '\\'))
else
str = str .. string.format([[:<C-u>call v:lua.call_async_action(%s, %s)<CR>]], fn.string(op), fn.string(args))
end
end
elseif vim.fn.exists(':'..op)==2 then
str = str .. ':'..op..'<CR>'
else
-- TODO: Support vim action parameters
str = str .. op
end
end
keymap = keymap .. head .. str .. "\n"
autocmd = autocmd .. keymap .. "\nendf"
vim.api.nvim_exec(autocmd, false)
end
function M.string(expr)
if type(expr)=='string' then
return expr
else
return vim.fn.string(expr)
end
end
function M.call_tree(command, args)
local paths, context = __parse_options(args)
try {
function()
call_async_action('redraw', {}) -- trigger exception when server dead
start(paths, context)
end,
catch {
function(error)
print('restart tree.nvim server')
M.channel_id = nil
start(paths, context)
end
}
}
end
--@param f function
function M.call(f)
local ctx = M.get_candidate()
-- a.nvim_call_function(f, {ctx})
f(ctx)
end
function M.print_error(s)
a.nvim_command(string.format("echohl Error | echomsg '[tree] %s' | echohl None", M.string(s)))
end
local function __re_unquoted_match(match)
-- Don't match a:match if it is located in-between unescaped single or double quotes
return match .. [[\v\ze([^"'\\]*(\\.|"([^"\\]*\\.)*[^"\\]*"|'([^'\\]*\\.)*[^'\\]*'))*[^"']*$]]
end
function M.convert2list(expr)
if vim.tbl_islist(expr) then
return expr
else
return {expr}
end
end
function __parse_options(cmdline)
local args = {}
local options = {}
local match = vim.fn.match
-- Eval
if match(cmdline, [[\\\@<!`.*\\\@<!`]]) ~= -1 then
cmdline = __eval_cmdline(cmdline)
end
for _, s in ipairs(vim.fn.split(cmdline, __re_unquoted_match([[\%(\\\@<!\s\)\+]]))) do
local arg = vim.fn.substitute(s, [[\\\( \)]], [[\1]], 'g')
local arg_key = vim.fn.substitute(arg, [[=\zs.*$]], '', '')
local name = vim.fn.substitute(vim.fn.tr(arg_key, '-', '_'), [[=$]], '', ''):sub(2)
local value
if match(name, '^no_') ~= -1 then
name = name:sub(4)
value = false
else
if match(arg_key, [[=$]]) ~= -1 then
value = __remove_quote_pairs(arg:sub(vim.fn.len(arg_key)+1))
else
value = true
end
end
local template_opts = user_options()
if vim.fn.index(vim.fn.keys(template_opts), name) >= 0 then
if type(template_opts[name]) == type(42) then
options[name] = vim.fn.str2nr(value)
else
options[name] = value
end
else
table.insert(args, arg)
end
end
return args, options
end
function __expand(path)
if path:find('^~') then
path = vim.fn.fnamemodify(path, ':p')
end
return __substitute_path_separator(path)
end
function __remove_quote_pairs(s)
-- remove leading/ending quote pairs
local t = s
if (t[1] == '"' and t[#t] == '"') or (t[1] == "'" and t[#t] == "'") then
t = t:sub(2, #t-1)
else
t = vim.fn.substitute(s, [[\\\(.\)]], "\\1", 'g')
end
return t
end
function __substitute_path_separator(path)
if is_windows then
return vim.fn.substitute(path, '\\', '/', 'g')
else
return path
end
end
function map_filter(func, t)
vim.validate{func={func,'c'},t={t,'t'}}
local rettab = {}
for k, v in pairs(t) do
if func(k, v) then
rettab[k] = v
end
end
return rettab
end
function __expand_complete(path)
if path:find('^~') then
path = vim.fn.fnamemodify(path, ':p')
elseif vim.fn.match(path, [[^\$\h\w*]]) ~= -1 then
path = vim.fn.substitute(path, [[^\$\h\w*]], [[\=eval(submatch(0))]], '')
end
return __substitute_path_separator(path)
end
function complete(arglead, cmdline, cursorpos)
local copy = vim.fn.copy
local _ = {}
if arglead:find('^-') then
-- Option names completion.
local bool_options = vim.tbl_keys(map_filter(
function(k, v) return type(v) == 'boolean' end, copy(user_options())))
local bt = vim.tbl_map(function(v) return '-' .. vim.fn.tr(v, '_', '-') end, copy(bool_options))
vim.list_extend(_, bt)
local string_options = vim.tbl_keys(map_filter(
function(k, v) return type(v) ~= type(true) end, copy(user_options())))
local st = vim.tbl_map(function(v) return '-' .. vim.fn.tr(v, '_', '-') .. '=' end, copy(string_options))
vim.list_extend(_, st)
-- Add "-no-" option names completion.
local nt = vim.tbl_map(function(v) return '-no-' .. vim.fn.tr(v, '_', '-') end, copy(bool_options))
vim.list_extend(_, nt)
else
local al = __expand_complete(arglead)
-- Path names completion.
local files = vim.tbl_filter(function(v) return vim.fn.stridx(v:lower(), al:lower()) == 0 end,
vim.tbl_map(__substitute_path_separator, vim.fn.glob(arglead .. '*', true, true)))
files = vim.tbl_map(
__expand_complete,
vim.tbl_filter(function(v) return vim.fn.isdirectory(v)==1 end, files))
if arglead:find('^~') then
local home_pattern = '^'.. __expand_complete('~')
files = vim.tbl_map(function(v) return vim.fn.substitute(v, home_pattern, '~/', '') end, files)
end
files = vim.tbl_map(function(v) return vim.fn.escape(v..'/', ' \\') end, files)
vim.list_extend(_, files)
end
return vim.fn.uniq(vim.fn.sort(vim.tbl_filter(function(v) return vim.fn.stridx(v, arglead) == 0 end, _)))
end
-- Test case
-- -columns=mark:git:indent:icon:filename:size:time -winwidth=40 -listed `expand('%:p:h')`
-- -buffer-name=\`foo\` -split=vertical -direction=topleft -winwidth=40 -listed `expand('%:p:h')`
function __eval_cmdline(cmdline)
local cl = ''
local prev_match = 0
local eval_pos = vim.fn.match(cmdline, [[\\\@<!`.\{-}\\\@<!`]])
while eval_pos >= 0 do
if eval_pos - prev_match > 0 then
cl = cl .. cmdline:sub(prev_match+1, eval_pos)
end
prev_match = vim.fn.matchend(cmdline, [[\\\@<!`.\{-}\\\@<!`]], eval_pos)
cl = cl .. vim.fn.escape(vim.fn.eval(cmdline:sub(eval_pos+2, prev_match-1)), [[\ ]])
eval_pos = vim.fn.match(cmdline, [[\\\@<!`.\{-}\\\@<!`]], prev_match)
end
if prev_match >= 0 then
cl = cl .. cmdline:sub(prev_match+1)
end
return cl
end
function M.new_file(args)
print(inspect(args))
ret = fn.input(args.prompt, args.text, args.completion)
print(ret)
rpcrequest('function', {"new_file", {ret, args.bufnr}}, true)
end
function M.rename(args)
print(inspect(args))
ret = fn.input(args.prompt, args.text, args.completion)
if ret == "" then
M.print_message("Cancel")
return
end
rpcrequest('function', {"rename", {ret, args.bufnr}}, true)
end
function M.error(str)
local cmd = string.format('echomsg "[tree] %s"', str)
a.nvim_command('echohl Error')
a.nvim_command(cmd)
a.nvim_command('echohl None')
end
function M.warning(str)
local cmd = string.format('echomsg "[tree] %s"', str)
a.nvim_command('echohl WarningMsg')
a.nvim_command(cmd)
a.nvim_command('echohl None')
end
function M.print_message(str)
local cmd = string.format('echo "[tree] %s"', str)
a.nvim_command(cmd)
end
function rpcrequest(method, args, is_async)
if not M.channel_id then
-- TODO: temporary
M.error("tree.channel_id doesn't exists")
return -1
end
local channel_id = M.channel_id
if is_async then
return vim.rpcnotify(channel_id, method, args)
else
return vim.rpcrequest(channel_id, method, args)
end
end
function M.linux()
return is_linux
end
function M.windows()
return is_windows
end
function M.macos()
return is_macos
end
-- Open a file.
function M.open(filename)
local filename = vim.fn.fnamemodify(filename, ':p')
local system = vim.fn.system
local shellescape = vim.fn.shellescape
local executable = vim.fn.executable
local exists = vim.fn.exists
local printf = string.format
-- Detect desktop environment.
if tree.windows() then
-- For URI only.
-- Note:
-- # and % required to be escaped (:help cmdline-special)
a.nvim_command(
printf("silent execute '!start rundll32 url.dll,FileProtocolHandler %s'", vim.fn.escape(filename, '#%')))
elseif vim.fn.has('win32unix')==1 then
-- Cygwin.
system(printf('cygstart %s', shellescape(filename)))
elseif executable('xdg-open')==1 then
-- Linux.
system(printf('%s %s &', 'xdg-open', shellescape(filename)))
elseif exists('$KDE_FULL_SESSION')==1 and vim.env['KDE_FULL_SESSION'] == 'true' then
-- KDE.
system(printf('%s %s &', 'kioclient exec', shellescape(filename)))
elseif exists('$GNOME_DESKTOP_SESSION_ID')==1 then
-- GNOME.
system(printf('gnome-open %s &', shellescape(filename)))
elseif executable('exo-open')==1 then
-- Xfce.
system(printf('exo-open %s &', shellescape(filename)))
elseif tree.macos() and executable('open')==1 then
-- Mac OS.
system(printf('open %s &', shellescape(filename)))
else
-- Give up.
M.print_error('Not supported.')
end
end
-------------------- end of util.vim --------------------
-------------------- start of init.vim --------------------
g_servername = nil
local function init_channel()
if fn.has('nvim-0.5') == 0 then
print('tree requires nvim 0.5+.')
return true
end
local servername = vim.v.servername
local cmd
-- NOTE: ~ cant expand in {cmd} arg of jobstart
if M.linux() then
cmd = {project_root .. '/bin/tree', servername}
elseif M.windows() then
local ip = '127.0.0.1'
if not g_servername then
local port = 6666
while not g_servername do
try {
function()
vim.fn.serverstart(ip..':'..tostring(port))
g_servername = port
end,
catch {
function(error)
port = port + 1
end
}
}
end
end
cmd = {project_root .. '\\bin\\tree.exe', tostring(g_servername)}
elseif M.macos() then
cmd = {project_root .. '/bin/tree', servername}
end
-- print('bin:', bin)
-- print('servername:', servername)
-- print(inspect(cmd))
fn.jobstart(cmd)
local N = 250
local i = 0
while i < N and M.channel_id == nil do
C('sleep 4m')
i = i + 1
end
-- print(string.format('Wait for server %dms', i*4))
return true
end
local function initialize()
if M.channel_id then
return
end
init_channel()
-- NOTE: Exec VimL snippets in lua.
a.nvim_exec([[
augroup tree
autocmd!
augroup END
]], false)
-- TODO: g:tree#_histories
M.tree_histories = {}
end
-- options = core + etc
local function user_var_options()
return {
wincol=math.modf(vim.o.columns/4),
winrow=math.modf(vim.o.lines/3)
}
end
function user_options()
return vim.tbl_extend('force', {
auto_cd=false,
auto_recursive_level=0,
columns='mark:indent:icon:filename:size',
ignored_files='.*',
listed=false,
profile=false,
resume=false,
root_marker='[in]: ',
session_file='',
show_ignored_files=false,
sort='filename',
toggle=false,
}, user_var_options(), default_etc_options)
end
local function internal_options()
local s = fn.getpos("'<")[2]
local e = fn.getpos("'>")[2]
cmd('delmarks <')
cmd('delmarks >')
return {
cursor=fn.line('.'),
-- drives={},
prev_bufnr=fn.bufnr('%'),
prev_winid=fn.win_getid(),
visual_start=s,
visual_end=e,
}
end
-- Transfer action context to server when perform action
-- Transfer core options when _tree_start
local function init_context(user_context)
local buffer_name = user_context.buffer_name or 'default'
local context = {} -- TODO: move user_var_options to etc options
local custom = vim.deepcopy(custom.get())
-- NOTE: Avoid empty custom.column being converted to vector
if vim.tbl_isempty(custom.column) then
custom.column = nil
end
if custom.option._ then
context = vim.tbl_extend('force', context, custom.option._)
custom.option._ = nil
end
if custom.option.buffer_name then
context = vim.tbl_extend('force', context, custom.option.buffer_name)
end
context = vim.tbl_extend('force', context, user_context)
-- TODO: support custom#column
context.custom = custom
return context
end
local function action_context()
local context = internal_options()
return context
end
-------------------- end of init.vim --------------------
-------------------- start of tree.vim --------------------
-- NOTE: The buffer creation is done by the lua side
M.alive_buf_cnt = 0
M.etc_options = {}
local count = 0
function start(paths, user_context)
initialize()
local context = init_context(user_context)
local paths = fn.map(paths, "fnamemodify(v:val, ':p')")
if #paths == 0 then
paths = {fn.expand('%:p:h')}
end
if M.alive_buf_cnt < 1 or user_context.new then
local buf = a.nvim_create_buf(false, true)
local bufname = "Tree-" .. tostring(count)
a.nvim_buf_set_name(buf, bufname);
count = count + 1
M.alive_buf_cnt = M.alive_buf_cnt + 1
local etc_opts = vim.deepcopy(default_etc_options)
for k, v in pairs(default_etc_options) do
if context[k] then
etc_opts[k] = context[k]
end
end
M.etc_options[buf] = etc_opts
context.bufnr = buf
end
rpcrequest('_tree_start', {paths, context}, false)
-- TODO: 检查 search 是否存在
-- if context['search'] !=# ''
-- call tree#call_action('search', [context['search']])
-- endif
end
function M.call_action(action, ...)
if vim.bo.filetype ~= 'tree' then
return
end
local context = action_context()
local args = ...
if type(args) ~= type({}) then
args = {...}
end
rpcrequest('_tree_do_action', {action, args, context}, false)
end
function call_async_action(action, ...)
if vim.bo.filetype ~= 'tree' then
return
end
local context = action_context()
local args = ...
if type(args) ~= 'table' then
args = {...}
end
rpcrequest('_tree_async_action', {action, args, context}, true)
end
function M.get_candidate()
if vim.bo.filetype ~= 'tree' then
return {}
end
local context = internal_options()
return rpcrequest('_tree_get_candidate', {context}, false)
end
function M.is_directory()
return fn.get(M.get_candidate(), 'is_directory', false)
end
function M.is_opened_tree()
return fn.get(M.get_candidate(), 'is_opened_tree', false)
end
function M.get_context()
if vim.bo.filetype ~= 'tree' then
return {}
end
return rpcrequest('_tree_get_context', {}, false)
end
-------------------- end of tree.vim --------------------
if _TEST then
-- Note: we prefix it with an underscore, such that the test function and real function have
-- different names. Otherwise an accidental call in the code to `M.FirstToUpper` would
-- succeed in tests, but later fail unexpectedly in production
M._set_custom = set_custom
M._init_context = init_context
M._initialize = initialize
M.__expand_complete = __expand_complete
M.custom = custom
end
tree = M
return M