diff --git a/README.md b/README.md index e9dc6da..e1a1612 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # telescope-file-browser.nvim -`telescope-file-browser.nvim` is a file browser extension for telescope.nvim. It supports synchronized creation, deletion, renaming, and moving of files and folders powered by [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) and [plenary.nvim](https://github.com/nvim-lua/plenary.nvim). +`telescope-file-browser.nvim` is a file browser extension for telescope.nvim. +It supports synchronized creation, deletion, renaming, and moving of files and +folders (with LSP integration with nvim 0.10+) powered by +[telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) and +[plenary.nvim](https://github.com/nvim-lua/plenary.nvim). ![Demo](https://user-images.githubusercontent.com/39233597/149016073-6fcc9383-a761-422b-be40-17d4b854cd3c.gif) More demo examples can be found in the [showcase issue](https://github.com/nvim-telescope/telescope-file-browser.nvim/issues/53). @@ -47,7 +51,7 @@ use {
vim-plug -```viml +```vim Plug 'nvim-lua/plenary.nvim' Plug 'nvim-telescope/telescope.nvim' Plug 'nvim-telescope/telescope-file-browser.nvim' @@ -57,7 +61,12 @@ Plug 'nvim-telescope/telescope-file-browser.nvim' ## Setup and Configuration -You can configure the `telescope-file-browser` like any other `telescope.nvim` picker. Please see `:h telescope-file-browser.picker` for the full set of options dedicated to the picker. Unless otherwise stated, you can pass these options either to your configuration at extension setup or picker startup. For instance, you can map `theme` and [mappings](#remappings) as you are used to from `telescope.nvim`. +You can configure the `telescope-file-browser` like any other `telescope.nvim` +picker. Please see `:h telescope-file-browser.picker` for the full set of +options dedicated to the picker. Unless otherwise stated, you can pass these +options either to your configuration at extension setup or picker startup. For +instance, you can map `theme` and [mappings](#remappings) as you are used to +from `telescope.nvim`. ```lua -- You don't need to set any of these options. @@ -179,10 +188,13 @@ end) ## Mappings -`telescope-file-browser.nvim` comes with a lot of default mappings for discoverability. You can use `telescope`'s `which_key` (insert mode: ``, normal mode: `?`) to list mappings attached to your picker. +`telescope-file-browser.nvim` comes with a lot of default mappings for +discoverability. You can use `telescope`'s `which_key` (insert mode: ``, +normal mode: `?`) to list mappings attached to your picker. - `path` denotes the folder the `file_browser` is currently in -- `fb_actions` refers to the table of provided `telescope-file-browser.actions` accessible via `require "telescope".extensions.file_browser.actions` +- `fb_actions` refers to the table of provided `telescope-file-browser.actions` + accessible via `require "telescope".extensions.file_browser.actions` | Insert / Normal | fb_actions | Description | | --------------- | -------------------- | -------------------------------------------------------------------------------- | @@ -208,25 +220,33 @@ end) #### Remappings -As part of the [setup](#setup-and-configuration), you can remap actions as you like. The default mappings can also be found in this [file](https://github.com/nvim-telescope/telescope-file-browser.nvim/blob/master/lua/telescope/_extensions/file_browser.lua). +As part of the [setup](#setup-and-configuration), you can remap actions as you +like. The default mappings can also be found in this +[file](https://github.com/nvim-telescope/telescope-file-browser.nvim/blob/master/lua/telescope/_extensions/file_browser.lua). ```lua local fb_actions = require "telescope".extensions.file_browser.actions --- mappings in file_browser extension of telescope.setup -... + +require("telescope").setup { + defaults = { --[[ your defaults]] }, + pickers = { + file_browser = { mappings = { ["i"] = { -- remap to going to home directory - [""] = fb_actions.goto_home_dir + [""] = fb_actions.goto_home_dir, [""] = function(prompt_bufnr) -- your custom function - end + end, }, ["n"] = { -- unmap toggling `fb_actions.toggle_browser` f = false, }, -... + }, + }, + }, +} ``` ## Documentation @@ -251,23 +271,43 @@ Please make sure to consult the docs prior to raising issues for asking question 1. `file_browser`: finds files and folders in the (currently) selected folder (denoted as `path`, default: `cwd`) 2. `folder_browser`: swiftly fuzzy find folders from `cwd` downwards to switch folders for the `file_browser` (i.e. set `path` to selected folder) -Within a single session, `path` always refers to the folder the `file_browser` is currently in and changes by selecting folders from within the `file` or `folder_browser`. +Within a single session, `path` always refers to the folder the `file_browser` +is currently in and changes by selecting folders from within the `file` or +`folder_browser`. -If you want to open the `file_browser` from within the folder of your current buffer, you should pass `path = "%:p:h"` to the `opts` table of the picker (Vimscript: `:Telescope file_browser path=%:p:h`) or to the extension setup configuration. Strings passed to `path` or `cwd` are expanded automatically. +If you want to open the `file_browser` from within the folder of your current +buffer, you should pass `path = "%:p:h"` to the `opts` table of the picker +(Vimscript: `:Telescope file_browser path=%:p:h`) or to the extension setup +configuration. Strings passed to `path` or `cwd` are expanded automatically. -By default, the `folder_browser` always launches from `cwd`, but it can be configured to launch from `path` via passing the `cwd_to_path = true` to picker `opts` table or at extension setup. The former corresponds to a more project-centric file browser workflow, whereas the latter typically facilitates file and folder browsing across the entire file system. +By default, the `folder_browser` always launches from `cwd`, but it can be +configured to launch from `path` via passing the `cwd_to_path = true` to picker +`opts` table or at extension setup. The former corresponds to a more +project-centric file browser workflow, whereas the latter typically facilitates +file and folder browsing across the entire file system. -In practice, it mostly affects how you navigate the file system in multi-hop scenarios, for instance, when moving files from varying folders into a separate folder. The default works well in projects from which the `folder_browser` can easily reach any folder. `cwd_to_path = true` would possibly require returning to parent directories or `cwd` intermittently. However, if you move deeply through the file system, launching the `folder_browser` from `cwd` every time is tedious. Hence, it can be configured to follow `path` instead. +In practice, it mostly affects how you navigate the file system in multi-hop +scenarios, for instance, when moving files from varying folders into a separate +folder. The default works well in projects from which the `folder_browser` can +easily reach any folder. `cwd_to_path = true` would possibly require returning +to parent directories or `cwd` intermittently. However, if you move deeply +through the file system, launching the `folder_browser` from `cwd` every time +is tedious. Hence, it can be configured to follow `path` instead. -In general, `telescope-file-browser.nvim` intends to enable any workflow without comprise via opting in as virtually any component can be overriden. +In general, `telescope-file-browser.nvim` intends to enable any workflow +without comprise via opting in as virtually any component can be overriden. ## Multi-Selections -Multiple files and directories can be selected at the same time using default bindings (``/``) from `telescope.nvim`. +Multiple files and directories can be selected at the same time using default +bindings (``/``) from `telescope.nvim`. -One distinct difference to `telescope.nvim` is that multi-selections are preserved between browsers. +One distinct difference to `telescope.nvim` is that multi-selections are +preserved between browsers. -Hence, whenever you (de-)select a file or folder within `{file, folder}_browser`, respectively, this change persists across browsers (in a single session). +Hence, whenever you (de-)select a file or folder within `{file, +folder}_browser`, respectively, this change persists across browsers (in a +single session). ## File System Operations @@ -302,6 +342,10 @@ The extension exports the following attributes via `:lua require "telescope".ext # Roadmap & Contributing -Please see the associated [issue](https://github.com/nvim-telescope/telescope-file-browser.nvim/issues/3) on more immediate open `TODOs` for `telescope-file-browser.nvim`. +Please see the associated +[issue](https://github.com/nvim-telescope/telescope-file-browser.nvim/issues/3) +on more immediate open `TODOs` for `telescope-file-browser.nvim`. -That said, the primary work surrounds on enabling users to tailor the extension to their individual workflow, primarily through opting in and possibly overriding specific components. +That said, the primary work surrounds on enabling users to tailor the extension +to their individual workflow, primarily through opting in and possibly +overriding specific components. diff --git a/lua/telescope/_extensions/file_browser/actions.lua b/lua/telescope/_extensions/file_browser/actions.lua index 3147fe0..ee96d77 100755 --- a/lua/telescope/_extensions/file_browser/actions.lua +++ b/lua/telescope/_extensions/file_browser/actions.lua @@ -25,6 +25,7 @@ local a = vim.api local fb_utils = require "telescope._extensions.file_browser.utils" +local fb_lsp = require "telescope._extensions.file_browser.lsp" local actions = require "telescope.actions" local state = require "telescope.state" @@ -78,11 +79,17 @@ local create = function(file, finder) fb_utils.notify("actions.create", { msg = "Selection already exists!", level = "WARN", quiet = finder.quiet }) return end + + local filename = file:absolute() + fb_lsp.will_create_files { filename } + if not fb_utils.is_dir(file.filename) then file:touch { parents = true } else Path:new(file.filename:sub(1, -2)):mkdir { parents = true, mode = 493 } -- 493 => decimal for mode 0755 end + + fb_lsp.did_create_files { filename } return file end @@ -173,6 +180,31 @@ fb_actions.create_from_prompt = function(prompt_bufnr) end end +local rename_au_group = a.nvim_create_augroup("TelescopeBatchRename", { clear = true }) + +---@param path_map table table of old -> new +local function rename_files(path_map) + local str_map = {} ---@type table + for old, new in pairs(path_map) do + str_map[old:absolute()] = new:absolute() + end + + fb_lsp.will_rename_files(str_map) + + for old, new in pairs(path_map) do + local old_name = old:absolute() + local new_name = new:absolute() + old:rename { new_name = new_name } + if new:is_dir() then + fb_utils.rename_dir_buf(old_name, new_name) + else + fb_utils.rename_buf(old_name, new_name) + end + end + + fb_lsp.did_rename_files(str_map) +end + local batch_rename = function(prompt_bufnr, selections) local current_picker = action_state.get_current_picker(prompt_bufnr) local prompt_win = a.nvim_get_current_win() @@ -208,42 +240,40 @@ local batch_rename = function(prompt_bufnr, selections) }) end - _G.__TelescopeBatchRename = function() + local _batch_rename = function() local lines = a.nvim_buf_get_lines(buf, 0, -1, false) assert(#lines == #what, "Keep a line unchanged if you do not want to rename") + local path_map = {} for idx, file in ipairs(lines) do - local old_path = selections[idx]:absolute() - local new_path = Path:new(file):absolute() - if old_path ~= new_path then - local is_dir = selections[idx]:is_dir() - selections[idx]:rename { new_name = new_path } - if not is_dir then - fb_utils.rename_buf(old_path, new_path) - else - fb_utils.rename_dir_buf(old_path, new_path) - end + local old = selections[idx] + local new = Path:new(file) + if old.filename ~= new.filename then + path_map[old] = new end end + rename_files(path_map) a.nvim_set_current_win(prompt_win) current_picker:refresh(current_picker.finder, { reset_prompt = true }) end - local set_bkm = a.nvim_buf_set_keymap - local opts = { noremap = true, silent = true } - set_bkm(buf, "n", "", string.format("lua vim.api.nvim_set_current_win(%s)", prompt_win), opts) - set_bkm(buf, "i", "", string.format("lua vim.api.nvim_set_current_win(%s)", prompt_win), opts) - set_bkm(buf, "n", "", "lua _G.__TelescopeBatchRename()", opts) - set_bkm(buf, "i", "", "lua _G.__TelescopeBatchRename()", opts) - - vim.cmd(string.format( - "autocmd BufLeave ++once lua %s", - table.concat({ - string.format("_G.__TelescopeBatchRename = nil", win), - string.format("pcall(vim.api.nvim_win_close, %s, true)", win), - string.format("pcall(vim.api.nvim_win_close, %s, true)", win_opts.border.win_id), - string.format("require 'telescope.utils'.buf_delete(%s)", buf), - }, ";") - )) + local opts = { noremap = true, silent = true, buffer = buf } + -- stylua: ignore start + vim.keymap.set("n", "", function() a.nvim_set_current_win(prompt_win) end, opts) + vim.keymap.set("i", "", function() a.nvim_set_current_win(prompt_win) end, opts) + -- stylua: ignore end + vim.keymap.set("n", "", _batch_rename, opts) + vim.keymap.set("i", "", _batch_rename, opts) + + a.nvim_create_autocmd("BufLeave", { + once = true, + callback = function() + pcall(a.nvim_win_close, win, true) + pcall(a.nvim_win_close, win_opts.border.win_id, true) + require("telescope.utils").buf_delete(buf) + end, + group = rename_au_group, + buffer = buf, + }) end --- Rename files or folders for |telescope-file-browser.picker.file_browser|. @@ -301,15 +331,7 @@ fb_actions.rename = function(prompt_bufnr) return end - -- rename changes old_name in place - local old_name = old_path:absolute() - - old_path:rename { new_name = new_path.filename } - if not new_path:is_dir() then - fb_utils.rename_buf(old_name, new_path:absolute()) - else - fb_utils.rename_dir_buf(old_name, new_path:absolute()) - end + rename_files { [old_path] = new_path } -- persist multi selections unambiguously by only removing renamed entry if current_picker:is_multi_selected(entry) then @@ -519,6 +541,8 @@ fb_actions.remove = function(prompt_bufnr) get_confirmation({ prompt = "Remove selection? (" .. #files .. " items)" }, function(confirmed) vim.cmd [[ redraw ]] -- redraw to clear out vim.ui.prompt to avoid hit-enter prompt if confirmed then + fb_lsp.will_delete_files(files) + for _, p in ipairs(selections) do local is_dir = p:is_dir() p:rm { recursive = is_dir } @@ -530,6 +554,8 @@ fb_actions.remove = function(prompt_bufnr) end table.insert(removed, p.filename:sub(#p:parent().filename + 2)) end + + fb_lsp.did_delete_files(files) fb_utils.notify( "actions.remove", { msg = "Removed: " .. table.concat(removed, ", "), level = "INFO", quiet = quiet } @@ -551,8 +577,10 @@ fb_actions.toggle_hidden = function(prompt_bufnr) finder.hidden = not finder.hidden else if finder.files then + ---@diagnostic disable-next-line: inject-field finder.hidden.file_browser = not finder.hidden.file_browser else + ---@diagnostic disable-next-line: inject-field finder.hidden.folder_browser = not finder.hidden.folder_browser end end diff --git a/lua/telescope/_extensions/file_browser/lsp.lua b/lua/telescope/_extensions/file_browser/lsp.lua new file mode 100644 index 0000000..886b18c --- /dev/null +++ b/lua/telescope/_extensions/file_browser/lsp.lua @@ -0,0 +1,250 @@ +if vim.fn.has "nvim-0.10" == 0 then + return { + will_create_files = function() end, + did_create_files = function() end, + will_rename_files = function() end, + did_rename_files = function() end, + will_delete_files = function() end, + did_delete_files = function() end, + } +end + +local fb_utils = require "telescope._extensions.file_browser.utils" +local methods = vim.lsp.protocol.Methods + +local M = {} + +local capability_names = { + [methods.workspace_willCreateFiles] = "willCreate", + [methods.workspace_didCreateFiles] = "didCreate", + [methods.workspace_willRenameFiles] = "willRename", + [methods.workspace_didRenameFiles] = "didRename", + [methods.workspace_willDeleteFiles] = "willDelete", + [methods.workspace_didDeleteFiles] = "didDelete", +} + +local glob_to_lpeg +do + -- HACK: https://github.com/neovim/neovim/issues/28931 + local lpeg = vim.lpeg + local P, S, V, R, B = lpeg.P, lpeg.S, lpeg.V, lpeg.R, lpeg.B + local C, Cc, Ct, Cf = lpeg.C, lpeg.Cc, lpeg.Ct, lpeg.Cf + + local pathsep = P "/" + + --- Parses a raw glob into an |lua-lpeg| pattern. + --- + --- This uses glob semantics from LSP 3.17.0: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#pattern + --- + --- Glob patterns can have the following syntax: + --- - `*` to match one or more characters in a path segment + --- - `?` to match on one character in a path segment + --- - `**` to match any number of path segments, including none + --- - `{}` to group conditions (e.g. `*.{ts,js}` matches TypeScript and JavaScript files) + --- - `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) + --- - `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) + --- + ---@param pattern string The raw glob pattern + ---@return vim.lpeg.Pattern pattern An |lua-lpeg| representation of the pattern + function glob_to_lpeg(pattern) + local function class(inv, ranges) + local patt = R(unpack(vim.tbl_map(table.concat, ranges))) + if inv == "!" then + patt = P(1) - patt + end + return patt + end + + local function condlist(conds, after) + return vim.iter(conds):fold(P(false), function(acc, cond) + return acc + cond * after + end) + end + + local function mul(acc, m) + return acc * m + end + + local function star(stars, after) + return (-after * (P(1) - pathsep)) ^ #stars * after + end + + local function dstar(after) + return (-after * P(1)) ^ 0 * after + end + + local p = P { + "Pattern", + Pattern = V "Elem" ^ -1 * V "End", + Elem = Cf((V "DStar" + V "Star" + V "Ques" + V "Class" + V "CondList" + V "Literal") * (V "Elem" + V "End"), mul), + DStar = (B(pathsep) + -B(P(1))) * P "**" * (pathsep * (V "Elem" + V "End") + V "End") / dstar, + Star = C(P "*" ^ 1) * (V "Elem" + V "End") / star, + Ques = P "?" * Cc(P(1) - pathsep), + Class = P "[" * C(P "!" ^ -1) * Ct(Ct(C(P(1)) * P "-" * C(P(1) - P "]")) ^ 1 * P "]") / class, + CondList = P "{" * Ct(V "Cond" * (P "," * V "Cond") ^ 0) * P "}" * V "Pattern" / condlist, + -- TODO: '*' inside a {} condition is interpreted literally but should probably have the same + -- wildcard semantics it usually has. + -- Fixing this is non-trivial because '*' should match non-greedily up to "the rest of the + -- pattern" which in all other cases is the entire succeeding part of the pattern, but at the end of a {} + -- condition means "everything after the {}" where several other options separated by ',' may + -- exist in between that should not be matched by '*'. + Cond = Cf((V "Ques" + V "Class" + V "Literal" - S ",}") ^ 1, mul) + Cc(P(0)), + Literal = P(1) / P, + End = P(-1) * Cc(P(-1)), + } + + local lpeg_pattern = p:match(pattern) --[[@as vim.lpeg.Pattern?]] + assert(lpeg_pattern, "Invalid glob") + return lpeg_pattern + end +end + +---@param glob string +---@param ignore_case boolean +---@return vim.lpeg.Pattern +local function glob_to_pattern(glob, ignore_case) + glob = ignore_case and glob:lower() or glob + if fb_utils.iswin then + glob = glob:gsub("/", "\\") + end + return glob_to_lpeg(glob) +end + +---@param files string[] +---@param filters lsp.FileOperationFilter[] +---@return string[] +local function matching_files(files, filters) + local match_fns = {} ---@type (fun(file: string): boolean)[] + for _, filter in ipairs(filters) do + if filter.scheme == nil or filter.scheme == "file" then + local ignore_case = vim.F.if_nil(vim.tbl_get(filter, "pattern", "ignoreCase"), false) + local lpeg_pattern = glob_to_pattern(filter.pattern.glob, ignore_case) + local pattern_kind = vim.F.if_nil(vim.tbl_get(filter, "pattern", "matches"), "all") + + table.insert(match_fns, function(file) + local is_dir = vim.fn.isdirectory(file) == 1 + if pattern_kind == "file" and is_dir then + return false + elseif pattern_kind == "folder" and not is_dir then + return false + end + + file = ignore_case and file:lower() or file + return lpeg_pattern:match(file) ~= nil + end) + end + end + + return vim + .iter(files) + :filter(function(file) + return vim.iter(match_fns):any(function(fn) + return fn(file) + end) + end) + :totable() +end + +---@param method string +---@param files string[] +---@param param_fn fun(files: string[]): (lsp.CreateFilesParams | lsp.RenameFilesParams | lsp.DeleteFilesParams) +local function will_do(method, files, param_fn) + local clients = vim.lsp.get_clients { method = method } ---@type vim.lsp.Client[] + + if vim.tbl_isempty(clients) then + return + end + + for _, client in pairs(clients) do + local filters = + vim.tbl_get(client, "server_capabilities", "workspace", "fileOperations", capability_names[method], "filters") + if filters ~= nil then + files = matching_files(files, filters) + local param = param_fn(files) + local result, reason = client.request_sync(method, param, nil, 0) + if result == nil then + fb_utils.notify("lsp", { msg = reason, level = "WARN" }) + elseif result.err ~= nil then + fb_utils.notify("lsp", { msg = result.err, level = "WARN" }) + else + vim.lsp.util.apply_workspace_edit(result.result, client.offset_encoding) + end + end + end +end + +---@param method string +---@param files string[] +---@param param_fn fun(files: string[]): (lsp.CreateFilesParams | lsp.RenameFilesParams | lsp.DeleteFilesParams) +local function did_do(method, files, param_fn) + local clients = vim.lsp.get_clients { method = method } ---@type vim.lsp.Client[] + + if vim.tbl_isempty(clients) then + return + end + + for _, client in pairs(clients) do + local filters = + vim.tbl_get(client, "server_capabilities", "workspace", "fileOperations", capability_names[method], "filters") + if filters ~= nil then + files = matching_files(files, filters) + local param = param_fn(files) + local status = client.notify(method, param) + if not status then + fb_utils.notify("lsp", { msg = "Failed to notify LSP server", level = "WARN" }) + end + end + end +end + +---@param files string[] +---@return lsp.CreateFilesParams | lsp.DeleteFilesParams +local create_delete_params = function(files) + return { files = vim.tbl_map(function(file) + return { uri = vim.uri_from_fname(file) } + end, files) } +end + +local rename_params = function(file_map) + ---@param files string[] + ---@return lsp.RenameFilesParams + return function(files) + return { + files = vim.tbl_map(function(file) + return { oldUri = vim.uri_from_fname(file), newUri = vim.uri_from_fname(file_map[file]) } + end, files), + } + end +end + +---@param files string[] +function M.will_create_files(files) + will_do(methods.workspace_willCreateFiles, files, create_delete_params) +end + +---@param files string[] +function M.did_create_files(files) + did_do(methods.workspace_didCreateFiles, files, create_delete_params) +end + +---@param file_map table old name to new name mapping +function M.will_rename_files(file_map) + will_do(methods.workspace_willRenameFiles, vim.tbl_keys(file_map), rename_params(file_map)) +end + +---@param file_map table old name to new name mapping +function M.did_rename_files(file_map) + did_do(methods.workspace_didRenameFiles, vim.tbl_keys(file_map), rename_params(file_map)) +end + +---@param files string[] +function M.will_delete_files(files) + will_do(methods.workspace_willDeleteFiles, files, create_delete_params) +end + +---@param files string[] +function M.did_delete_files(files) + did_do(methods.workspace_didDeleteFiles, files, create_delete_params) +end + +return M diff --git a/lua/telescope/_extensions/file_browser/make_entry.lua b/lua/telescope/_extensions/file_browser/make_entry.lua index bb902ad..e7e4881 100644 --- a/lua/telescope/_extensions/file_browser/make_entry.lua +++ b/lua/telescope/_extensions/file_browser/make_entry.lua @@ -239,7 +239,7 @@ local make_entry = function(opts) local path = Path:new(absolute_path) local is_dir = path:is_dir() - local e = setmetatable({ + local entry = setmetatable({ absolute_path, ordinal = fb_make_entry_utils.get_ordinal_path(absolute_path, opts.cwd, parent_dir), Path = path, @@ -252,14 +252,18 @@ local make_entry = function(opts) local cached_entry = opts._entry_cache[absolute_path] if cached_entry ~= nil then -- update the entry in-place to keep multi selections in tact - cached_entry.ordinal = e.ordinal - cached_entry.display = e.display + cached_entry.is_dir = is_dir + cached_entry.path = absolute_path + cached_entry.Path = path + cached_entry.ordinal = entry.ordinal + cached_entry.display = entry.display cached_entry.cwd = opts.cwd + return cached_entry end - opts._entry_cache[absolute_path] = e - return e -- entry + opts._entry_cache[absolute_path] = entry + return entry end end