-- 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 ` 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 ]]..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([[:lua tree.call(tree.callback["%s"])]], vim.fn.escape(lhs, '\\')) else if expr then str = str .. string.format([[:call v:lua.call_async_action(%s, luaeval('tree.callback["%s"]()'))]], fn.string(op), vim.fn.escape(lhs, '\\')) else str = str .. string.format([[:call v:lua.call_async_action(%s, %s)]], fn.string(op), fn.string(args)) end end elseif vim.fn.exists(':'..op)==2 then str = str .. ':'..op..'' 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, [[\\\@= 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, [[\\\@= 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, [[\\\@= 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