feat: first draft
This commit is contained in:
parent
bf2dfb970d
commit
fefd6ad5e4
48 changed files with 7201 additions and 1 deletions
71
lua/oil/mutator/disclaimer.lua
Normal file
71
lua/oil/mutator/disclaimer.lua
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
local fs = require("oil.fs")
|
||||
local ReplLayout = require("oil.repl_layout")
|
||||
local M = {}
|
||||
|
||||
M.show = function(callback)
|
||||
local marker_file = fs.join(vim.fn.stdpath("cache"), ".oil_accepted_disclaimer")
|
||||
vim.loop.fs_stat(
|
||||
marker_file,
|
||||
vim.schedule_wrap(function(err, stat)
|
||||
if stat and stat.type and not err then
|
||||
callback(true)
|
||||
return
|
||||
end
|
||||
|
||||
local confirmation = "I understand this may destroy my files"
|
||||
local lines = {
|
||||
"WARNING",
|
||||
"This plugin has been tested thoroughly, but it is still new.",
|
||||
"There is a chance that there may be bugs that could lead to data loss.",
|
||||
"I recommend that you ONLY use it for files that are checked in to version control.",
|
||||
"",
|
||||
string.format('Please type: "%s" below', confirmation),
|
||||
"",
|
||||
}
|
||||
local hints = {
|
||||
"Try again",
|
||||
"Not quite!",
|
||||
"It's right there ^^^^^^^^^^^",
|
||||
"...seriously?",
|
||||
"Just type this ^^^^",
|
||||
}
|
||||
local attempt = 0
|
||||
local repl
|
||||
repl = ReplLayout.new({
|
||||
lines = lines,
|
||||
on_submit = function(line)
|
||||
if line:upper() ~= confirmation:upper() then
|
||||
attempt = attempt % #hints + 1
|
||||
vim.api.nvim_buf_set_lines(repl.input_bufnr, 0, -1, true, {})
|
||||
vim.bo[repl.view_bufnr].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(repl.view_bufnr, 6, 7, true, { hints[attempt] })
|
||||
vim.bo[repl.view_bufnr].modifiable = false
|
||||
vim.bo[repl.view_bufnr].modified = false
|
||||
else
|
||||
fs.mkdirp(vim.fn.fnamemodify(marker_file, ":h"))
|
||||
fs.touch(
|
||||
marker_file,
|
||||
vim.schedule_wrap(function(err2)
|
||||
if err2 then
|
||||
vim.notify(
|
||||
string.format("Error recording response: %s", err2),
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
end
|
||||
callback(true)
|
||||
repl:close()
|
||||
end)
|
||||
)
|
||||
end
|
||||
end,
|
||||
on_cancel = function()
|
||||
callback(false)
|
||||
end,
|
||||
})
|
||||
local ns = vim.api.nvim_create_namespace("Oil")
|
||||
vim.api.nvim_buf_add_highlight(repl.view_bufnr, ns, "DiagnosticError", 0, 0, -1)
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
return M
|
||||
507
lua/oil/mutator/init.lua
Normal file
507
lua/oil/mutator/init.lua
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
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<integer, oil.Diff[]>
|
||||
---@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()
|
||||
-- Defer showing the progress to avoid flicker for fast operations
|
||||
vim.defer_fn(function()
|
||||
if not finished then
|
||||
progress:show()
|
||||
end
|
||||
end, 100)
|
||||
|
||||
local function finish(...)
|
||||
finished = true
|
||||
progress:close()
|
||||
cb(...)
|
||||
end
|
||||
|
||||
local idx = 1
|
||||
local next_action
|
||||
next_action = function()
|
||||
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 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
|
||||
|
||||
---@param confirm nil|boolean
|
||||
M.try_write_changes = function(confirm)
|
||||
local buffers = view.get_all_buffers()
|
||||
local all_diffs = {}
|
||||
local all_errors = {}
|
||||
for _, bufnr in ipairs(buffers) do
|
||||
-- Lock the buffer to prevent race conditions
|
||||
vim.bo[bufnr].modifiable = false
|
||||
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 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
|
||||
end
|
||||
|
||||
local actions = M.create_actions_from_diffs(all_diffs)
|
||||
disclaimer.show(function(disclaimed)
|
||||
if not disclaimed then
|
||||
return
|
||||
end
|
||||
preview.show(actions, confirm, function(proceed)
|
||||
if not proceed then
|
||||
return
|
||||
end
|
||||
|
||||
M.process_actions(
|
||||
actions,
|
||||
vim.schedule_wrap(function(err)
|
||||
if err then
|
||||
vim.notify(string.format("[oil] Error applying actions: %s", err), vim.log.levels.ERROR)
|
||||
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_visible_and_cleanup({ preserve_undo = M.trash })
|
||||
end
|
||||
end)
|
||||
)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
225
lua/oil/mutator/parser.lua
Normal file
225
lua/oil/mutator/parser.lua
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
local cache = require("oil.cache")
|
||||
local columns = require("oil.columns")
|
||||
local util = require("oil.util")
|
||||
local view = require("oil.view")
|
||||
local FIELD = require("oil.constants").FIELD
|
||||
local M = {}
|
||||
|
||||
---@alias oil.Diff oil.DiffNew|oil.DiffDelete|oil.DiffChange
|
||||
|
||||
---@class oil.DiffNew
|
||||
---@field type "new"
|
||||
---@field name string
|
||||
---@field entry_type oil.EntryType
|
||||
---@field id nil|integer
|
||||
---@field link nil|string
|
||||
|
||||
---@class oil.DiffDelete
|
||||
---@field type "delete"
|
||||
---@field name string
|
||||
---@field id integer
|
||||
---
|
||||
---@class oil.DiffChange
|
||||
---@field type "change"
|
||||
---@field entry_type oil.EntryType
|
||||
---@field name string
|
||||
---@field column string
|
||||
---@field value any
|
||||
|
||||
---@param name string
|
||||
---@return string
|
||||
---@return boolean
|
||||
local function parsedir(name)
|
||||
local isdir = vim.endswith(name, "/")
|
||||
if isdir then
|
||||
name = name:sub(1, name:len() - 1)
|
||||
end
|
||||
return name, isdir
|
||||
end
|
||||
|
||||
---Parse a single line in a buffer
|
||||
---@param adapter oil.Adapter
|
||||
---@param line string
|
||||
---@param column_defs oil.ColumnSpec[]
|
||||
---@return table
|
||||
---@return nil|oil.InternalEntry
|
||||
M.parse_line = function(adapter, line, column_defs)
|
||||
local ret = {}
|
||||
local value, rem = line:match("^/(%d+) (.+)$")
|
||||
if not value then
|
||||
return nil, nil, "Malformed ID at start of line"
|
||||
end
|
||||
ret.id = tonumber(value)
|
||||
for _, def in ipairs(column_defs) do
|
||||
local name = util.split_config(def)
|
||||
value, rem = columns.parse_col(adapter, rem, def)
|
||||
if not value then
|
||||
return nil, nil, string.format("Parsing %s failed", name)
|
||||
end
|
||||
ret[name] = value
|
||||
end
|
||||
local name = rem
|
||||
if name then
|
||||
local isdir
|
||||
name, isdir = parsedir(vim.trim(name))
|
||||
if name ~= "" then
|
||||
ret.name = name
|
||||
end
|
||||
ret._type = isdir and "directory" or "file"
|
||||
end
|
||||
local entry = cache.get_entry_by_id(ret.id)
|
||||
if not entry then
|
||||
return ret
|
||||
end
|
||||
|
||||
-- Parse the symlink syntax
|
||||
local meta = entry[FIELD.meta]
|
||||
local entry_type = entry[FIELD.type]
|
||||
if entry_type == "link" and meta and meta.link then
|
||||
local name_pieces = vim.split(ret.name, " -> ", { plain = true })
|
||||
if #name_pieces ~= 2 then
|
||||
ret.name = ""
|
||||
return ret
|
||||
end
|
||||
ret.name = parsedir(vim.trim(name_pieces[1]))
|
||||
ret.link_target = name_pieces[2]
|
||||
ret._type = "link"
|
||||
end
|
||||
|
||||
-- Try to keep the same file type
|
||||
if entry_type ~= "directory" and entry_type ~= "file" and ret._type ~= "directory" then
|
||||
ret._type = entry[FIELD.type]
|
||||
end
|
||||
|
||||
return ret, entry
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return oil.Diff[]
|
||||
---@return table[] Parsing errors
|
||||
M.parse = function(bufnr)
|
||||
local diffs = {}
|
||||
local errors = {}
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local adapter = util.get_adapter(bufnr)
|
||||
if not adapter then
|
||||
table.insert(errors, {
|
||||
lnum = 1,
|
||||
col = 0,
|
||||
message = string.format("Cannot parse buffer '%s': No adapter", bufname),
|
||||
})
|
||||
return diffs, errors
|
||||
end
|
||||
local scheme, path = util.parse_url(bufname)
|
||||
local column_defs = columns.get_supported_columns(scheme)
|
||||
local parent_url = scheme .. path
|
||||
local children = cache.list_url(parent_url)
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)
|
||||
local original_entries = {}
|
||||
for _, child in pairs(children) do
|
||||
if view.should_display(child) then
|
||||
original_entries[child[FIELD.name]] = child[FIELD.id]
|
||||
end
|
||||
end
|
||||
for i, line in ipairs(lines) do
|
||||
if line:match("^/%d+") then
|
||||
local parsed_entry, entry, err = M.parse_line(adapter, line, column_defs)
|
||||
if err then
|
||||
table.insert(errors, {
|
||||
message = err,
|
||||
lnum = i - 1,
|
||||
col = 0,
|
||||
})
|
||||
goto continue
|
||||
end
|
||||
if not parsed_entry.name or parsed_entry.name:match("/") or not entry then
|
||||
local message
|
||||
if not parsed_entry.name then
|
||||
message = "No filename found"
|
||||
elseif not entry then
|
||||
message = "Could not find existing entry (was the ID changed?)"
|
||||
else
|
||||
message = "Filename cannot contain '/'"
|
||||
end
|
||||
table.insert(errors, {
|
||||
message = message,
|
||||
lnum = i,
|
||||
col = 0,
|
||||
})
|
||||
goto continue
|
||||
end
|
||||
local meta = entry[FIELD.meta]
|
||||
if original_entries[parsed_entry.name] == parsed_entry.id then
|
||||
if entry[FIELD.type] == "link" and (not meta or meta.link ~= parsed_entry.link_target) then
|
||||
table.insert(diffs, {
|
||||
type = "new",
|
||||
name = parsed_entry.name,
|
||||
entry_type = "link",
|
||||
link = parsed_entry.link_target,
|
||||
})
|
||||
else
|
||||
original_entries[parsed_entry.name] = nil
|
||||
end
|
||||
else
|
||||
table.insert(diffs, {
|
||||
type = "new",
|
||||
name = parsed_entry.name,
|
||||
entry_type = parsed_entry._type,
|
||||
id = parsed_entry.id,
|
||||
link = parsed_entry.link_target,
|
||||
})
|
||||
end
|
||||
|
||||
for _, col_def in ipairs(column_defs) do
|
||||
local col_name = util.split_config(col_def)
|
||||
if columns.compare(adapter, col_name, entry, parsed_entry[col_name]) then
|
||||
table.insert(diffs, {
|
||||
type = "change",
|
||||
name = parsed_entry.name,
|
||||
entry_type = entry[FIELD.type],
|
||||
column = col_name,
|
||||
value = parsed_entry[col_name],
|
||||
})
|
||||
end
|
||||
end
|
||||
else
|
||||
local name, isdir = parsedir(vim.trim(line))
|
||||
if vim.startswith(name, "/") then
|
||||
table.insert(errors, {
|
||||
message = "Paths cannot start with '/'",
|
||||
lnum = i,
|
||||
col = 0,
|
||||
})
|
||||
goto continue
|
||||
end
|
||||
if name ~= "" then
|
||||
local link_pieces = vim.split(name, " -> ", { plain = true })
|
||||
local entry_type = isdir and "directory" or "file"
|
||||
local link
|
||||
if #link_pieces == 2 then
|
||||
entry_type = "link"
|
||||
name, link = unpack(link_pieces)
|
||||
end
|
||||
table.insert(diffs, {
|
||||
type = "new",
|
||||
name = name,
|
||||
entry_type = entry_type,
|
||||
link = link,
|
||||
})
|
||||
end
|
||||
end
|
||||
::continue::
|
||||
end
|
||||
|
||||
for name, child_id in pairs(original_entries) do
|
||||
table.insert(diffs, {
|
||||
type = "delete",
|
||||
name = name,
|
||||
id = child_id,
|
||||
})
|
||||
end
|
||||
|
||||
return diffs, errors
|
||||
end
|
||||
|
||||
return M
|
||||
130
lua/oil/mutator/preview.lua
Normal file
130
lua/oil/mutator/preview.lua
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
local columns = require("oil.columns")
|
||||
local config = require("oil.config")
|
||||
local util = require("oil.util")
|
||||
local M = {}
|
||||
|
||||
---@param actions oil.Action[]
|
||||
---@return boolean
|
||||
local function is_simple_edit(actions)
|
||||
local num_create = 0
|
||||
local num_copy = 0
|
||||
local num_move = 0
|
||||
for _, action in ipairs(actions) do
|
||||
-- If there are any deletes, it is not a simple edit
|
||||
if action.type == "delete" then
|
||||
return false
|
||||
elseif action.type == "create" then
|
||||
num_create = num_create + 1
|
||||
elseif action.type == "copy" then
|
||||
num_copy = num_copy + 1
|
||||
-- Cross-adapter copies are not simple
|
||||
if util.parse_url(action.src_url) ~= util.parse_url(action.dest_url) then
|
||||
return false
|
||||
end
|
||||
elseif action.type == "move" then
|
||||
num_move = num_move + 1
|
||||
-- Cross-adapter moves are not simple
|
||||
if util.parse_url(action.src_url) ~= util.parse_url(action.dest_url) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
-- More than one move/copy is complex
|
||||
if num_move > 1 or num_copy > 1 then
|
||||
return false
|
||||
end
|
||||
-- More than 5 creates is complex
|
||||
if num_create > 5 then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
---@param actions oil.Action[]
|
||||
---@param should_confirm nil|boolean
|
||||
---@param cb fun(proceed: boolean)
|
||||
M.show = vim.schedule_wrap(function(actions, should_confirm, cb)
|
||||
if should_confirm == false then
|
||||
cb(true)
|
||||
return
|
||||
end
|
||||
if should_confirm == nil and config.skip_confirm_for_simple_edits and is_simple_edit(actions) then
|
||||
cb(true)
|
||||
return
|
||||
end
|
||||
-- The schedule wrap ensures that we actually enter the floating window.
|
||||
-- Not sure why it doesn't work without that
|
||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[bufnr].bufhidden = "wipe"
|
||||
local width = 120
|
||||
local height = 40
|
||||
local winid = vim.api.nvim_open_win(bufnr, true, {
|
||||
relative = "editor",
|
||||
width = width,
|
||||
height = height,
|
||||
row = math.floor((vim.o.lines - vim.o.cmdheight - height) / 2),
|
||||
col = math.floor((vim.o.columns - width) / 2),
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
})
|
||||
vim.bo[bufnr].syntax = "oil_preview"
|
||||
|
||||
local lines = {}
|
||||
for _, action in ipairs(actions) do
|
||||
local adapter = util.get_adapter_for_action(action)
|
||||
if action.type == "change" then
|
||||
table.insert(lines, columns.render_change_action(adapter, action))
|
||||
else
|
||||
table.insert(lines, adapter.render_action(action))
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
width = vim.api.nvim_win_get_width(0)
|
||||
local last_line = "[O]k [C]ancel"
|
||||
local highlights = {}
|
||||
local padding = string.rep(" ", math.floor((width - last_line:len()) / 2))
|
||||
last_line = padding .. last_line
|
||||
table.insert(highlights, { "Special", #lines, padding:len(), padding:len() + 3 })
|
||||
table.insert(highlights, { "Special", #lines, padding:len() + 8, padding:len() + 11 })
|
||||
table.insert(lines, last_line)
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines)
|
||||
vim.bo[bufnr].modified = false
|
||||
vim.bo[bufnr].modifiable = false
|
||||
local ns = vim.api.nvim_create_namespace("Oil")
|
||||
for _, hl in ipairs(highlights) do
|
||||
vim.api.nvim_buf_add_highlight(bufnr, ns, unpack(hl))
|
||||
end
|
||||
|
||||
local cancel
|
||||
local confirm
|
||||
local function make_callback(value)
|
||||
return function()
|
||||
confirm = function() end
|
||||
cancel = function() end
|
||||
vim.api.nvim_win_close(winid, true)
|
||||
cb(value)
|
||||
end
|
||||
end
|
||||
cancel = make_callback(false)
|
||||
confirm = make_callback(true)
|
||||
vim.api.nvim_create_autocmd("BufLeave", {
|
||||
callback = cancel,
|
||||
once = true,
|
||||
nested = true,
|
||||
buffer = bufnr,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("WinLeave", {
|
||||
callback = cancel,
|
||||
once = true,
|
||||
nested = true,
|
||||
})
|
||||
vim.keymap.set("n", "q", cancel, { buffer = bufnr })
|
||||
vim.keymap.set("n", "C", cancel, { buffer = bufnr })
|
||||
vim.keymap.set("n", "c", cancel, { buffer = bufnr })
|
||||
vim.keymap.set("n", "<Esc>", cancel, { buffer = bufnr })
|
||||
vim.keymap.set("n", "<C-c>", cancel, { buffer = bufnr })
|
||||
vim.keymap.set("n", "O", confirm, { buffer = bufnr })
|
||||
vim.keymap.set("n", "o", confirm, { buffer = bufnr })
|
||||
end)
|
||||
|
||||
return M
|
||||
77
lua/oil/mutator/progress.lua
Normal file
77
lua/oil/mutator/progress.lua
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
local columns = require("oil.columns")
|
||||
local loading = require("oil.loading")
|
||||
local util = require("oil.util")
|
||||
local Progress = {}
|
||||
|
||||
local FPS = 20
|
||||
|
||||
function Progress.new()
|
||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[bufnr].bufhidden = "wipe"
|
||||
return setmetatable({
|
||||
lines = { "", "", "" },
|
||||
bufnr = bufnr,
|
||||
}, {
|
||||
__index = Progress,
|
||||
})
|
||||
end
|
||||
|
||||
function Progress:show()
|
||||
if self.winid and vim.api.nvim_win_is_valid(self.winid) then
|
||||
return
|
||||
end
|
||||
local loading_iter = loading.get_bar_iter()
|
||||
self.timer = vim.loop.new_timer()
|
||||
self.timer:start(
|
||||
0,
|
||||
math.floor(1000 / FPS),
|
||||
vim.schedule_wrap(function()
|
||||
self.lines[2] = loading_iter()
|
||||
self:_render()
|
||||
end)
|
||||
)
|
||||
local width = 120
|
||||
local height = 10
|
||||
self.winid = vim.api.nvim_open_win(self.bufnr, true, {
|
||||
relative = "editor",
|
||||
width = width,
|
||||
height = height,
|
||||
row = math.floor((vim.o.lines - vim.o.cmdheight - height) / 2),
|
||||
col = math.floor((vim.o.columns - width) / 2),
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
})
|
||||
end
|
||||
|
||||
function Progress:_render()
|
||||
util.render_centered_text(self.bufnr, self.lines)
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@param idx integer
|
||||
---@param total integer
|
||||
function Progress:set_action(action, idx, total)
|
||||
local adapter = util.get_adapter_for_action(action)
|
||||
local change_line
|
||||
if action.type == "change" then
|
||||
change_line = columns.render_change_action(adapter, action)
|
||||
else
|
||||
change_line = adapter.render_action(action)
|
||||
end
|
||||
self.lines[1] = change_line
|
||||
self.lines[3] = string.format("[%d/%d]", idx, total)
|
||||
self:_render()
|
||||
end
|
||||
|
||||
function Progress:close()
|
||||
if self.timer then
|
||||
self.timer:close()
|
||||
self.timer = nil
|
||||
end
|
||||
if self.winid then
|
||||
vim.api.nvim_win_close(self.winid, true)
|
||||
self.winid = nil
|
||||
end
|
||||
end
|
||||
|
||||
return Progress
|
||||
153
lua/oil/mutator/trie.lua
Normal file
153
lua/oil/mutator/trie.lua
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
local util = require("oil.util")
|
||||
local Trie = {}
|
||||
|
||||
Trie.new = function()
|
||||
return setmetatable({
|
||||
root = { values = {}, children = {} },
|
||||
}, {
|
||||
__index = Trie,
|
||||
})
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@return string[]
|
||||
function Trie:_url_to_path_pieces(url)
|
||||
local scheme, path = util.parse_url(url)
|
||||
local pieces = vim.split(path, "/")
|
||||
table.insert(pieces, 1, scheme)
|
||||
return pieces
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param value any
|
||||
function Trie:insert_action(url, value)
|
||||
local pieces = self:_url_to_path_pieces(url)
|
||||
self:insert(pieces, value)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param value any
|
||||
function Trie:remove_action(url, value)
|
||||
local pieces = self:_url_to_path_pieces(url)
|
||||
self:remove(pieces, value)
|
||||
end
|
||||
|
||||
---@param path_pieces string[]
|
||||
---@param value any
|
||||
function Trie:insert(path_pieces, value)
|
||||
local current = self.root
|
||||
for _, piece in ipairs(path_pieces) do
|
||||
local next_container = current.children[piece]
|
||||
if not next_container then
|
||||
next_container = { values = {}, children = {} }
|
||||
current.children[piece] = next_container
|
||||
end
|
||||
current = next_container
|
||||
end
|
||||
table.insert(current.values, value)
|
||||
end
|
||||
|
||||
---@param path_pieces string[]
|
||||
---@param value any
|
||||
function Trie:remove(path_pieces, value)
|
||||
local current = self.root
|
||||
for _, piece in ipairs(path_pieces) do
|
||||
local next_container = current.children[piece]
|
||||
if not next_container then
|
||||
next_container = { values = {}, children = {} }
|
||||
current.children[piece] = next_container
|
||||
end
|
||||
current = next_container
|
||||
end
|
||||
for i, v in ipairs(current.values) do
|
||||
if v == value then
|
||||
table.remove(current.values, i)
|
||||
-- if vim.tbl_isempty(current.values) and vim.tbl_isempty(current.children) then
|
||||
-- TODO remove container from trie
|
||||
-- end
|
||||
return
|
||||
end
|
||||
end
|
||||
error("Value not present in trie: " .. vim.inspect(value))
|
||||
end
|
||||
|
||||
---Add the first action that affects a parent path of the url
|
||||
---@param url string
|
||||
---@param ret oil.InternalEntry[]
|
||||
function Trie:accum_first_parents_of(url, ret)
|
||||
local pieces = self:_url_to_path_pieces(url)
|
||||
local containers = { self.root }
|
||||
for _, piece in ipairs(pieces) do
|
||||
local next_container = containers[#containers].children[piece]
|
||||
table.insert(containers, next_container)
|
||||
end
|
||||
table.remove(containers)
|
||||
while not vim.tbl_isempty(containers) do
|
||||
local container = containers[#containers]
|
||||
if not vim.tbl_isempty(container.values) then
|
||||
vim.list_extend(ret, container.values)
|
||||
break
|
||||
end
|
||||
table.remove(containers)
|
||||
end
|
||||
end
|
||||
|
||||
---Do a depth-first-search and add all children matching the filter
|
||||
function Trie:_dfs(container, ret, filter)
|
||||
if filter then
|
||||
for _, action in ipairs(container.values) do
|
||||
if filter(action) then
|
||||
table.insert(ret, action)
|
||||
end
|
||||
end
|
||||
else
|
||||
vim.list_extend(ret, container.values)
|
||||
end
|
||||
for _, child in ipairs(container.children) do
|
||||
self:_dfs(child, ret)
|
||||
end
|
||||
end
|
||||
|
||||
---Add all actions affecting children of the url
|
||||
---@param url string
|
||||
---@param ret oil.InternalEntry[]
|
||||
---@param filter nil|fun(entry: oil.InternalEntry): boolean
|
||||
function Trie:accum_children_of(url, ret, filter)
|
||||
local pieces = self:_url_to_path_pieces(url)
|
||||
local current = self.root
|
||||
for _, piece in ipairs(pieces) do
|
||||
current = current.children[piece]
|
||||
if not current then
|
||||
return
|
||||
end
|
||||
end
|
||||
if current then
|
||||
for _, child in pairs(current.children) do
|
||||
self:_dfs(child, ret, filter)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Add all actions at a specific path
|
||||
---@param url string
|
||||
---@param ret oil.InternalEntry[]
|
||||
---@param filter nil|fun(entry: oil.InternalEntry): boolean
|
||||
function Trie:accum_actions_at(url, ret, filter)
|
||||
local pieces = self:_url_to_path_pieces(url)
|
||||
local current = self.root
|
||||
for _, piece in ipairs(pieces) do
|
||||
current = current.children[piece]
|
||||
if not current then
|
||||
return
|
||||
end
|
||||
end
|
||||
if current then
|
||||
for _, action in ipairs(current.values) do
|
||||
if not filter or filter(action) then
|
||||
table.insert(ret, action)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return Trie
|
||||
Loading…
Add table
Add a link
Reference in a new issue