Newer
Older
dotfiles / .config / lite-xl / plugins / renamer.lua
--- mod-version:3

local core = require "core"
local DocView = require "core.docview"
local Doc = require "core.doc"
local command = require "core.command"
local keymap = require "core.keymap"
local common = require "core.common"
local style = require "core.style"

local coro_diff = require "libraries.coro_diff"

---Unscaled size used to align the original file names
local stop_size = 100

---@class RenamerDoc: core.doc
---@field super core.doc
local RenamerDoc = Doc:extend()

function RenamerDoc:new()
  RenamerDoc.super.new(self)
  self.initial_state = true
end

function RenamerDoc:save()
end

function RenamerDoc:raw_insert(line, col, text, ...)
  if self.initial_state then return end
  return RenamerDoc.super.raw_insert(self, line, col, text, ...)
end

function RenamerDoc:raw_remove(...)
  if self.initial_state then return end
  return RenamerDoc.super.raw_remove(self, ...)
end

---@class Renamer.file
---@field file_path string The full path of the file
---@field dir_path string The path of the directory that contains the file
---@field basename string The file name
---@field last_stop integer
---@field last_diff any? Diff cache
---@field last_file_path string? Diff cache key

---@class Renamer: core.docview
---@field super core.docview
---@field files Renamer.file[]
local Renamer = DocView:extend()

function Renamer:new()
  Renamer.super.new(self, RenamerDoc())
  self:reset()
end

function Renamer:reset()
  self.doc.initial_state = true
  self.doc:reset()
  self.last_change = self.doc:get_change_id()
  self.files = { }
  self.operations = { }
  self.errors = { }
  if self.showing_tooltip then
    core.status_view:remove_tooltip()
  end
  self.showing_tooltip = false
end

function Renamer:get_name()
  local post = self.doc:is_dirty() and "*" or ""
  local name = "Renamer"
  return name .. post
end

function Renamer:on_file_dropped(filename)
  self:add_file(filename)
  return true
end

function Renamer:add_file(filename)
  local basename = common.basename(filename)
  local path = assert(common.dirname(filename))
  for _, v in ipairs(self.files) do
    if v.file_path == filename then
      return false
    end
  end
  table.insert(self.files, {
    file_path = filename,
    dir_path = path,
    basename = basename,
    last_stop = -1,
    last_diff = nil,
    last_file_path = nil
  } --[[@as Renamer.file]])

  if self.doc.initial_state then
    self.doc.initial_state = false
    self.doc:insert(1, 1, basename)
  else
    self.doc:insert(#self.doc.lines, math.huge, "\n"..basename)
  end
  self.recalc_base_stop = true
  return true
end

function Renamer:on_mouse_moved(...)
  if self.doc.initial_state then
    self.cursor = "arrow"
  else
    Renamer.super.on_mouse_moved(self, ...)
  end
end

---Split string into a table of characters.
---@param s string
---@return string[]
local function get_utf8_chars(s)
  local result = { }
  for char in string.gmatch(s, utf8.charpattern) do
    table.insert(result, char)
  end
  return result
end


------ coro_diff definitions ----------

---@alias libraries.coro_diff.direction
---| '"+"' Added from `b`
---| '"-"' Removed from `a`
---| '"="' Same in `a` and `b`
---@alias libraries.coro_diff.solution_item {a_index: integer, a_len: integer, b_index: integer, b_len: integer, direction: libraries.coro_diff.direction, values: any[]}
---@alias libraries.coro_diff.solution libraries.coro_diff.solution_item[]

---------------------------------------


---@class Renamer.operation
---@field old_file_path string
---@field new_file_path string
---@field new_basename string
---@field diff libraries.coro_diff.solution

---@class Renamer.operation_error
---@field kind string
---@field message string

---Check if rename operations will have problems.
---@return table<integer, Renamer.operation> operations
---@return table<integer, Renamer.operation_error> errors
function Renamer:check()
  ---@type table<integer, Renamer.operation>
  local operations = { }
  ---@type table<string, integer>
  local file_path_indexes = { }
  ---@type table<integer, Renamer.operation_error>
  local errors = { }
  for i,f in ipairs(self.files) do
    ---@type Renamer.operation_error?
    local error_msg
    local new_file_path
    local new_basename = self.doc:get_text(i, 1, i, math.huge)
    if new_basename == "" then
      -- TODO: we could ask to delete files without names
      error_msg = {
        kind = "missing name",
        message = string.format("Specify a name for [%s].", self.files[i].basename)
      }
      goto continue
    end
    if new_basename:find(PATHSEP)
      or (PLATFORM == "Windows" and new_basename:find("/")) then -- On Windows always avoid /
      -- TODO: we could manage moving to new/different dirs
      error_msg = {
        kind = "invalid character",
        message = string.format("Don't use [%s].", PATHSEP)
      }
      goto continue
    end

    new_file_path = f.dir_path .. PATHSEP .. new_basename
    if f.basename ~= new_basename then
      if file_path_indexes[new_file_path] then
        error_msg = {
          kind = "conflict",
          message = string.format("This would overwrite the rename on line %d.", file_path_indexes[new_file_path])
        }
        errors[file_path_indexes[new_file_path]] = {
          kind = "conflict",
          message = string.format("This would overwrite the rename on line %d.", i)
        }
        goto continue
      elseif system.get_file_info(new_file_path) then
        -- Check if this conflicting file will be renamed in a previous operation
        local ok = false
        for _, op in pairs(operations) do
          if op and op.old_file_path == new_file_path and op.new_file_path ~= new_file_path then
            ok = true
            break
          end
        end
        if not ok then
          error_msg = {
            kind = "file already exists",
            message = string.format("This would overwrite an existing file.", i)
          }
          goto continue
        end
      end
    end
    ::continue::
    if error_msg then
    	errors[i] = error_msg
    elseif new_file_path ~= f.file_path then
      local solution
      if not f.last_diff or f.last_file_path ~= new_file_path then
        local differ_getter = coro_diff.get_diff(get_utf8_chars(f.basename), get_utf8_chars(new_basename))
        local done
        repeat
          done, solution = differ_getter(math.maxinteger)
        until done
        f.last_diff = solution
        f.last_file_path = new_file_path
      end

      file_path_indexes[new_file_path] = i
      operations[i] = {
        old_file_path = f.file_path,
        new_file_path = new_file_path,
        new_basename = new_basename,
        diff = f.last_diff
      }
    end
  end
  if #self.files > #self.doc.lines then
    errors[#self.doc.lines] = {
      kind = "missing lines",
      message = string.format("Some lines are missing.")
    }
  elseif #self.files < #self.doc.lines then
    errors[#self.doc.lines] = {
      kind = "too many lines",
      message = string.format("Too many lines.")
    }
  end
  return operations, errors
end

function Renamer:update(...)
  if not self.doc.initial_state and #self.files == 0 then
    self:reset()
  end
  if not self.doc.initial_state then
    local current_change_id = self.doc:get_change_id()
    if self.last_change ~= current_change_id then
      self.last_change = current_change_id
      self.operations, self.errors = self:check()
      if not next(self.operations) and not next(self.errors) then
        self.doc.clean_change_id = current_change_id
      end
    end

    -- Only recalc base stop offset when an entry changes the stop it belongs to
    if self.recalc_base_stop then
      self.recalc_base_stop = false
      local total_len = 0
      local max_len = 0
      for i, file in ipairs(self.files) do
        file.last_stop = -1
        local len = Renamer.super.get_col_x_offset(self, i, math.huge)
        max_len = math.max(max_len, len)
        total_len = total_len + len
      end
      self.base_stop_offset = math.min(total_len / #self.files + stop_size * SCALE, max_len)
    end

    if core.active_view == self then
      local keybind = (keymap.get_bindings("renamer:apply") or { })[1]
      core.status_view:show_tooltip(
          string.format([[Edit the file names, then run the command "%s"%s to apply the changes.]], command.prettify_name("renamer:apply"), keybind and " ["..keybind.."]" or "")
      )
      self.showing_tooltip = true
    elseif self.showing_tooltip then
      core.status_view:remove_tooltip()
      self.showing_tooltip = false
    end
  end
  Renamer.super.update(self, ...)
end

function Renamer:draw_line_gutter(line, x, y, width)
  local lh = Renamer.super.draw_line_gutter(self, line, x, y, width)
  local gw, gpad = self:get_gutter_width()
  local marker_width = 3 * SCALE
  local color
  if line > #self.files or not self.files[line] or self.errors[line] then
    color = style.error
  elseif self.doc:get_text(line, 1, line, math.huge) ~= self.files[line].basename then
    color = style.warn
  end
  if color then
    renderer.draw_rect(x + gw - (gpad / 2 + marker_width) / 2, y, marker_width, lh, color)
  end
  return lh
end


function Renamer:draw_line_text(line, x, y)
  local lh = self:get_line_height()
  if self.errors[line] or not self.operations[line] then
    Renamer.super.draw_line_text(self, line, x, y)
  end

  local x_off = Renamer.super.get_col_x_offset(self, line, math.huge)
  if self.errors[line] then
    renderer.draw_text(
      style.code_font,
      self.errors[line].message,
      x + x_off,
      y + self:get_line_text_y_offset(),
      style.warn
    )
  elseif self.operations[line] then
    local op = self.operations[line]

    local x2 = x + style.padding.x
    local stop = (x_off // (stop_size * SCALE) + 1)
    local avg_stop = (self.base_stop_offset // (stop_size * SCALE) + 1)
    x2 = x2 + math.max(stop, avg_stop) * (stop_size * SCALE)
    if stop ~= self.files[line].last_stop then
      if self.files[line].last_stop >= 0 then
      	self.recalc_base_stop = true
      end
      self.files[line].last_stop = stop
    end

    for _, d in ipairs(op.diff) do
      local char = table.concat(d.values)
      local color
      if d.direction == "-" then
        color = style.warn
        char = char:gsub(" ", "·")
      elseif d.direction == "+" then
        color = style.good
        char = char:gsub(" ", "·")
      end
      if d.direction ~= "-" then
        x = renderer.draw_text(style.code_font, char, x, y + self:get_line_text_y_offset(), color or style.syntax["normal"])
      end
      if d.direction ~= "+" then
        x2 = renderer.draw_text(style.code_font, char, x2, y + self:get_line_text_y_offset(), color or style.text)
      end
    end
  end
  return lh
end

function Renamer:draw(...)
  if self.doc.initial_state then
    local drop_text = { "Drop files", "to rename", "here" }
    local full_text = table.concat(drop_text, " ")
    local too_big = style.big_font:get_width(full_text) >= self.size.x
    local h = style.big_font:get_height()
    self:draw_background(style.background)
    if too_big then
      for i, part in ipairs(drop_text) do
        local offset_y = (i - ((#drop_text + 1) / 2)) * h
        common.draw_text(style.big_font, style.accent, part, "center", self.position.x, self.position.y + offset_y, self.size.x, self.size.y)
      end
    else
      common.draw_text(style.big_font, style.accent, full_text, "center", self.position.x, self.position.y, self.size.x, self.size.y)
    end
  else
    Renamer.super.draw(self, ...)
  end
end

command.add(nil, {
  ["renamer:open-renamer"] = function()
    local node = core.root_view:get_active_node_default()
    local view = Renamer()
    node:add_view(view)
  end
})

---@param dry_run boolean?
function Renamer:apply(dry_run)
  if #self.doc.lines ~= #self.files then
    return core.error("Unable to apply renames. Mismatching number of lines.")
  end
  local operations, errors = { }, { }
  operations, errors = self:check()
  for index, error in pairs(errors) do
    return core.error("Unable to apply renames. Issue on line %d: %s", index, error.message)
  end

  self.last_change = -1
  local renamed = 0
  for i,f in ipairs(self.files) do
    local op = operations[i]
    if op then
      local src = op.old_file_path
      local dst = op.new_file_path
      if src ~= dst then
        core.log_quiet("Renaming [%s] to [%s].", src, dst)
        local ok, err
        if dry_run then
          ok, err = true, nil
        else
          ok, err = os.rename(src, dst)
        end
        if not ok then
          core.error("Failed to rename [%s] to [%s]. Message: %s", src, dst, err)
          return
        end
        renamed = renamed + 1
        f.file_path = dst
        f.basename = op.new_basename
      end
    end
  end
  core.log("Renamed %d entries.", renamed)
end

command.add(
  function()
    return core.active_view:extends(Renamer) and #core.active_view.files > 0, core.active_view
  end,
  {
    ["renamer:apply"] = function(renamer)
      ---@cast renamer Renamer
      renamer:apply()
    end,
    ["renamer:apply-dry-run"] = function(renamer)
      ---@cast renamer Renamer
      renamer:apply(true)
    end,
    ["renamer:reset"] = function(renamer)
      ---@cast renamer Renamer
      renamer:reset()
    end,
    ["renamer:restore-selected"] = function(renamer)
      ---@cast renamer Renamer
      for _, l1, _, l2, _ in renamer.doc:get_selections(true, true) do
        for i=l2,l1,-1 do
          -- Insert first to avoid issues
          renamer.doc:insert(i, 1, renamer.files[i].basename)
          renamer.doc:remove(i, #renamer.files[i].basename + 1, i, math.huge)
        end
      end
    end,
    ["renamer:restore-everything"] = function(renamer)
      ---@cast renamer Renamer
      local old_files = renamer.files
      renamer:reset()
      for _,file in ipairs(old_files) do
        renamer:add_file(file.file_path)
      end
    end,
    ["renamer:remove"] = function(renamer)
      ---@cast renamer Renamer
      for _, l1, _, l2, _ in renamer.doc:get_selections(true, true) do
        renamer.doc:remove(l1 - 1, math.huge, l2, math.huge)
        for i=l2,l1,-1 do
          table.remove(renamer.files, i)
        end
      end
    end
  }
)

keymap.add({
  ["ctrl+s"] = "renamer:apply"
})

local enumerate_regex = assert(regex.compile("^(\\d+)(?:,(\\d+))?(?:,(\\d+))?$"))
command.add(DocView, {
  ["renamer:enumerate"] = function(dv)
    core.command_view:enter("Specify [size[,initial index[,step]]] (e.g. 3,5 -> 005, 006, 007, ...)", {
      submit = function(text, _)
        local size, initial, step = regex.match(enumerate_regex, text)
        size = size and tonumber(size) or 1
        initial = initial and tonumber(initial) or 1
        step = step and tonumber(step) or 1
        for idx in dv.doc:get_selections() do
          local value = string.format("%0"..size.."d", initial)
          dv.doc:text_input(value, idx)
          initial = initial + step
        end
      end,
      validate = function(text, _)
        return regex.match(enumerate_regex, text) or text == ""
      end
    })
  end
})

-- Workaround needed because data/core/commands/findreplace.lua compares to DocView strictly
function Renamer:is(T)
  if T == DocView then return true end
  return self.super.is(self, T)
end

-- Workaround needed because Windows doesn't send the correct drop coordinates (see https://github.com/lite-xl/lite-xl/issues/1925)
if PLATFORM == "Windows" then
  local on_event = core.on_event
  local suspended_drops = { }
  function core.on_event(type, ...)
    if type == "mousemoved" then
      if #suspended_drops > 0 then
        local mx, my = ...
        for _,file in ipairs(suspended_drops) do
          on_event("filedropped", file, mx, my)
        end
        suspended_drops = { }
      end
    elseif type == "filedropped" then
      local file = ...
      table.insert(suspended_drops, file)
      return false
    end
    return on_event(type, ...)
  end
end

return Renamer