--============================================================================= -- projectmanager.lua --- The lua version of projectmanager..vim -- Copyright (c) 2016-2023 Wang Shidong & Contributors -- Author: Wang Shidong < wsdjeg@outlook.com > -- URL: https://spacevim.org -- License: GPLv3 --============================================================================= local logger = require('spacevim.logger').derive('project') local sp_buffer = require('spacevim.api').import('vim.buffer') -- start debug mode logger.start_debug() local sp = require('spacevim') local sp_file = require('spacevim.api.file') local sp_json = require('spacevim.api.data.json') local sp_opt = require('spacevim.opt') local fn = sp.fn local layer = require('spacevim.layer') local project_paths = {} local project_cache_path = sp_file.unify_path(sp_opt.data_dir, ':p') .. 'SpaceVim/projects.json' local spacevim_project_rooter_patterns = {} local project_rooter_patterns = {} local project_rooter_ignores = {} local project_callback = {} local cd = 'cd' if fn.exists(':tcd') then cd = 'tcd' elseif fn.exists(':lcd') then cd = 'lcd' end local function update_rooter_patterns() project_rooter_patterns = {} project_rooter_ignores = {} for _, v in pairs(sp_opt.project_rooter_patterns) do if string.match(v, '^!') == nil then table.insert(project_rooter_patterns, v) else table.insert(project_rooter_ignores, string.sub(v, 2, -1)) end end logger.debug('project rooter patterns:' .. vim.inspect(project_rooter_patterns)) end local function is_ignored_dir(dir) for _, v in pairs(project_rooter_ignores) do if string.match(dir, v) ~= nil then logger.debug('this is an ignored dir:' .. dir) return true end end return false end local function cache() local path = sp_file.unify_path(project_cache_path, ':p') local file = io.open(path, 'w') if file then if file:write(sp_json.json_encode(project_paths)) == nil then logger.debug('failed to write to file:' .. path) end io.close(file) else logger.debug('failed to open file:' .. path) end end local function readfile(path) local file = io.open(path, 'r') if file then local content = file:read('*a') io.close(file) return content end return nil end local function filereadable(fpath) local f = io.open(fpath, 'r') if f ~= nil then io.close(f) return true else return false end end local function isdirectory(fpath) local f, err, code = io.open(fpath, 'r') if f ~= nil then f:close() return false end return code == 13 end local function filter_invalid(projects) for key, value in pairs(projects) do if fn.isdirectory(value.path) == 0 then projects[key] = nil end end return projects end local function load_cache() if filereadable(project_cache_path) then logger.info('Load projects cache from: ' .. project_cache_path) local cache_context = readfile(project_cache_path) if cache_context ~= nil then local cache_object = sp_json.json_decode(cache_context) if type(cache_object) == 'table' then project_paths = filter_invalid(cache_object) end end else logger.info('projects cache file does not exists!') end end local function compare_time(d1, d2) local proj1 = project_paths[d1] or {} local proj1time = proj1['opened_time'] or 0 local proj2 = project_paths[d2] or {} local proj2time = proj2['opened_time'] or 0 return proj2time < proj1time end local function sort_by_opened_time() local paths = {} for k, _ in pairs(project_paths) do table.insert(paths, k) end table.sort(paths, compare_time) if sp_opt.projects_cache_num > 0 and #paths >= sp_opt.projects_cache_num then for i = sp_opt.projects_cache_num, #paths, 1 do project_paths[paths[sp_opt.projects_cache_num]] = nil table.remove(paths, sp_opt.projects_cache_num) end end return paths end local function change_dir(dir) if dir == sp_file.unify_path(fn.getcwd()) then return false else sp.cmd(cd .. ' ' .. sp.fn.fnameescape(sp.fn.fnamemodify(dir, ':p'))) return true end end local function compare(d1, d2) local al = #vim.split(d1, '/') local bl = #vim.split(d2, '/') -- logger.debug('al is ' .. al) -- logger.debug('bl is ' .. bl) -- the project_rooter_outermost is 0/false or 1 true if sp_opt.project_rooter_outermost == 0 or sp_opt.project_rooter_outermost == false then if bl >= al then return false else return true end else if al > bl then return false else return true end end end local function sort_dirs(dirs) table.sort(dirs, compare) local dir = dirs[1] -- logger.debug(vim.inspect(dirs)) local bufdir = fn.getbufvar('%', 'rootDir', '') if bufdir == dir then return '' else return dir end end local function find_root_directory() local fd = fn.bufname('%') if fd == '' then -- for empty name buffer, check previous buffer dir local previous_bufnr = vim.fn.bufnr('#') if previous_bufnr == -1 then logger.debug('previous buffer does not exist, use current directory instead!') elseif fn.getbufvar('#', 'rootDir', '') == '' then logger.debug('previous buffer rootDir is empty, use current directory instead!') else return fn.getbufvar('#', 'rootDir', '') end fd = fn.getcwd() end fd = fn.fnamemodify(fd, ':p') logger.debug('start to find root for: ' .. fd) local dirs = {} for _, pattern in pairs(project_rooter_patterns) do local find_path = '' if string.sub(pattern, -1) == '/' then if sp_opt.project_rooter_outermost == 1 then find_path = sp_file.finddir(pattern, fd, -1) else find_path = sp_file.finddir(pattern, fd) end else if sp_opt.project_rooter_outermost == 1 then find_path = sp_file.findfile(pattern, fd, -1) else find_path = sp_file.findfile(pattern, fd) end end local path_type = fn.getftype(find_path) if (path_type == 'dir' or path_type == 'file') and not (is_ignored_dir(find_path)) then find_path = sp_file.unify_path(find_path, ':p') if path_type == 'dir' then find_path = sp_file.unify_path(find_path, ':h:h') else find_path = sp_file.unify_path(find_path, ':h') end if find_path ~= sp_file.unify_path(fn.expand('$HOME')) then logger.info(' (' .. pattern .. '):' .. find_path) table.insert(dirs, find_path) else logger.info('ignore $HOME directory:' .. find_path) end end end return sort_dirs(dirs) end local function cache_project(prj) project_paths[prj.path] = prj sp.cmd('let g:unite_source_menu_menus.Projects.command_candidates = []') for _, key in pairs(sort_by_opened_time()) do local desc = '[' .. project_paths[key].name .. '] ' .. project_paths[key].path .. ' <' .. fn.strftime('%Y-%m-%d %T', project_paths[key].opened_time) .. '>' local cmd = "call SpaceVim#plugins#projectmanager#open('" .. project_paths[key].path .. "')" sp.cmd( 'call add(g:unite_source_menu_menus.Projects.command_candidates, ["' .. desc .. '", "' .. cmd .. '"])' ) end if sp_opt.enable_projects_cache then cache() end end -- call add(g:spacevim_project_rooter_patterns, '.SpaceVim.d/') -- let s:spacevim_project_rooter_patterns = copy(g:spacevim_project_rooter_patterns) update_rooter_patterns() if sp_opt.enable_projects_cache == 1 then load_cache() end sp.cmd([[ let g:unite_source_menu_menus = get(g:,'unite_source_menu_menus',{}) let g:unite_source_menu_menus.Projects = {'description': 'Custom mapped keyboard shortcuts [SPC] p p'} let g:unite_source_menu_menus.Projects.command_candidates = get(g:unite_source_menu_menus.Projects,'command_candidates', []) ]]) if sp_opt.project_auto_root == 1 then sp.cmd('augroup spacevim_project_rooter') sp.cmd('autocmd!') sp.cmd('autocmd VimEnter,BufEnter * ++nested call SpaceVim#plugins#projectmanager#current_root()') sp.cmd( "autocmd BufWritePost * :call setbufvar('%', 'rootDir', '') | call SpaceVim#plugins#projectmanager#current_root()" ) sp.cmd('augroup END') end local M = {} function M.list() if layer.isLoaded('unite') then sp.cmd('Unite menu:Projects') elseif layer.isLoaded('denite') then sp.cmd('Denite menu:Projects') elseif layer.isLoaded('fzf') then sp.cmd('FzfMenu Projects') elseif layer.isLoaded('leaderf') then sp.cmd("call SpaceVim#layers#leaderf#run_menu('Projects')") elseif layer.isLoaded('telescope') then sp.cmd('Telescope project') else logger.warn('fuzzy find layer is needed to find project!') end end function M.open(project) local path = project_paths[project]['path'] -- local name = project_paths[project]['name'] sp.cmd('tabnew') -- I am not sure we should set the project name here. -- sp.cmd('let t:_spacevim_tab_name = "[' .. name .. ']"') sp.cmd(cd .. ' ' .. path) if sp_opt.filemanager == 'vimfiler' then sp.cmd('Startify | VimFiler') elseif sp_opt.filemanager == 'nerdtree' then sp.cmd('Startify | NERDTree') elseif sp_opt.filemanager == 'defx' then sp.cmd('Startify | Defx -new') elseif sp_opt.filemanager == 'neo-tree' then sp.cmd('Startify | NeoTreeFocusToggle') end end function M.current_name() return sp.eval('b:_spacevim_project_name') end function M.RootchandgeCallback() -- this function only will be called when switch to other project. local path = sp_file.unify_path(fn.getcwd(), ':p') local name = fn.fnamemodify(path, ':h:t') logger.info('switch to project:[' .. name .. ']') logger.info(' rootdir is:' .. path) local project = { ['path'] = path, ['name'] = name, ['opened_time'] = os.time(), } if project.path == '' then return end cache_project(project) -- let g:_spacevim_project_name = project.name -- let b:_spacevim_project_name = g:_spacevim_project_name fn.setbufvar('%', '_spacevim_project_name', project.name) for _, Callback in pairs(project_callback) do if type(Callback.func) == 'string' then if Callback.desc then logger.debug(' run callback:' .. Callback.desc) else logger.debug(' run callback:' .. Callback.func) end fn.call(Callback.func, {}) elseif type(Callback.func) == 'function' then if Callback.desc then logger.debug(' run callback:' .. Callback.desc) else logger.debug(' run callback:' .. tostring(Callback.func)) end pcall(Callback.func) end end end function M.reg_callback(func, ...) local callback = { func = func } local argv = { ... } if argv[1] then callback.desc = argv[1] end if type(callback.func) == 'string' or type(callback.func) == 'function' then if type(callback.func) == 'string' and string.match(callback.func, '^function%(') ~= nil then callback.func = string.sub(callback.func, 11, -3) end table.insert(project_callback, callback) else logger.warn('type of func is:' .. type(callback.func)) logger.warn('can not register the project callback: ' .. fn.string(callback.func)) end end function M.kill_project() local name = sp.eval('b:_spacevim_project_name') if name ~= '' then sp_buffer.filter_do({ ['expr'] = { 'buflisted(v:val)', 'getbufvar(v:val, "_spacevim_project_name") == "' .. name .. '"', }, ['do'] = 'bd %d', }) end end function M.complete_project(arglead, cmdline, cursorpos) local dir = vim.g.spacevim_src_root or '~' local result = fn.split(fn.globpath(dir, '*'), '\n') local ps = {} for _, p in pairs(result) do if fn.isdirectory(p) == 1 and fn.isdirectory(p .. sp_file.separator .. '.git') == 1 then table.insert(ps, fn.fnamemodify(p, ':t')) end end return fn.join(ps, '\n') end function M.OpenProject(p) local dir = vim.g.spacevim_src_root or '~' local project_root = sp_file.unify_path(dir, ':p') .. p if vim.fn.isdirectory(project_root) == 1 then sp.cmd('tabnew | cd ' .. project_root .. ' | Startify') end end function M.current_root() local bufname = fn.bufname('%') if bufname:match('%[denite%]') or bufname:match('denite-filter') or bufname:match('%[defx%]') or bufname:match('^git://') -- this is for git.vim or vim.fn.empty(bufname) == 1 or bufname:match('^neo%-tree') -- this is for neo-tree.nvim then return fn.getcwd() end if table.concat(sp_opt.project_rooter_patterns, ':') ~= table.concat(spacevim_project_rooter_patterns, ':') then logger.info('project_rooter_patterns option has been change, clear b:rootDir') fn.setbufvar('%', 'rootDir', '') spacevim_project_rooter_patterns = sp_opt.project_rooter_patterns update_rooter_patterns() end local rootdir = fn.getbufvar('%', 'rootDir', '') if rootdir == '' then rootdir = find_root_directory() if rootdir == nil or rootdir == '' then -- for no project if vim.g.spacevim_project_non_root == '' then rootdir = sp_file.unify_path(fn.getcwd()) elseif vim.g.spacevim_project_non_root == 'home' and filereadable(bufname) then rootdir = sp_file.unify_path(fn.expand('~')) elseif vim.g.spacevim_project_non_root == 'current' then local dir = sp_file.unify_path(bufname, ':p:h') if isdirectory(dir) then rootdir = dir else rootdir = sp_file.unify_path(fn.getcwd()) end else -- maybe log error end change_dir(rootdir) else -- for project if change_dir(rootdir) then M.RootchandgeCallback() end end fn.setbufvar('%', 'rootDir', rootdir) elseif change_dir(rootdir) then M.RootchandgeCallback() end return rootdir end function M.get_project_history() -- {{{ return project_paths end -- }}} return M