local cache = require("oil.cache") local columns = require("oil.columns") local config = require("oil.config") local disclaimer = require("oil.mutator.disclaimer") 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 FIELD = require("oil.constants").FIELD local M = {} ---@alias oil.Action oil.CreateAction|oil.DeleteAction|oil.MoveAction|oil.CopyAction|oil.ChangeAction ---@class oil.CreateAction ---@field type "create" ---@field url string ---@field entry_type oil.EntryType ---@field link nil|string ---@class oil.DeleteAction ---@field type "delete" ---@field url string ---@field entry_type oil.EntryType ---@class oil.MoveAction ---@field type "move" ---@field entry_type oil.EntryType ---@field src_url string ---@field dest_url string ---@class oil.CopyAction ---@field type "copy" ---@field entry_type oil.EntryType ---@field src_url string ---@field dest_url string ---@class 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 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. local function get_deps(action) local ret = {} if action.type == "delete" then return 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 actions[i] = { type = "move", src_url = v.url, entry_type = v.entry_type, dest_url = trash_url .. "/" .. pathutil.basename(path) .. string.format( "_%06d", math.random(999999) ), } 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 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 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) -- TODO(2023-06-01) If no one has reported data loss by this time, we can remove the disclaimer disclaimer.show(function(disclaimed) if not disclaimed then return unlock() end 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.name, "/")[1] ) end view.rerender_all_oil_buffers({ preserve_undo = M.trash }) end mutation_in_progress = false end) ) end) end) end return M