local cache = require("oil.cache") local columns = require("oil.columns") local config = require("oil.config") local constants = require("oil.constants") local oil = require("oil") local parser = require("oil.mutator.parser") local pathutil = require("oil.pathutil") local preview = require("oil.mutator.preview") local Progress = require("oil.mutator.progress") local Trie = require("oil.mutator.trie") local util = require("oil.util") local view = require("oil.view") local M = {} local FIELD_NAME = constants.FIELD_NAME local FIELD_TYPE = constants.FIELD_TYPE ---@alias oil.Action oil.CreateAction|oil.DeleteAction|oil.MoveAction|oil.CopyAction|oil.ChangeAction ---@class (exact) oil.CreateAction ---@field type "create" ---@field url string ---@field entry_type oil.EntryType ---@field link nil|string ---@class (exact) oil.DeleteAction ---@field type "delete" ---@field url string ---@field entry_type oil.EntryType ---@class (exact) oil.MoveAction ---@field type "move" ---@field entry_type oil.EntryType ---@field src_url string ---@field dest_url string ---@class (exact) oil.CopyAction ---@field type "copy" ---@field entry_type oil.EntryType ---@field src_url string ---@field dest_url string ---@class (exact) oil.ChangeAction ---@field type "change" ---@field entry_type oil.EntryType ---@field url string ---@field column string ---@field value any ---@param all_diffs table ---@return oil.Action[] M.create_actions_from_diffs = function(all_diffs) ---@type oil.Action[] local actions = {} local diff_by_id = setmetatable({}, { __index = function(t, key) local list = {} rawset(t, key, list) return list end, }) for bufnr, diffs in pairs(all_diffs) do local adapter = util.get_adapter(bufnr) if not adapter then error("Missing adapter") end local parent_url = vim.api.nvim_buf_get_name(bufnr) for _, diff in ipairs(diffs) do if diff.type == "new" then if diff.id then local by_id = diff_by_id[diff.id] -- FIXME this is kind of a hack. We shouldn't be setting undocumented fields on the diff ---@diagnostic disable-next-line: inject-field diff.dest = parent_url .. diff.name table.insert(by_id, diff) else -- Parse nested files like foo/bar/baz local pieces = vim.split(diff.name, "/") local url = parent_url:gsub("/$", "") for i, v in ipairs(pieces) do local is_last = i == #pieces local entry_type = is_last and diff.entry_type or "directory" local alternation = v:match("{([^}]+)}") if is_last and alternation then -- Parse alternations like foo.{js,test.js} for _, alt in ipairs(vim.split(alternation, ",")) do local alt_url = url .. "/" .. v:gsub("{[^}]+}", alt) table.insert(actions, { type = "create", url = alt_url, entry_type = entry_type, link = diff.link, }) end else url = url .. "/" .. v table.insert(actions, { type = "create", url = url, entry_type = entry_type, link = diff.link, }) end end end elseif diff.type == "change" then table.insert(actions, { type = "change", url = parent_url .. diff.name, entry_type = diff.entry_type, column = diff.column, value = diff.value, }) else local by_id = diff_by_id[diff.id] by_id.has_delete = true -- Don't insert the delete. We already know that there is a delete because of the presense -- in the diff_by_id map. The list will only include the 'new' diffs. end end end for id, diffs in pairs(diff_by_id) do local entry = cache.get_entry_by_id(id) if not entry then error(string.format("Could not find entry %d", id)) end if diffs.has_delete then local has_create = #diffs > 0 if has_create then -- MOVE (+ optional copies) when has both creates and delete for i, diff in ipairs(diffs) do table.insert(actions, { type = i == #diffs and "move" or "copy", entry_type = entry[FIELD_TYPE], dest_url = diff.dest, src_url = cache.get_parent_url(id) .. entry[FIELD_NAME], }) end else -- DELETE when no create table.insert(actions, { type = "delete", entry_type = entry[FIELD_TYPE], url = cache.get_parent_url(id) .. entry[FIELD_NAME], }) end else -- COPY when create but no delete for _, diff in ipairs(diffs) do table.insert(actions, { type = "copy", entry_type = entry[FIELD_TYPE], src_url = cache.get_parent_url(id) .. entry[FIELD_NAME], dest_url = diff.dest, }) end end end return M.enforce_action_order(actions) end ---@param actions oil.Action[] ---@return oil.Action[] M.enforce_action_order = function(actions) local src_trie = Trie.new() local dest_trie = Trie.new() for _, action in ipairs(actions) do if action.type == "delete" or action.type == "change" then src_trie:insert_action(action.url, action) elseif action.type == "create" then dest_trie:insert_action(action.url, action) else dest_trie:insert_action(action.dest_url, action) src_trie:insert_action(action.src_url, action) end end -- 1. create a graph, each node points to all of its dependencies -- 2. for each action, if not added, find it in the graph -- 3. traverse through the graph until you reach a node that has no dependencies (leaf) -- 4. append that action to the return value, and remove it from the graph -- a. TODO optimization: check immediate parents to see if they have no dependencies now -- 5. repeat ---Gets the dependencies of a particular action. Effectively dynamically calculates the dependency ---"edges" of the graph. ---@param action oil.Action local function get_deps(action) local ret = {} if action.type == "delete" then src_trie:accum_children_of(action.url, ret) elseif action.type == "create" then -- Finish operating on parents first -- e.g. NEW /a BEFORE NEW /a/b dest_trie:accum_first_parents_of(action.url, ret) -- Process remove path before creating new path -- e.g. DELETE /a BEFORE NEW /a src_trie:accum_actions_at(action.url, ret, function(a) return a.type == "move" or a.type == "delete" end) elseif action.type == "change" then -- Finish operating on parents first -- e.g. NEW /a BEFORE CHANGE /a/b dest_trie:accum_first_parents_of(action.url, ret) -- Finish operations on this path first -- e.g. NEW /a BEFORE CHANGE /a dest_trie:accum_actions_at(action.url, ret) -- Finish copy from operations first -- e.g. COPY /a -> /b BEFORE CHANGE /a src_trie:accum_actions_at(action.url, ret, function(entry) return entry.type == "copy" end) elseif action.type == "move" then -- Finish operating on parents first -- e.g. NEW /a BEFORE MOVE /z -> /a/b dest_trie:accum_first_parents_of(action.dest_url, ret) -- Process children before moving -- e.g. NEW /a/b BEFORE MOVE /a -> /b dest_trie:accum_children_of(action.src_url, ret) -- Copy children before moving parent dir -- e.g. COPY /a/b -> /b BEFORE MOVE /a -> /d src_trie:accum_children_of(action.src_url, ret, function(a) return a.type == "copy" end) -- Process remove path before moving to new path -- e.g. MOVE /a -> /b BEFORE MOVE /c -> /a src_trie:accum_actions_at(action.dest_url, ret, function(a) return a.type == "move" or a.type == "delete" end) elseif action.type == "copy" then -- Finish operating on parents first -- e.g. NEW /a BEFORE COPY /z -> /a/b dest_trie:accum_first_parents_of(action.dest_url, ret) -- Process children before copying -- e.g. NEW /a/b BEFORE COPY /a -> /b dest_trie:accum_children_of(action.src_url, ret) -- Process remove path before copying to new path -- e.g. MOVE /a -> /b BEFORE COPY /c -> /a src_trie:accum_actions_at(action.dest_url, ret, function(a) return a.type == "move" or a.type == "delete" end) end return ret end ---@return nil|oil.Action The leaf action ---@return nil|oil.Action When no leaves found, this is the last action in the loop local function find_leaf(action, seen) if not seen then seen = {} elseif seen[action] then return nil, action end seen[action] = true local deps = get_deps(action) if vim.tbl_isempty(deps) then return action end local action_in_loop for _, dep in ipairs(deps) do local leaf, loop_action = find_leaf(dep, seen) if leaf then return leaf elseif not action_in_loop and loop_action then action_in_loop = loop_action end end return nil, action_in_loop end local ret = {} local after = {} while not vim.tbl_isempty(actions) do local action = actions[1] local selected, loop_action = find_leaf(action) local to_remove if selected then to_remove = selected else if loop_action and loop_action.type == "move" then -- If this is moving a parent into itself, that's an error if vim.startswith(loop_action.dest_url, loop_action.src_url) then error("Detected cycle in desired paths") end -- We've detected a move cycle (e.g. MOVE /a -> /b + MOVE /b -> /a) -- Split one of the moves and retry local intermediate_url = string.format("%s__oil_tmp_%05d", loop_action.src_url, math.random(999999)) local move_1 = { type = "move", entry_type = loop_action.entry_type, src_url = loop_action.src_url, dest_url = intermediate_url, } local move_2 = { type = "move", entry_type = loop_action.entry_type, src_url = intermediate_url, dest_url = loop_action.dest_url, } to_remove = loop_action table.insert(actions, move_1) table.insert(after, move_2) dest_trie:insert_action(move_1.dest_url, move_1) src_trie:insert_action(move_1.src_url, move_1) else error("Detected cycle in desired paths") end end if selected then if selected.type == "move" or selected.type == "copy" then if vim.startswith(selected.dest_url, selected.src_url .. "/") then error( string.format( "Cannot move or copy parent into itself: %s -> %s", selected.src_url, selected.dest_url ) ) end end table.insert(ret, selected) end if to_remove then if to_remove.type == "delete" or to_remove.type == "change" then src_trie:remove_action(to_remove.url, to_remove) elseif to_remove.type == "create" then dest_trie:remove_action(to_remove.url, to_remove) else dest_trie:remove_action(to_remove.dest_url, to_remove) src_trie:remove_action(to_remove.src_url, to_remove) end for i, a in ipairs(actions) do if a == to_remove then table.remove(actions, i) break end end end end vim.list_extend(ret, after) return ret end ---@param actions oil.Action[] ---@param cb fun(err: nil|string) M.process_actions = function(actions, cb) -- convert delete actions to move-to-trash local trash_url = config.get_trash_url() if trash_url then for i, v in ipairs(actions) do if v.type == "delete" then local scheme, path = util.parse_url(v.url) if config.adapters[scheme] == "files" then assert(path) ---@type oil.MoveAction local move_action = { type = "move", src_url = v.url, entry_type = v.entry_type, dest_url = trash_url .. "/" .. pathutil.basename(path) .. string.format( "_%06d", math.random(999999) ), } actions[i] = move_action end end end end -- Convert cross-adapter moves to a copy + delete for _, action in ipairs(actions) do if action.type == "move" then local src_scheme = util.parse_url(action.src_url) local dest_scheme = util.parse_url(action.dest_url) if src_scheme ~= dest_scheme then action.type = "copy" table.insert(actions, { type = "delete", url = action.src_url, entry_type = action.entry_type, }) end end end local finished = false local progress = Progress.new() local function finish(...) if not finished then finished = true progress:close() cb(...) end end -- Defer showing the progress to avoid flicker for fast operations vim.defer_fn(function() if not finished then progress:show({ -- TODO some actions are actually cancelable. -- We should stop them instead of stopping after the current action cancel = function() finish("Canceled") end, }) end end, 100) local idx = 1 local next_action next_action = function() if finished then return end if idx > #actions then finish() return end local action = actions[idx] progress:set_action(action, idx, #actions) idx = idx + 1 local ok, adapter = pcall(util.get_adapter_for_action, action) if not ok then return finish(adapter) end local callback = vim.schedule_wrap(function(err) if finished then -- This can happen if the user canceled out of the progress window return elseif err then finish(err) else cache.perform_action(action) next_action() end end) if action.type == "change" then ---@cast action oil.ChangeAction columns.perform_change_action(adapter, action, callback) else adapter.perform_action(action, callback) end end next_action() end local mutation_in_progress = false ---@param confirm nil|boolean M.try_write_changes = function(confirm) if mutation_in_progress then error("Cannot perform mutation when already in progress") return end local current_buf = vim.api.nvim_get_current_buf() local was_modified = vim.bo.modified local buffers = view.get_all_buffers() local all_diffs = {} local all_errors = {} mutation_in_progress = true -- Lock the buffer to prevent race conditions from the user modifying them during parsing view.lock_buffers() for _, bufnr in ipairs(buffers) do if vim.bo[bufnr].modified then local diffs, errors = parser.parse(bufnr) all_diffs[bufnr] = diffs if not vim.tbl_isempty(errors) then all_errors[bufnr] = errors end end end local function unlock() view.unlock_buffers() -- The ":write" will set nomodified even if we cancel here, so we need to restore it if was_modified then vim.bo[current_buf].modified = true end mutation_in_progress = false end local ns = vim.api.nvim_create_namespace("Oil") vim.diagnostic.reset(ns) if not vim.tbl_isempty(all_errors) then vim.notify("Error parsing oil buffers", vim.log.levels.ERROR) for bufnr, errors in pairs(all_errors) do vim.diagnostic.set(ns, bufnr, errors) end -- Jump to an error local curbuf = vim.api.nvim_get_current_buf() if all_errors[curbuf] then pcall( vim.api.nvim_win_set_cursor, 0, { all_errors[curbuf][1].lnum + 1, all_errors[curbuf][1].col } ) else ---@diagnostic disable-next-line: param-type-mismatch local bufnr, errs = next(pairs(all_errors)) vim.api.nvim_win_set_buf(0, bufnr) pcall(vim.api.nvim_win_set_cursor, 0, { errs[1].lnum + 1, errs[1].col }) end return unlock() end local actions = M.create_actions_from_diffs(all_diffs) preview.show(actions, confirm, function(proceed) if not proceed then return unlock() end M.process_actions( actions, vim.schedule_wrap(function(err) view.unlock_buffers() if err then vim.notify(string.format("[oil] Error applying actions: %s", err), vim.log.levels.ERROR) view.rerender_all_oil_buffers({ preserve_undo = false }) else local current_entry = oil.get_cursor_entry() if current_entry then -- get the entry under the cursor and make sure the cursor stays on it view.set_last_cursor( vim.api.nvim_buf_get_name(0), vim.split(current_entry.parsed_name or current_entry.name, "/")[1] ) end view.rerender_all_oil_buffers({ preserve_undo = M.trash }) end mutation_in_progress = false end) ) end) end return M