canola.nvim/lua/oil/view.lua
Steven Arcangeli 0b2a4ddde1 refactor: only gc oil buffers once all oil bufs are hidden
Previously we were gc-ing all hidden oil buffers 2 seconds after they
were hidden. I think this is a little too magical, and interferes with
some expected vim behavior (ctrl-o/i). If people want the old behavior,
we can expose the "GC hidden buffers" function via the API.

We're also changing the "rerender visible oil buffers" logic, because
previously that would delete hidden oil buffers. Now it simply marks
them as dirty, and they will be rerendered during the next BufEnter.
2023-02-24 07:55:39 -08:00

542 lines
16 KiB
Lua

local cache = require("oil.cache")
local columns = require("oil.columns")
local config = require("oil.config")
local keymap_util = require("oil.keymap_util")
local loading = require("oil.loading")
local util = require("oil.util")
local FIELD = require("oil.constants").FIELD
local M = {}
-- map of path->last entry under cursor
local last_cursor_entry = {}
---@param entry oil.InternalEntry
---@param bufnr integer
---@return boolean
M.should_display = function(entry, bufnr)
local name = entry[FIELD.name]
return config.view_options.show_hidden or not config.view_options.is_hidden_file(name, bufnr)
end
---@param bufname string
---@param name nil|string
M.set_last_cursor = function(bufname, name)
last_cursor_entry[bufname] = name
end
---Set the cursor to the last_cursor_entry if one exists
M.maybe_set_cursor = function()
local oil = require("oil")
local bufname = vim.api.nvim_buf_get_name(0)
local entry_name = last_cursor_entry[bufname]
if not entry_name then
return
end
local line_count = vim.api.nvim_buf_line_count(0)
for lnum = 1, line_count do
local entry = oil.get_entry_on_line(0, lnum)
if entry and entry.name == entry_name then
local line = vim.api.nvim_buf_get_lines(0, lnum - 1, lnum, true)[1]
local id_str = line:match("^/(%d+)")
local col = line:find(entry_name, 1, true) or (id_str:len() + 1)
vim.api.nvim_win_set_cursor(0, { lnum, col - 1 })
M.set_last_cursor(bufname, nil)
break
end
end
end
---@param bufname string
---@return nil|string
M.get_last_cursor = function(bufname)
return last_cursor_entry[bufname]
end
local function are_any_modified()
local view = require("oil.view")
local buffers = view.get_all_buffers()
for _, bufnr in ipairs(buffers) do
if vim.bo[bufnr].modified then
return true
end
end
return false
end
M.toggle_hidden = function()
local view = require("oil.view")
local any_modified = are_any_modified()
if any_modified then
vim.notify("Cannot toggle hidden files when you have unsaved changes", vim.log.levels.WARN)
else
config.view_options.show_hidden = not config.view_options.show_hidden
view.rerender_all_oil_buffers({ refetch = false })
end
end
M.set_columns = function(cols)
local view = require("oil.view")
local any_modified = are_any_modified()
if any_modified then
vim.notify("Cannot change columns when you have unsaved changes", vim.log.levels.WARN)
else
config.columns = cols
-- TODO only refetch if we don't have all the necessary data for the columns
view.rerender_all_oil_buffers({ refetch = true })
end
end
-- List of bufnrs
local session = {}
---@return integer[]
M.get_all_buffers = function()
return vim.tbl_filter(vim.api.nvim_buf_is_loaded, vim.tbl_keys(session))
end
---@param opts table
---@note
--- This DISCARDS ALL MODIFICATIONS a user has made to oil buffers
M.rerender_all_oil_buffers = function(opts)
local buffers = M.get_all_buffers()
local hidden_buffers = {}
for _, bufnr in ipairs(buffers) do
hidden_buffers[bufnr] = true
end
for _, winid in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_is_valid(winid) then
hidden_buffers[vim.api.nvim_win_get_buf(winid)] = nil
end
end
for _, bufnr in ipairs(buffers) do
if hidden_buffers[bufnr] then
vim.b[bufnr].oil_dirty = opts
else
M.render_buffer_async(bufnr, opts)
end
end
end
M.set_win_options = function()
local winid = vim.api.nvim_get_current_win()
for k, v in pairs(config.win_options) do
if config.restore_win_options then
local varname = "_oil_" .. k
if not pcall(vim.api.nvim_win_get_var, winid, varname) then
local prev_value = vim.wo[k]
vim.api.nvim_win_set_var(winid, varname, prev_value)
end
end
vim.api.nvim_win_set_option(winid, k, v)
end
end
M.restore_win_options = function()
local winid = vim.api.nvim_get_current_win()
for k in pairs(config.win_options) do
local varname = "_oil_" .. k
local has_opt, opt = pcall(vim.api.nvim_win_get_var, winid, varname)
if has_opt then
vim.api.nvim_win_set_option(winid, k, opt)
end
end
end
---Get a list of visible oil buffers and a list of hidden oil buffers
---@note
--- If any buffers are modified, return values are nil
---@return nil|integer[]
---@return nil|integer[]
local function get_visible_hidden_buffers()
local buffers = M.get_all_buffers()
local hidden_buffers = {}
for _, bufnr in ipairs(buffers) do
if vim.bo[bufnr].modified then
return
end
hidden_buffers[bufnr] = true
end
for _, winid in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_is_valid(winid) then
hidden_buffers[vim.api.nvim_win_get_buf(winid)] = nil
end
end
local visible_buffers = vim.tbl_filter(function(bufnr)
return not hidden_buffers[bufnr]
end, buffers)
return visible_buffers, vim.tbl_keys(hidden_buffers)
end
---Delete unmodified, hidden oil buffers and if none remain, clear the cache
M.delete_hidden_buffers = function()
local visible_buffers, hidden_buffers = get_visible_hidden_buffers()
if not visible_buffers or not hidden_buffers or not vim.tbl_isempty(visible_buffers) then
return
end
for _, bufnr in ipairs(hidden_buffers) do
vim.api.nvim_buf_delete(bufnr, { force = true })
end
cache.clear_everything()
end
---@param bufnr integer
M.initialize = function(bufnr)
if bufnr == 0 then
bufnr = vim.api.nvim_get_current_buf()
end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
vim.bo[bufnr].buftype = "acwrite"
vim.bo[bufnr].syntax = "oil"
vim.bo[bufnr].filetype = "oil"
vim.b[bufnr].EditorConfig_disable = 1
session[bufnr] = true
for k, v in pairs(config.buf_options) do
vim.api.nvim_buf_set_option(bufnr, k, v)
end
M.set_win_options()
vim.api.nvim_clear_autocmds({
buffer = bufnr,
group = "Oil",
})
vim.api.nvim_create_autocmd("BufHidden", {
desc = "Delete oil buffers when no longer in use",
group = "Oil",
nested = true,
buffer = bufnr,
callback = function()
-- First wait a short time (10ms) for the buffer change to settle
vim.defer_fn(function()
local visible_buffers = get_visible_hidden_buffers()
-- Only kick off the 2-second timer if we don't have any visible oil buffers
if visible_buffers and vim.tbl_isempty(visible_buffers) then
vim.defer_fn(function()
M.delete_hidden_buffers()
end, 2000)
end
end, 10)
end,
})
vim.api.nvim_create_autocmd("BufDelete", {
group = "Oil",
nested = true,
once = true,
buffer = bufnr,
callback = function()
session[bufnr] = nil
end,
})
vim.api.nvim_create_autocmd("BufEnter", {
group = "Oil",
buffer = bufnr,
callback = function(args)
local opts = vim.b[args.buf].oil_dirty
if opts then
vim.b[args.buf].oil_dirty = nil
M.render_buffer_async(args.buf, opts)
end
end,
})
local timer
vim.api.nvim_create_autocmd("CursorMoved", {
desc = "Update oil preview window",
group = "Oil",
buffer = bufnr,
callback = function()
if timer then
timer:again()
return
end
timer = vim.loop.new_timer()
timer:start(10, 100, function()
timer:stop()
timer:close()
timer = nil
vim.schedule(function()
if vim.api.nvim_get_current_buf() ~= bufnr then
return
end
local oil = require("oil")
local entry = oil.get_cursor_entry()
if entry then
local winid = util.get_preview_win()
if winid then
if entry.id ~= vim.w[winid].oil_entry_id then
oil.select({ preview = true })
end
end
end
end)
end)
end,
})
M.render_buffer_async(bufnr, {}, function(err)
if err then
vim.notify(
string.format("Error rendering oil buffer %s: %s", vim.api.nvim_buf_get_name(bufnr), err),
vim.log.levels.ERROR
)
end
end)
keymap_util.set_keymaps("", config.keymaps, bufnr)
end
---@param entry oil.InternalEntry
---@return boolean
local function is_entry_directory(entry)
local type = entry[FIELD.type]
if type == "directory" then
return true
elseif type == "link" then
local meta = entry[FIELD.meta]
return meta and meta.link_stat and meta.link_stat.type == "directory"
else
return false
end
end
---@param bufnr integer
---@param opts nil|table
--- jump boolean
--- jump_first boolean
---@return boolean
local function render_buffer(bufnr, opts)
if bufnr == 0 then
bufnr = vim.api.nvim_get_current_buf()
end
if not vim.api.nvim_buf_is_valid(bufnr) then
return false
end
local bufname = vim.api.nvim_buf_get_name(bufnr)
opts = vim.tbl_extend("keep", opts or {}, {
jump = false,
jump_first = false,
})
local scheme = util.parse_url(bufname)
local adapter = util.get_adapter(bufnr)
if not adapter then
return false
end
local entries = cache.list_url(bufname)
local entry_list = vim.tbl_values(entries)
table.sort(entry_list, function(a, b)
local a_isdir = is_entry_directory(a)
local b_isdir = is_entry_directory(b)
if a_isdir ~= b_isdir then
return a_isdir
end
return a[FIELD.name] < b[FIELD.name]
end)
local jump_idx
if opts.jump_first then
jump_idx = 1
end
local seek_after_render_found = false
local seek_after_render = M.get_last_cursor(bufname)
local column_defs = columns.get_supported_columns(scheme)
local line_table = {}
local col_width = {}
for i in ipairs(column_defs) do
col_width[i + 1] = 1
end
local virt_text = {}
for _, entry in ipairs(entry_list) do
if not M.should_display(entry, bufnr) then
goto continue
end
local cols = M.format_entry_cols(entry, column_defs, col_width, adapter)
table.insert(line_table, cols)
local name = entry[FIELD.name]
if seek_after_render == name then
seek_after_render_found = true
jump_idx = #line_table
M.set_last_cursor(bufname, nil)
end
::continue::
end
local lines, highlights = util.render_table(line_table, col_width)
vim.bo[bufnr].modifiable = true
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines)
vim.bo[bufnr].modifiable = false
vim.bo[bufnr].modified = false
util.set_highlights(bufnr, highlights)
local ns = vim.api.nvim_create_namespace("Oil")
for _, v in ipairs(virt_text) do
local lnum, col, ext_opts = unpack(v)
vim.api.nvim_buf_set_extmark(bufnr, ns, lnum, col, ext_opts)
end
if opts.jump then
-- TODO why is the schedule necessary?
vim.schedule(function()
for _, winid in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_is_valid(winid) and vim.api.nvim_win_get_buf(winid) == bufnr then
-- If we're not jumping to a specific lnum, use the current lnum so we can adjust the col
local lnum = jump_idx or vim.api.nvim_win_get_cursor(winid)[1]
local line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1]
local id_str = line:match("^/(%d+)")
local id = tonumber(id_str)
if id then
local entry = cache.get_entry_by_id(id)
if entry then
local name = entry[FIELD.name]
local col = line:find(name, 1, true) or (id_str:len() + 1)
vim.api.nvim_win_set_cursor(winid, { lnum, col - 1 })
end
end
end
end
end)
end
return seek_after_render_found
end
---@private
---@param adapter oil.Adapter
---@param entry oil.InternalEntry
---@param column_defs table[]
---@param col_width integer[]
---@param adapter oil.Adapter
---@return oil.TextChunk[]
M.format_entry_cols = function(entry, column_defs, col_width, adapter)
local name = entry[FIELD.name]
-- First put the unique ID
local cols = {}
local id_key = cache.format_id(entry[FIELD.id])
col_width[1] = id_key:len()
table.insert(cols, id_key)
-- Then add all the configured columns
for i, column in ipairs(column_defs) do
local chunk = columns.render_col(adapter, column, entry)
local text = type(chunk) == "table" and chunk[1] or chunk
col_width[i + 1] = math.max(col_width[i + 1], vim.api.nvim_strwidth(text))
table.insert(cols, chunk)
end
-- Always add the entry name at the end
local entry_type = entry[FIELD.type]
if entry_type == "directory" then
table.insert(cols, { name .. "/", "OilDir" })
elseif entry_type == "socket" then
table.insert(cols, { name, "OilSocket" })
elseif entry_type == "link" then
local meta = entry[FIELD.meta]
local link_text
if meta then
if meta.link_stat and meta.link_stat.type == "directory" then
name = name .. "/"
end
if meta.link then
link_text = "->" .. " " .. meta.link
if meta.link_stat and meta.link_stat.type == "directory" then
link_text = util.addslash(link_text)
end
end
end
table.insert(cols, { name, "OilLink" })
if link_text then
table.insert(cols, { link_text, "Comment" })
end
else
table.insert(cols, { name, "OilFile" })
end
return cols
end
---@param bufnr integer
---@param opts nil|table
--- preserve_undo nil|boolean
--- refetch nil|boolean Defaults to true
---@param callback nil|fun(err: nil|string)
M.render_buffer_async = function(bufnr, opts, callback)
opts = vim.tbl_deep_extend("keep", opts or {}, {
preserve_undo = false,
refetch = true,
})
if bufnr == 0 then
bufnr = vim.api.nvim_get_current_buf()
end
local bufname = vim.api.nvim_buf_get_name(bufnr)
local scheme, dir = util.parse_url(bufname)
local preserve_undo = opts.preserve_undo and config.adapters[scheme] == "files"
if not preserve_undo then
-- Undo should not return to a blank buffer
-- Method taken from :h clear-undo
vim.bo[bufnr].undolevels = -1
end
local handle_error = vim.schedule_wrap(function(message)
if not preserve_undo then
vim.bo[bufnr].undolevels = vim.api.nvim_get_option("undolevels")
end
util.render_centered_text(bufnr, { "Error: " .. message })
if callback then
callback(message)
else
error(message)
end
end)
if not dir then
handle_error(string.format("Could not parse oil url '%s'", bufname))
return
end
local adapter = util.get_adapter(bufnr)
if not adapter then
handle_error(string.format("[oil] no adapter for buffer '%s'", bufname))
return
end
local start_ms = vim.loop.hrtime() / 1e6
local seek_after_render_found = false
local first = true
vim.bo[bufnr].modifiable = false
loading.set_loading(bufnr, true)
local finish = vim.schedule_wrap(function()
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
loading.set_loading(bufnr, false)
render_buffer(bufnr, { jump = true })
if not preserve_undo then
vim.bo[bufnr].undolevels = vim.api.nvim_get_option("undolevels")
end
vim.bo[bufnr].modifiable = adapter.is_modifiable(bufnr)
if callback then
callback()
end
end)
if not opts.refetch then
finish()
return
end
adapter.list(bufname, config.columns, function(err, has_more)
loading.set_loading(bufnr, false)
if err then
handle_error(err)
return
elseif has_more then
local now = vim.loop.hrtime() / 1e6
local delta = now - start_ms
-- If we've been chugging for more than 40ms, go ahead and render what we have
if delta > 40 then
start_ms = now
vim.schedule(function()
seek_after_render_found =
render_buffer(bufnr, { jump = not seek_after_render_found, jump_first = first })
end)
end
first = false
else
-- done iterating
finish()
end
end)
end
return M