local auto = require 'std.auto' local map = require 'std.map' local table = require 'std.table' local history = {} local state = { timer = nil, source_buf = nil, source_win = nil, popup_win = nil, popup_buf = nil, selecting_idx = 1, buffers = {}, hl_ns = nil, } local SETTLE_MS = 1000 local MAX_HEIGHT = 100 local ICON_WIDTH = 2 local PATH_START_OFFSET = 3 local function get_icon(buf_path) local ok, icon_data = pcall(require, 'mini.icons') if not ok then return ' ', '' end local icon, hl = icon_data.get('file', buf_path) return icon or ' ', hl or '' end local function truncate(str, max) if #str > max then return str:sub(1, max - 1) .. '…' end return str end local function shorten_path(full_path, max_len) local basename = vim.fs.basename(full_path) local abs_cwd = vim.fs.normalize(vim.fn.getcwd()) local abs_path = vim.fs.normalize(full_path) if abs_path:find(abs_cwd, 1, true) ~= 1 then return #basename <= max_len and basename or truncate(basename, max_len) end local rel_path = abs_path:sub(#abs_cwd + 1) if rel_path:sub(1, 1) == '/' then rel_path = rel_path:sub(2) end rel_path = './' .. rel_path if #rel_path <= max_len then return rel_path end if #basename >= max_len then return truncate(basename, max_len) end local available = max_len - #basename - 1 if available <= 0 then return basename end if #rel_path - #basename > available then local dirname_len = available - 1 if dirname_len > 0 then local dirname_start = #rel_path - #basename - dirname_len rel_path = './…/' .. rel_path:sub(dirname_start) else rel_path = './…' end end return rel_path end local function render_item(b, idx, selecting_idx, width) local prefix = idx == selecting_idx and '› ' or ' ' local full_path = b.name local icon, icon_hl = get_icon(full_path) local icon_len = #icon local rel_path = shorten_path(full_path, width - #prefix - icon_len - 1) local line = prefix .. icon .. ' ' .. rel_path local basename = vim.fs.basename(full_path) local basename_in_line = icon .. ' ' .. rel_path local name_start_byte = basename_in_line:find(basename, 1, true) local name_start = #prefix + (name_start_byte or 1) local name_end_exclusive = name_start + #basename local info = { line_idx = idx - 1, icon_hl = icon_hl ~= '' and icon_hl or nil, name_start = name_start, name_end_exclusive = name_end_exclusive, } return line, info end local function render_list(buffers, selecting_idx, width) local lines = {} local highlights = {} for i, b in ipairs(buffers) do local line, info = render_item(b, i, selecting_idx, width) lines[i] = line highlights[i] = info end return lines, highlights end local function get_listed_buffers() local cur = vim.api.nvim_get_current_buf() local result = {} for _, bufnr in ipairs(history) do if vim.api.nvim_buf_is_valid(bufnr) and vim.bo[bufnr].buflisted then local name = vim.api.nvim_buf_get_name(bufnr) if name ~= '' then table.insert(result, { bufnr = bufnr, name = name }) end end end for _, info in ipairs(vim.fn.getbufinfo { buflisted = 1 }) do local bufnr = info.bufnr if vim.api.nvim_buf_is_valid(bufnr) and vim.bo[bufnr].buflisted and info.name ~= '' then local found = false for _, b in ipairs(result) do if b.bufnr == bufnr then found = true break end end if not found then table.insert(result, { bufnr = bufnr, name = info.name }) end end end return result end local function close_popup() if state.popup_win and type(state.popup_win) == 'number' then local ok, valid = pcall(vim.api.nvim_win_is_valid, state.popup_win) if ok and valid then pcall(vim.api.nvim_win_close, state.popup_win, true) end end state.popup_win = nil state.popup_buf = nil state.selecting_idx = 1 state.buffers = {} state.hl_ns = nil end local function cancel() if state.timer then state.timer:stop() state.timer:close() state.timer = nil end close_popup() if state.source_buf then local ok, valid = pcall(vim.api.nvim_buf_is_valid, state.source_buf) if ok and valid then pcall(vim.api.nvim_set_current_buf, state.source_buf) end end end local function commit_switch() local target = state.buffers[state.selecting_idx] if target then close_popup() vim.api.nvim_set_current_buf(target.bufnr) for i = #history, 1, -1 do if history[i] == target.bufnr then table.remove(history, i) end end table.insert(history, target.bufnr) if #history > 50 then table.remove(history, 1) end else close_popup() end end local function apply_highlights(bufnr, hl_ns, highlights) for _, h in ipairs(highlights) do if h.icon_hl then vim.hl.range(bufnr, hl_ns, h.icon_hl, { h.line_idx, 0 }, { h.line_idx, 2 }) end vim.hl.range( bufnr, hl_ns, 'CycleBufferFileName', { h.line_idx, h.name_start - 1 }, { h.line_idx, h.name_end_exclusive - 1 } ) end end local function clear_highlights() if state.hl_ns and state.popup_buf and vim.api.nvim_buf_is_valid(state.popup_buf) then vim.api.nvim_buf_clear_namespace(state.popup_buf, state.hl_ns, 0, -1) end end local function open_popup() close_popup() state.buffers = get_listed_buffers() if #state.buffers == 0 then return end if state.selecting_idx > #state.buffers then state.selecting_idx = 1 end local width = math.min(55, vim.o.columns - 4) local height = math.min(#state.buffers, MAX_HEIGHT) local row = math.floor((vim.o.lines - height) / 2) - 1 local col = math.floor((vim.o.columns - width) / 2) state.popup_buf = vim.api.nvim_create_buf(false, true) state.hl_ns = vim.api.nvim_create_namespace('cycle_buffer') local lines, highlights = render_list(state.buffers, state.selecting_idx, width) vim.api.nvim_buf_set_lines(state.popup_buf, 0, -1, false, lines) vim.bo[state.popup_buf].modifiable = false vim.bo[state.popup_buf].filetype = 'CycleBuffer' local opts = { relative = 'editor', width = width, height = height, row = row, col = col, style = 'minimal', border = vim.g.border_style, focusable = false, } state.popup_win = vim.api.nvim_open_win(state.popup_buf, true, opts) vim.wo[state.popup_win].cursorline = true apply_highlights(state.popup_buf, state.hl_ns, highlights) map.normal { keys = '', command = function() BugabingaCycle.cycle(1) end, buffer = state.popup_buf } map.normal { keys = '', command = function() BugabingaCycle.cycle(-1) end, buffer = state.popup_buf } map.normal { keys = '', command = function() BugabingaCycle.cycle(1) end, buffer = state.popup_buf } map.normal { keys = '', command = function() BugabingaCycle.cycle(-1) end, buffer = state.popup_buf } map.normal { keys = '', command = cancel, buffer = state.popup_buf } map.normal { keys = 'q', command = cancel, buffer = state.popup_buf } end local function cycle(direction) if state.timer then state.timer:stop() state.timer:close() state.timer = nil end if not state.popup_win then state.source_win = vim.api.nvim_get_current_win() state.source_buf = vim.api.nvim_get_current_buf() state.selecting_idx = 1 open_popup() if not state.popup_win then vim.notify('buffer cycle: No other buffers', vim.log.levels.INFO) return end else state.selecting_idx = state.selecting_idx + direction if state.selecting_idx < 1 then state.selecting_idx = #state.buffers elseif state.selecting_idx > #state.buffers then state.selecting_idx = 1 end if state.popup_buf and vim.api.nvim_buf_is_valid(state.popup_buf) then local width = math.min(55, vim.o.columns - 4) local lines, highlights = render_list(state.buffers, state.selecting_idx, width) clear_highlights() local was_modifiable = vim.bo[state.popup_buf].modifiable vim.bo[state.popup_buf].modifiable = true vim.api.nvim_buf_set_lines(state.popup_buf, 0, -1, false, lines) vim.bo[state.popup_buf].modifiable = was_modifiable apply_highlights(state.popup_buf, state.hl_ns, highlights) vim.api.nvim_win_set_cursor(state.popup_win, { state.selecting_idx, 0 }) end end local timer = assert(vim.uv.new_timer()) timer:start(SETTLE_MS, 0, vim.schedule_wrap(function() timer:stop() timer:close() state.timer = nil commit_switch() end)) state.timer = timer end auto 'cycle_mru' { events = 'BufEnter', command = function() if state.popup_win then return end local bufnr = vim.api.nvim_get_current_buf() if not vim.bo[bufnr].buflisted then return end local name = vim.api.nvim_buf_get_name(bufnr) if name == '' then return end for i = #history, 1, -1 do if history[i] == bufnr then table.remove(history, i) end end table.insert(history, bufnr) if #history > 50 then table.remove(history, 1) end end, } map.normal { description = 'Cycle to next MRU buffer.', category = 'cycle', keys = '', command = function() cycle(1) end, } map.normal { description = 'Cycle to previous MRU buffer.', category = 'cycle', keys = '', command = function() cycle(-1) end, } auto 'cycle_bufwipeout' { events = 'BufWipeout', command = function(args) for i = #history, 1, -1 do if history[i] == args.buf then table.remove(history, i) end end end, } auto 'cycle_vimleave' { events = 'VimLeave', once = true, command = function() cancel() end, } _G.BugabingaCycle = { cycle = cycle, cancel = cancel, history = history, get_icon = get_icon, shorten_path = shorten_path, } return BugabingaCycle