Files
deb-lite-xl/data/plugins/treeview.lua
T
Francesco AbbateandGuldoman 9155be7a22 Ensure TreeView cache entry is removed on delete
Address issue:

https://github.com/lite-xl/lite-xl/issues/689

Attempt to provide a more accurate fix to commit:

59f64088e1

For this latter what happens is that any change inside a directory
cause the corresponding entry to be folded in the TreeView.

The new change is more accurate because we remove only the stale
entry corresponding to the delete event and we do not reset the
cache of the parent directory using the modify event.
2021-12-22 23:40:54 +01:00

599 lines
18 KiB
Lua

-- mod-version:2 -- lite-xl 2.0
local core = require "core"
local common = require "core.common"
local command = require "core.command"
local config = require "core.config"
local keymap = require "core.keymap"
local style = require "core.style"
local View = require "core.view"
local ContextMenu = require "core.contextmenu"
local RootView = require "core.rootview"
local default_treeview_size = 200 * SCALE
local tooltip_offset = style.font:get_height()
local tooltip_border = 1
local tooltip_delay = 0.5
local tooltip_alpha = 255
local tooltip_alpha_rate = 1
local function get_depth(filename)
local n = 1
for sep in filename:gmatch("[\\/]") do
n = n + 1
end
return n
end
local function replace_alpha(color, alpha)
local r, g, b = table.unpack(color)
return { r, g, b, alpha }
end
local TreeView = View:extend()
function TreeView:new()
TreeView.super.new(self)
self.scrollable = true
self.visible = true
self.init_size = true
self.target_size = default_treeview_size
self.cache = {}
self.tooltip = { x = 0, y = 0, begin = 0, alpha = 0 }
self.item_icon_width = 0
self.item_text_spacing = 0
self:add_core_hooks()
end
function TreeView:add_core_hooks()
-- When a file or directory is deleted we delete the corresponding cache entry
-- because if the entry is recreated we may use wrong information from cache.
local on_delete = core.on_dirmonitor_delete
core.on_dirmonitor_delete = function(dir, filepath)
local cache = self.cache[dir.name]
if cache then cache[filepath] = nil end
on_delete(dir, filepath)
end
end
function TreeView:set_target_size(axis, value)
if axis == "x" then
self.target_size = value
return true
end
end
function TreeView:get_cached(dir, item, dirname)
local dir_cache = self.cache[dirname]
if not dir_cache then
dir_cache = {}
self.cache[dirname] = dir_cache
end
-- to discriminate top directories from regular files or subdirectories
-- we add ':' at the end of the top directories' filename. it will be
-- used only to identify the entry into the cache.
local cache_name = item.filename .. (item.topdir and ":" or "")
local t = dir_cache[cache_name]
if not t then
t = {}
local basename = common.basename(item.filename)
if item.topdir then
t.filename = basename
t.expanded = true
t.depth = 0
t.abs_filename = dirname
else
t.filename = item.filename
t.depth = get_depth(item.filename)
t.abs_filename = dirname .. PATHSEP .. item.filename
end
t.name = basename
t.type = item.type
t.dir = dir -- points to top level "dir" item
dir_cache[cache_name] = t
end
return t
end
function TreeView:get_name()
return "Project"
end
function TreeView:get_item_height()
return style.font:get_height() + style.padding.y
end
function TreeView:invalidate_cache(dirname)
for _, v in pairs(self.cache[dirname]) do
v.skip = nil
end
end
function TreeView:check_cache()
for i = 1, #core.project_directories do
local dir = core.project_directories[i]
-- invalidate cache's skip values if directory is declared dirty
if dir.is_dirty and self.cache[dir.name] then
self:invalidate_cache(dir.name)
end
dir.is_dirty = false
end
end
function TreeView:each_item()
return coroutine.wrap(function()
self:check_cache()
local count_lines = 0
local ox, oy = self:get_content_offset()
local y = oy + style.padding.y
local w = self.size.x
local h = self:get_item_height()
for k = 1, #core.project_directories do
local dir = core.project_directories[k]
local dir_cached = self:get_cached(dir, dir.item, dir.name)
coroutine.yield(dir_cached, ox, y, w, h)
count_lines = count_lines + 1
y = y + h
local i = 1
while i <= #dir.files and dir_cached.expanded do
local item = dir.files[i]
local cached = self:get_cached(dir, item, dir.name)
coroutine.yield(cached, ox, y, w, h)
count_lines = count_lines + 1
y = y + h
i = i + 1
if not cached.expanded then
if cached.skip then
i = cached.skip
else
local depth = cached.depth
while i <= #dir.files do
if get_depth(dir.files[i].filename) <= depth then break end
i = i + 1
end
cached.skip = i
end
end
end -- while files
end -- for directories
self.count_lines = count_lines
end)
end
function TreeView:get_text_bounding_box(item, x, y, w, h)
local icon_width = style.icon_font:get_width("D")
local xoffset = item.depth * style.padding.x + style.padding.x + icon_width
x = x + xoffset
w = style.font:get_width(item.name) + 2 * style.padding.x
return x, y, w, h
end
function TreeView:on_mouse_moved(px, py, ...)
TreeView.super.on_mouse_moved(self, px, py, ...)
if self.dragging_scrollbar then return end
local item_changed, tooltip_changed
for item, x,y,w,h in self:each_item() do
if px > x and py > y and px <= x + w and py <= y + h then
item_changed = true
self.hovered_item = item
x,y,w,h = self:get_text_bounding_box(item, x,y,w,h)
if px > x and py > y and px <= x + w and py <= y + h then
tooltip_changed = true
self.tooltip.x, self.tooltip.y = px, py
self.tooltip.begin = system.get_time()
end
break
end
end
if not item_changed then self.hovered_item = nil end
if not tooltip_changed then self.tooltip.x, self.tooltip.y = nil, nil end
end
local function create_directory_in(item)
local path = item.abs_filename
core.command_view:enter("Create directory in " .. path, function(text)
local dirname = path .. PATHSEP .. text
local success, err = system.mkdir(dirname)
if not success then
core.error("cannot create directory %q: %s", dirname, err)
end
item.expanded = true
end)
end
function TreeView:on_mouse_pressed(button, x, y, clicks)
local caught = TreeView.super.on_mouse_pressed(self, button, x, y, clicks)
if caught or button ~= "left" then
return true
end
local hovered_item = self.hovered_item
if not hovered_item then
return false
elseif hovered_item.type == "dir" then
if keymap.modkeys["ctrl"] and button == "left" then
create_directory_in(hovered_item)
else
hovered_item.expanded = not hovered_item.expanded
if hovered_item.dir.files_limit then
core.update_project_subdir(hovered_item.dir, hovered_item.filename, hovered_item.expanded)
core.project_subdir_set_show(hovered_item.dir, hovered_item.filename, hovered_item.expanded)
end
end
else
core.try(function()
if core.last_active_view and core.active_view == self then
core.set_active_view(core.last_active_view)
end
local doc_filename = core.normalize_to_project_dir(hovered_item.abs_filename)
core.root_view:open_doc(core.open_doc(doc_filename))
end)
end
return true
end
function TreeView:update()
-- update width
local dest = self.visible and self.target_size or 0
if self.init_size then
self.size.x = dest
self.init_size = false
else
self:move_towards(self.size, "x", dest)
end
local duration = system.get_time() - self.tooltip.begin
if self.hovered_item and self.tooltip.x and duration > tooltip_delay then
self:move_towards(self.tooltip, "alpha", tooltip_alpha, tooltip_alpha_rate)
else
self.tooltip.alpha = 0
end
self.item_icon_width = style.icon_font:get_width("D")
self.item_text_spacing = style.icon_font:get_width("f") / 2
TreeView.super.update(self)
end
function TreeView:get_scrollable_size()
return self.count_lines and self:get_item_height() * (self.count_lines + 1) or math.huge
end
function TreeView:draw_tooltip()
local text = common.home_encode(self.hovered_item.abs_filename)
local w, h = style.font:get_width(text), style.font:get_height(text)
local x, y = self.tooltip.x + tooltip_offset, self.tooltip.y + tooltip_offset
w, h = w + style.padding.x, h + style.padding.y
if x + w > core.root_view.root_node.size.x then -- check if we can span right
x = x - w -- span left instead
end
local bx, by = x - tooltip_border, y - tooltip_border
local bw, bh = w + 2 * tooltip_border, h + 2 * tooltip_border
renderer.draw_rect(bx, by, bw, bh, replace_alpha(style.text, self.tooltip.alpha))
renderer.draw_rect(x, y, w, h, replace_alpha(style.background2, self.tooltip.alpha))
common.draw_text(style.font, replace_alpha(style.text, self.tooltip.alpha), text, "center", x, y, w, h)
end
function TreeView:get_item_icon(item, active, hovered)
local character = "f"
if item.type == "dir" then
character = item.expanded and "D" or "d"
end
local font = style.icon_font
local color = style.text
if active or hovered then
color = style.accent
end
return character, font, color
end
function TreeView:get_item_text(item, active, hovered)
local text = item.name
local font = style.font
local color = style.text
if active or hovered then
color = style.accent
end
return text, font, color
end
function TreeView:draw_item_text(item, active, hovered, x, y, w, h)
local item_text, item_font, item_color = self:get_item_text(item, active, hovered)
common.draw_text(item_font, item_color, item_text, nil, x, y, 0, h)
end
function TreeView:draw_item_icon(item, active, hovered, x, y, w, h)
local icon_char, icon_font, icon_color = self:get_item_icon(item, active, hovered)
common.draw_text(icon_font, icon_color, icon_char, nil, x, y, 0, h)
return self.item_icon_width + self.item_text_spacing
end
function TreeView:draw_item_body(item, active, hovered, x, y, w, h)
x = x + self:draw_item_icon(item, active, hovered, x, y, w, h)
self:draw_item_text(item, active, hovered, x, y, w, h)
end
function TreeView:draw_item_chevron(item, active, hovered, x, y, w, h)
if item.type == "dir" then
local chevron_icon = item.expanded and "-" or "+"
local chevron_color = hovered and style.accent or style.text
common.draw_text(style.icon_font, chevron_color, chevron_icon, nil, x, y, 0, h)
end
return style.padding.x
end
function TreeView:draw_item_background(item, active, hovered, x, y, w, h)
if hovered then
renderer.draw_rect(x, y, w, h, style.line_highlight)
end
end
function TreeView:draw_item(item, active, hovered, x, y, w, h)
self:draw_item_background(item, active, hovered, x, y, w, h)
x = x + item.depth * style.padding.x + style.padding.x
x = x + self:draw_item_chevron(item, active, hovered, x, y, w, h)
self:draw_item_body(item, active, hovered, x, y, w, h)
end
function TreeView:draw()
self:draw_background(style.background2)
local _y, _h = self.position.y, self.size.y
local doc = core.active_view.doc
local active_filename = doc and system.absolute_path(doc.filename or "")
for item, x,y,w,h in self:each_item() do
if y + h >= _y and y < _y + _h then
self:draw_item(item,
item.abs_filename == active_filename,
item == self.hovered_item,
x, y, w, h)
end
end
self:draw_scrollbar()
if self.hovered_item and self.tooltip.alpha > 0 then
core.root_view:defer_draw(self.draw_tooltip, self)
end
end
-- init
local view = TreeView()
local node = core.root_view:get_active_node()
local treeview_node = node:split("left", view, {x = true}, true)
-- The toolbarview plugin is special because it is plugged inside
-- a treeview pane which is itelf provided in a plugin.
-- We therefore break the usual plugin's logic that would require each
-- plugin to be independent of each other. In addition it is not the
-- plugin module that plug itself in the active node but it is plugged here
-- in the treeview node.
local toolbar_view = nil
local toolbar_plugin, ToolbarView = core.try(require, "plugins.toolbarview")
if config.plugins.toolbarview ~= false and toolbar_plugin then
toolbar_view = ToolbarView()
treeview_node:split("down", toolbar_view, {y = true})
local min_toolbar_width = toolbar_view:get_min_width()
view:set_target_size("x", math.max(default_treeview_size, min_toolbar_width))
command.add(nil, {
["toolbar:toggle"] = function()
toolbar_view:toggle_visible()
end,
})
end
-- Add a context menu to the treeview
local menu = ContextMenu()
local on_view_mouse_pressed = RootView.on_view_mouse_pressed
local on_mouse_moved = RootView.on_mouse_moved
local root_view_update = RootView.update
local root_view_draw = RootView.draw
function RootView:on_mouse_moved(...)
if menu:on_mouse_moved(...) then return end
on_mouse_moved(self, ...)
end
function RootView.on_view_mouse_pressed(button, x, y, clicks)
-- We give the priority to the menu to process mouse pressed events.
if button == "right" then
view.tooltip.alpha = 0
view.tooltip.x, view.tooltip.y = nil, nil
end
local handled = menu:on_mouse_pressed(button, x, y, clicks)
return handled or on_view_mouse_pressed(button, x, y, clicks)
end
function RootView:update(...)
root_view_update(self, ...)
menu:update()
end
function RootView:draw(...)
root_view_draw(self, ...)
menu:draw()
end
local function is_project_folder(path)
return core.project_dir == path
end
menu:register(function() return view.hovered_item end, {
{ text = "Open in System", command = "treeview:open-in-system" },
ContextMenu.DIVIDER
})
menu:register(
function()
return view.hovered_item
and not is_project_folder(view.hovered_item.abs_filename)
end,
{
{ text = "Rename", command = "treeview:rename" },
{ text = "Delete", command = "treeview:delete" },
}
)
menu:register(
function()
return view.hovered_item and view.hovered_item.type == "dir"
end,
{
{ text = "New File", command = "treeview:new-file" },
{ text = "New Folder", command = "treeview:new-folder" },
}
)
-- Register the TreeView commands and keymap
command.add(nil, {
["treeview:toggle"] = function()
view.visible = not view.visible
end})
command.add(function() return view.hovered_item ~= nil end, {
["treeview:rename"] = function()
local old_filename = view.hovered_item.filename
local old_abs_filename = view.hovered_item.abs_filename
core.command_view:set_text(old_filename)
core.command_view:enter("Rename", function(filename)
filename = core.normalize_to_project_dir(filename)
local abs_filename = core.project_absolute_path(filename)
local res, err = os.rename(old_abs_filename, abs_filename)
if res then -- successfully renamed
for _, doc in ipairs(core.docs) do
if doc.abs_filename and old_abs_filename == doc.abs_filename then
doc:set_filename(filename, abs_filename) -- make doc point to the new filename
doc:reset_syntax()
break -- only first needed
end
end
core.log("Renamed \"%s\" to \"%s\"", old_filename, filename)
else
core.error("Error while renaming \"%s\" to \"%s\": %s", old_abs_filename, abs_filename, err)
end
end, common.path_suggest)
end,
["treeview:new-file"] = function()
if not is_project_folder(view.hovered_item.abs_filename) then
core.command_view:set_text(view.hovered_item.filename .. "/")
end
core.command_view:enter("Filename", function(filename)
local doc_filename = core.project_dir .. PATHSEP .. filename
local file = io.open(doc_filename, "a+")
file:write("")
file:close()
core.root_view:open_doc(core.open_doc(doc_filename))
core.log("Created %s", doc_filename)
end, common.path_suggest)
end,
["treeview:new-folder"] = function()
if not is_project_folder(view.hovered_item.abs_filename) then
core.command_view:set_text(view.hovered_item.filename .. "/")
end
core.command_view:enter("Folder Name", function(filename)
local dir_path = core.project_dir .. PATHSEP .. filename
common.mkdirp(dir_path)
core.log("Created %s", dir_path)
end, common.path_suggest)
end,
["treeview:delete"] = function()
local filename = view.hovered_item.abs_filename
local relfilename = view.hovered_item.filename
local file_info = system.get_file_info(filename)
local file_type = file_info.type == "dir" and "Directory" or "File"
-- Ask before deleting
local opt = {
{ font = style.font, text = "Yes", default_yes = true },
{ font = style.font, text = "No" , default_no = true }
}
core.nag_view:show(
string.format("Delete %s", file_type),
string.format(
"Are you sure you want to delete the %s?\n%s: %s",
file_type:lower(), file_type, relfilename
),
opt,
function(item)
if item.text == "Yes" then
if file_info.type == "dir" then
local deleted, error, path = common.rm(filename, true)
if not deleted then
core.error("Error: %s - \"%s\" ", error, path)
return
end
else
local removed, error = os.remove(filename)
if not removed then
core.error("Error: %s - \"%s\"", error, filename)
return
end
end
core.log("Deleted \"%s\"", filename)
end
end
)
end,
["treeview:open-in-system"] = function()
local hovered_item = view.hovered_item
if PLATFORM == "Windows" then
system.exec(string.format("start \"\" %q", hovered_item.abs_filename))
elseif string.find(PLATFORM, "Mac") then
system.exec(string.format("open %q", hovered_item.abs_filename))
elseif PLATFORM == "Linux" then
system.exec(string.format("xdg-open %q", hovered_item.abs_filename))
end
end,
})
keymap.add { ["ctrl+\\"] = "treeview:toggle" }
-- Return the treeview with toolbar and contextmenu to allow
-- user or plugin modifications
view.toolbar = toolbar_view
view.contextmenu = menu
return view