local uv = vim.uv or vim.loop local cache = require('canola.cache') local columns = require('canola.columns') local config = require('canola.config') local constants = require('canola.constants') local fs = require('canola.fs') local keymap_util = require('canola.keymap_util') local loading = require('canola.loading') local util = require('canola.util') local M = {} local FIELD_ID = constants.FIELD_ID local FIELD_NAME = constants.FIELD_NAME local FIELD_TYPE = constants.FIELD_TYPE local FIELD_META = constants.FIELD_META -- map of path->last entry under cursor local last_cursor_entry = {} ---@param bufnr integer ---@param entry canola.InternalEntry ---@return boolean display ---@return boolean is_hidden Whether the file is classified as a hidden file M.should_display = function(bufnr, entry) local name = entry[FIELD_NAME] local public_entry = util.export_entry(entry) if config.view_options.is_always_hidden(name, bufnr, public_entry) then return false, true else local is_hidden = config.view_options.is_hidden_file(name, bufnr, public_entry) local display = config.view_options.show_hidden or not is_hidden return display, is_hidden end 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 canola = require('canola') 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 = canola.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 buffers = M.get_all_buffers() for _, bufnr in ipairs(buffers) do if vim.bo[bufnr].modified then return true end end return false end local function is_unix_executable(entry) if entry[FIELD_TYPE] == 'directory' then return false end local meta = entry[FIELD_META] if not meta or not meta.stat then return false end if meta.stat.type == 'directory' then return false end local S_IXUSR = 64 local S_IXGRP = 8 local S_IXOTH = 1 return bit.band(meta.stat.mode, bit.bor(S_IXUSR, S_IXGRP, S_IXOTH)) ~= 0 end M.toggle_hidden = function() 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 M.rerender_all_canola_buffers({ refetch = false }) end end ---@param is_hidden_file fun(filename: string, bufnr: integer, entry: canola.Entry): boolean M.set_is_hidden_file = function(is_hidden_file) local any_modified = are_any_modified() if any_modified then vim.notify('Cannot change is_hidden_file when you have unsaved changes', vim.log.levels.WARN) else config.view_options.is_hidden_file = is_hidden_file M.rerender_all_canola_buffers({ refetch = false }) end end M.set_columns = function(cols) 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 M.rerender_all_canola_buffers({ refetch = true }) end end M.set_sort = function(new_sort) local any_modified = are_any_modified() if any_modified then vim.notify('Cannot change sorting when you have unsaved changes', vim.log.levels.WARN) else config.view_options.sort = new_sort -- TODO only refetch if we don't have all the necessary data for the columns M.rerender_all_canola_buffers({ refetch = true }) end end ---@class canola.ViewData ---@field fs_event? any uv_fs_event_t -- List of bufnrs ---@type table local session = {} ---@return integer[] M.get_all_buffers = function() return vim.tbl_filter(vim.api.nvim_buf_is_loaded, vim.tbl_keys(session)) end local buffers_locked = false ---Make all canola buffers nomodifiable M.lock_buffers = function() buffers_locked = true for bufnr in pairs(session) do if vim.api.nvim_buf_is_loaded(bufnr) then vim.bo[bufnr].modifiable = false end end end ---Restore normal modifiable settings for canola buffers M.unlock_buffers = function() buffers_locked = false for bufnr in pairs(session) do if vim.api.nvim_buf_is_loaded(bufnr) then local adapter = util.get_adapter(bufnr, true) if adapter then vim.bo[bufnr].modifiable = adapter.is_modifiable(bufnr) end end end end ---@param opts? table ---@param callback? fun(err: nil|string) ---@note --- This DISCARDS ALL MODIFICATIONS a user has made to canola buffers M.rerender_all_canola_buffers = function(opts, callback) opts = opts or {} 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 local cb = util.cb_collect(#buffers, callback or function() end) for _, bufnr in ipairs(buffers) do if hidden_buffers[bufnr] then vim.b[bufnr].canola_dirty = opts -- We also need to mark this as nomodified so it doesn't interfere with quitting vim vim.bo[bufnr].modified = false vim.schedule(cb) else M.render_buffer_async(bufnr, opts, cb) end end end M.set_win_options = function() local winid = vim.api.nvim_get_current_win() -- work around https://github.com/neovim/neovim/pull/27422 vim.api.nvim_set_option_value('foldmethod', 'manual', { scope = 'local', win = winid }) for k, v in pairs(config.win_options) do vim.api.nvim_set_option_value(k, v, { scope = 'local', win = winid }) end if vim.wo[winid].previewwindow then -- apply preview window options last for k, v in pairs(config.preview_win.win_options) do vim.api.nvim_set_option_value(k, v, { scope = 'local', win = winid }) end end end ---Get a list of visible canola buffers and a list of hidden canola buffers ---@note --- If any buffers are modified, return values are nil ---@return nil|integer[] visible ---@return nil|integer[] hidden 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 canola 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) or vim.fn.win_gettype() == 'command' then return end for _, bufnr in ipairs(hidden_buffers) do vim.api.nvim_buf_delete(bufnr, { force = true }) end cache.clear_everything() end ---@param adapter canola.Adapter ---@param ranges table ---@return integer local function get_first_mutable_column_col(adapter, ranges) local min_col = ranges.name[1] for col_name, start_len in pairs(ranges) do local start = start_len[1] local col_spec = columns.get_column(adapter, col_name) local is_col_mutable = col_spec and col_spec.perform_action ~= nil if is_col_mutable and start < min_col then min_col = start end end return min_col end --- @param bufnr integer --- @param adapter canola.Adapter --- @param mode false|"name"|"editable" --- @param cur integer[] --- @return integer[] | nil local function calc_constrained_cursor_pos(bufnr, adapter, mode, cur) local parser = require('canola.mutator.parser') local line = vim.api.nvim_buf_get_lines(bufnr, cur[1] - 1, cur[1], true)[1] local column_defs = columns.get_supported_columns(adapter) local result = parser.parse_line(adapter, line, column_defs) if result and result.ranges then local min_col if mode == 'editable' then min_col = get_first_mutable_column_col(adapter, result.ranges) elseif mode == 'name' then min_col = result.ranges.name[1] else error(string.format('Unexpected value "%s" for option constrain_cursor', mode)) end if cur[2] < min_col then return { cur[1], min_col } end end end ---Force cursor to be after hidden/immutable columns ---@param bufnr integer ---@param mode false|"name"|"editable" local function constrain_cursor(bufnr, mode) if not mode then return end if bufnr ~= vim.api.nvim_get_current_buf() then return end local adapter = util.get_adapter(bufnr, true) if not adapter then return end local mc = package.loaded['multicursor-nvim'] if mc then mc.onSafeState(function() mc.action(function(ctx) ctx:forEachCursor(function(cursor) local new_cur = calc_constrained_cursor_pos(bufnr, adapter, mode, { cursor:line(), cursor:col() - 1 }) if new_cur then cursor:setPos({ new_cur[1], new_cur[2] + 1 }) end end) end) end, { once = true }) else local cur = vim.api.nvim_win_get_cursor(0) local new_cur = calc_constrained_cursor_pos(bufnr, adapter, mode, cur) if new_cur then vim.api.nvim_win_set_cursor(0, new_cur) end end end ---@param bufnr integer local function show_insert_guide(bufnr) if not config.constrain_cursor then return end if bufnr ~= vim.api.nvim_get_current_buf() then return end local adapter = util.get_adapter(bufnr, true) if not adapter then return end local cur = vim.api.nvim_win_get_cursor(0) local current_line = vim.api.nvim_buf_get_lines(bufnr, cur[1] - 1, cur[1], true)[1] if current_line ~= '' then return end local all_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) local ref_line if cur[1] > 1 and all_lines[cur[1] - 1] ~= '' then ref_line = all_lines[cur[1] - 1] elseif cur[1] < #all_lines and all_lines[cur[1] + 1] ~= '' then ref_line = all_lines[cur[1] + 1] else for i, line in ipairs(all_lines) do if line ~= '' and i ~= cur[1] then ref_line = line break end end end if not ref_line then return end local parser = require('canola.mutator.parser') local column_defs = columns.get_supported_columns(adapter) local result = parser.parse_line(adapter, ref_line, column_defs) if not result or not result.ranges then return end local id_end = result.ranges.id[2] + 1 local col_prefix = ref_line:sub(id_end + 1, result.ranges.name[1]) local col_width = vim.api.nvim_strwidth(col_prefix) local id_width local cole = vim.wo.conceallevel if cole >= 2 then id_width = 0 elseif cole == 1 then id_width = 1 else id_width = vim.api.nvim_strwidth(ref_line:sub(1, id_end)) end local virtual_col = id_width + col_width if virtual_col <= 0 then return end vim.w.canola_saved_ve = vim.wo.virtualedit vim.wo.virtualedit = 'all' vim.api.nvim_win_set_cursor(0, { cur[1], virtual_col }) vim.api.nvim_create_autocmd('TextChangedI', { group = 'Canola', buffer = bufnr, once = true, callback = function() if vim.w.canola_saved_ve ~= nil then vim.wo.virtualedit = vim.w.canola_saved_ve vim.w.canola_saved_ve = nil end end, }) end ---Redraw original path virtual text for trash buffer ---@param bufnr integer local function redraw_trash_virtual_text(bufnr) if not vim.api.nvim_buf_is_valid(bufnr) or not vim.api.nvim_buf_is_loaded(bufnr) then return end local parser = require('canola.mutator.parser') local adapter = util.get_adapter(bufnr, true) if not adapter or adapter.name ~= 'trash' then return end local _, buf_path = util.parse_url(vim.api.nvim_buf_get_name(bufnr)) local os_path = fs.posix_to_os_path(assert(buf_path)) local ns = vim.api.nvim_create_namespace('CanolaVtext') vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) local column_defs = columns.get_supported_columns(adapter) for lnum, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)) do local result = parser.parse_line(adapter, line, column_defs) local entry = result and result.entry if entry then local meta = entry[FIELD_META] ---@type nil|canola.TrashInfo local trash_info = meta and meta.trash_info if trash_info then vim.api.nvim_buf_set_extmark(bufnr, ns, lnum - 1, 0, { virt_text = { { '➜ ' .. fs.shorten_path(trash_info.original_path, os_path), 'CanolaTrashSourcePath', }, }, }) end end end 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.api.nvim_clear_autocmds({ buffer = bufnr, group = 'Canola', }) vim.bo[bufnr].buftype = 'acwrite' vim.bo[bufnr].readonly = false vim.bo[bufnr].swapfile = false vim.bo[bufnr].syntax = 'canola' vim.bo[bufnr].filetype = 'canola' vim.b[bufnr].EditorConfig_disable = 1 session[bufnr] = session[bufnr] or {} for k, v in pairs(config.buf_options) do vim.bo[bufnr][k] = v end vim.api.nvim_buf_call(bufnr, M.set_win_options) vim.api.nvim_create_autocmd('BufHidden', { desc = 'Delete canola buffers when no longer in use', group = 'Canola', nested = true, buffer = bufnr, callback = function() -- First wait a short time (100ms) for the buffer change to settle vim.defer_fn(function() local visible_buffers = get_visible_hidden_buffers() -- Only delete canola buffers if none of them are visible if visible_buffers and vim.tbl_isempty(visible_buffers) then -- Check if cleanup is enabled if type(config.cleanup_delay_ms) == 'number' then if config.cleanup_delay_ms > 0 then vim.defer_fn(function() M.delete_hidden_buffers() end, config.cleanup_delay_ms) else M.delete_hidden_buffers() end end end end, 100) end, }) vim.api.nvim_create_autocmd('BufUnload', { group = 'Canola', nested = true, once = true, buffer = bufnr, callback = function() local view_data = session[bufnr] session[bufnr] = nil if view_data and view_data.fs_event then view_data.fs_event:stop() end end, }) vim.api.nvim_create_autocmd('BufEnter', { group = 'Canola', buffer = bufnr, callback = function(args) local opts = vim.b[args.buf].canola_dirty if opts then vim.b[args.buf].canola_dirty = nil M.render_buffer_async(args.buf, opts) end end, }) local timer vim.api.nvim_create_autocmd('InsertEnter', { desc = 'Constrain canola cursor position', group = 'Canola', buffer = bufnr, callback = function() -- For some reason the cursor bounces back to its original position, -- so we have to defer the call vim.schedule(function() constrain_cursor(bufnr, config.constrain_cursor) show_insert_guide(bufnr) end) end, }) vim.api.nvim_create_autocmd('InsertLeave', { group = 'Canola', buffer = bufnr, callback = function() if vim.w.canola_saved_ve ~= nil then vim.wo.virtualedit = vim.w.canola_saved_ve vim.w.canola_saved_ve = nil end end, }) vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI', 'ModeChanged' }, { desc = 'Update canola preview window', group = 'Canola', buffer = bufnr, callback = function() local canola = require('canola') if vim.wo.previewwindow then return end constrain_cursor(bufnr, config.constrain_cursor) if config.preview_win.update_on_cursor_moved then -- Debounce and update the preview window if timer then timer:again() return end timer = uv.new_timer() if not timer then return end 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 entry = canola.get_cursor_entry() -- Don't update in visual mode. Visual mode implies editing not browsing, -- and updating the preview can cause flicker and stutter. if entry and not util.is_visual_mode() then local winid = util.get_preview_win() if winid then if entry.id ~= vim.w[winid].canola_entry_id then canola.open_preview() end end end end) end) end end, }) local adapter = util.get_adapter(bufnr, true) -- Set up a watcher that will refresh the directory if adapter and adapter.name == 'files' and config.watch_for_changes and not session[bufnr].fs_event then local fs_event = assert(uv.new_fs_event()) local bufname = vim.api.nvim_buf_get_name(bufnr) local _, dir = util.parse_url(bufname) fs_event:start( assert(dir), {}, vim.schedule_wrap(function(err, filename, events) if not vim.api.nvim_buf_is_valid(bufnr) then local sess = session[bufnr] if sess then sess.fs_event = nil end fs_event:stop() return end local mutator = require('canola.mutator') if err or vim.bo[bufnr].modified or vim.b[bufnr].canola_dirty or mutator.is_mutating() then return end -- If the buffer is currently visible, rerender 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 M.render_buffer_async(bufnr) return end end -- If it is not currently visible, mark it as dirty vim.b[bufnr].canola_dirty = {} end) ) session[bufnr].fs_event = fs_event end -- Watch for TextChanged and update the trash original path extmarks if adapter and adapter.name == 'trash' then local debounce_timer = assert(uv.new_timer()) local pending = false vim.api.nvim_create_autocmd('TextChanged', { desc = 'Update canola virtual text of original path', buffer = bufnr, callback = function() -- Respond immediately to prevent flickering, the set the timer for a "cooldown period" -- If this is called again during the cooldown window, we will rerender after cooldown. if debounce_timer:is_active() then pending = true else redraw_trash_virtual_text(bufnr) end debounce_timer:start( 50, 0, vim.schedule_wrap(function() if pending then pending = false redraw_trash_virtual_text(bufnr) end end) ) end, }) end M.render_buffer_async(bufnr, {}, function(err) if err then vim.notify( string.format('Error rendering canola buffer %s: %s', vim.api.nvim_buf_get_name(bufnr), err), vim.log.levels.ERROR ) else vim.b[bufnr].canola_ready = true vim.api.nvim_exec_autocmds( 'User', { pattern = 'CanolaEnter', modeline = false, data = { buf = bufnr } } ) end end) keymap_util.set_keymaps(config.keymaps, bufnr) end ---@param adapter canola.Adapter ---@param num_entries integer ---@return fun(a: canola.InternalEntry, b: canola.InternalEntry): boolean local function get_sort_function(adapter, num_entries) local idx_funs = {} local sort_config = config.view_options.sort -- If empty, default to type + name sorting if vim.tbl_isempty(sort_config) then sort_config = { { 'type', 'asc' }, { 'name', 'asc' } } end for _, sort_pair in ipairs(sort_config) do local col_name, order = unpack(sort_pair) if order ~= 'asc' and order ~= 'desc' then vim.notify_once( string.format( "Column '%s' has invalid sort order '%s'. Should be either 'asc' or 'desc'", col_name, order ), vim.log.levels.WARN ) end local col = columns.get_column(adapter, col_name) if col and col.create_sort_value_factory then table.insert(idx_funs, { col.create_sort_value_factory(num_entries), order }) elseif col and col.get_sort_value then table.insert(idx_funs, { col.get_sort_value, order }) else vim.notify_once( string.format("Column '%s' does not support sorting", col_name), vim.log.levels.WARN ) end end return function(a, b) for _, sort_fn in ipairs(idx_funs) do local get_sort_value, order = sort_fn[1], sort_fn[2] local a_val = get_sort_value(a) local b_val = get_sort_value(b) if a_val ~= b_val then if order == 'desc' then return a_val > b_val else return a_val < b_val end end end return a[FIELD_NAME] < b[FIELD_NAME] 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, true) if not scheme or not adapter then return false end local entries = cache.list_url(bufname) local entry_list = vim.tbl_values(entries) -- Only sort the entries once we have them all if not vim.b[bufnr].canola_rendering then table.sort(entry_list, get_sort_function(adapter, #entry_list)) 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 = {} local col_align = {} for i, col_def in ipairs(column_defs) do col_width[i + 1] = 1 local _, conf = util.split_config(col_def) col_align[i + 1] = conf and conf.align or 'left' end local parent_entry = { 0, '..', 'directory' } if M.should_display(bufnr, parent_entry) then local cols = M.format_entry_cols(parent_entry, column_defs, col_width, adapter, true, bufnr) table.insert(line_table, cols) end for _, entry in ipairs(entry_list) do local should_display, is_hidden = M.should_display(bufnr, entry) if should_display then local cols = M.format_entry_cols(entry, column_defs, col_width, adapter, is_hidden, bufnr) 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 end end end if config.view_options.show_hidden_when_empty and #line_table <= 1 then for _, entry in ipairs(entry_list) do local name = entry[FIELD_NAME] local public_entry = util.export_entry(entry) if not config.view_options.is_always_hidden(name, bufnr, public_entry) then local cols = M.format_entry_cols(entry, column_defs, col_width, adapter, true, bufnr) table.insert(line_table, cols) if seek_after_render == name then seek_after_render_found = true jump_idx = #line_table end end end end local lines, highlights = util.render_table(line_table, col_width, col_align) 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) 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 jump_idx then local lnum = jump_idx 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 }) return end end end constrain_cursor(bufnr, 'name') end end end) end return seek_after_render_found end ---@param name string ---@param meta? table ---@return string filename ---@return string|nil link_target local function get_link_text(name, 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:gsub('\n', '') if meta.link_stat and meta.link_stat.type == 'directory' then link_text = util.addslash(link_text) end end end return name, link_text end ---@private ---@param entry canola.InternalEntry ---@param column_defs table[] ---@param col_width integer[] ---@param adapter canola.Adapter ---@param is_hidden boolean ---@param bufnr integer ---@return canola.TextChunk[] M.format_entry_cols = function(entry, column_defs, col_width, adapter, is_hidden, bufnr) local name = entry[FIELD_NAME] local meta = entry[FIELD_META] local hl_suffix = '' if is_hidden then hl_suffix = 'Hidden' end if meta and meta.display_name then name = meta.display_name end -- We can't handle newlines in filenames (and shame on you for doing that) name = name:gsub('\n', '') -- 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, bufnr) local text = type(chunk) == 'table' and chunk[1] or chunk ---@cast text string 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] local get_custom_hl = config.view_options.highlight_filename local link_name, link_name_hl, link_target, link_target_hl if get_custom_hl then local external_entry = util.export_entry(entry) if entry_type == 'link' then link_name, link_target = get_link_text(name, meta) local is_orphan = not (meta and meta.link_stat) link_name_hl = get_custom_hl(external_entry, is_hidden, false, is_orphan, bufnr) if link_target then link_target_hl = get_custom_hl(external_entry, is_hidden, true, is_orphan, bufnr) end -- intentional fallthrough else local hl = get_custom_hl(external_entry, is_hidden, false, false, bufnr) if hl then -- Add the trailing / if this is a directory, this is important if entry_type == 'directory' then name = name .. '/' end table.insert(cols, { name, hl }) return cols end end end local highlight_as_executable = false if entry_type ~= 'directory' then local lower = name:lower() if lower:match('%.exe$') or lower:match('%.bat$') or lower:match('%.cmd$') or lower:match('%.com$') or lower:match('%.ps1$') then highlight_as_executable = true -- selene: allow(if_same_then_else) elseif is_unix_executable(entry) then highlight_as_executable = true end end if entry_type == 'directory' then table.insert(cols, { name .. '/', 'CanolaDir' .. hl_suffix }) elseif entry_type == 'socket' then table.insert(cols, { name, 'CanolaSocket' .. hl_suffix }) elseif entry_type == 'link' then if not link_name then link_name, link_target = get_link_text(name, meta) end local is_orphan = not (meta and meta.link_stat) if not link_name_hl then if highlight_as_executable then link_name_hl = 'CanolaExecutable' .. hl_suffix else link_name_hl = (is_orphan and 'CanolaOrphanLink' or 'CanolaLink') .. hl_suffix end end table.insert(cols, { link_name, link_name_hl }) if link_target then if not link_target_hl then link_target_hl = (is_orphan and 'CanolaOrphanLinkTarget' or 'CanolaLinkTarget') .. hl_suffix end table.insert(cols, { link_target, link_target_hl }) end elseif highlight_as_executable then table.insert(cols, { name, 'CanolaExecutable' .. hl_suffix }) else table.insert(cols, { name, 'CanolaFile' .. hl_suffix }) end return cols end ---Get the column names that are used for view and sort ---@return string[] local function get_used_columns() local cols = {} for _, def in ipairs(config.columns) do local name = util.split_config(def) table.insert(cols, name) end for _, sort_pair in ipairs(config.view_options.sort) do local name = sort_pair[1] table.insert(cols, name) end return cols end ---@type table local pending_renders = {} ---@param bufnr integer ---@param opts nil|table --- refetch nil|boolean Defaults to true ---@param caller_callback nil|fun(err: nil|string) M.render_buffer_async = function(bufnr, opts, caller_callback) local function callback(err) if not err then vim.api.nvim_exec_autocmds( 'User', { pattern = 'CanolaReadPost', modeline = false, data = { buf = bufnr } } ) end if caller_callback then caller_callback(err) end end opts = vim.tbl_deep_extend('keep', opts or {}, { refetch = true, }) ---@cast opts -nil if bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end -- If we're already rendering, queue up another rerender after it's complete if vim.b[bufnr].canola_rendering then if not pending_renders[bufnr] then pending_renders[bufnr] = { callback } elseif callback then table.insert(pending_renders[bufnr], callback) end return end local bufname = vim.api.nvim_buf_get_name(bufnr) vim.b[bufnr].canola_rendering = true local _, dir = util.parse_url(bufname) -- Undo should not return to a blank buffer -- Method taken from :h clear-undo vim.bo[bufnr].undolevels = -1 local handle_error = vim.schedule_wrap(function(message) vim.b[bufnr].canola_rendering = false vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value('undolevels', { scope = 'global' }) util.render_text(bufnr, { 'Error: ' .. message }) if pending_renders[bufnr] then for _, cb in ipairs(pending_renders[bufnr]) do cb(message) end pending_renders[bufnr] = nil end if callback then callback(message) else error(message) end end) if not dir then handle_error(string.format("Could not parse canola url '%s'", bufname)) return end local adapter = util.get_adapter(bufnr, true) if not adapter then handle_error(string.format("[canola] no adapter for buffer '%s'", bufname)) return end local start_ms = uv.hrtime() / 1e6 local seek_after_render_found = false local first = true vim.bo[bufnr].modifiable = false vim.bo[bufnr].modified = false loading.set_loading(bufnr, true) local finish = vim.schedule_wrap(function() if not vim.api.nvim_buf_is_valid(bufnr) then return end vim.b[bufnr].canola_rendering = false loading.set_loading(bufnr, false) render_buffer(bufnr, { jump = true }) M.set_last_cursor(bufname, nil) vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value('undolevels', { scope = 'global' }) vim.bo[bufnr].modifiable = not buffers_locked and adapter.is_modifiable(bufnr) if callback then callback() end -- If there were any concurrent calls to render this buffer, process them now if pending_renders[bufnr] then local all_cbs = pending_renders[bufnr] pending_renders[bufnr] = nil local new_cb = function(...) for _, cb in ipairs(all_cbs) do cb(...) end end M.render_buffer_async(bufnr, {}, new_cb) end end) if not opts.refetch then finish() return end cache.begin_update_url(bufname) local num_iterations = 0 adapter.list(bufname, get_used_columns(), function(err, entries, fetch_more) loading.set_loading(bufnr, false) if err then cache.end_update_url(bufname) handle_error(err) return end if entries then for _, entry in ipairs(entries) do cache.store_entry(bufname, entry) end end if fetch_more then local now = uv.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 > 25 and num_iterations < 1) or delta > 500 then num_iterations = num_iterations + 1 start_ms = now vim.schedule(function() seek_after_render_found = render_buffer(bufnr, { jump = not seek_after_render_found, jump_first = first }) start_ms = uv.hrtime() / 1e6 end) end first = false vim.defer_fn(fetch_more, 4) else cache.end_update_url(bufname) -- done iterating finish() end end) end return M