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.
599 lines
18 KiB
Lua
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
|