---Debug event viewer — special buffer UI for browsing debug events. --- ---## Public API --- ---| Function | Description | ---| ----------------------- | ------------------------------------------------ | ---| `open()` | Open viewer (reuse existing window if open) | ---| `close()` | Close viewer and delete its buffer + watcher | ---| `refresh()` | Re-read all events from disk and re-render | ---| `is_open()` | Returns `true` if viewer window is valid | ---| `jump_to_source(win_id)`| Jump to the source file:line of event at cursor | ---| `clear_filters()` | Remove all active filters | ---| `filter_by_kind()` | Prompt for kind filter (fk) | ---| `filter_by_message()` | Prompt for message filter (fm) | ---| `filter_by_location()` | Prompt for filename filter (fl) | ---| `filter_by_all()` | Prompt for combined filter (ff) | ---| `get_winbar()` | Returns winbar text (used by format string) | --- ---## Keymaps (in viewer buffer) --- ---| Key | Action | ---| ----- | -------------------------- | ---| `q` | Close viewer | ---| `` | Close viewer | ---| `r` | Refresh events from disk | ---| `` | Jump to source location | ---| `ff` | Filter combined (all) | ---| `fc` | Clear filters + notify | ---| `fk` | Filter by kind | ---| `fm` | Filter by message | ---| `fl` | Filter by location | --- ---## Layout --- ---- Opens as a **bottom split** (`botright split`), respecting `splitbelow` ---- Height is **golden ratio** (~61.8% of screen height) ---- Each event renders as a **4-line block**: header, message, location, blank ---- Events sorted **newest-first** (by date descending) ---- Auto-refreshes via `vim.uv.fs_event` when debug file changes --- ---## Filtering --- ---- Filters use `vim.fn.matchfuzzy` for fuzzy matching ---- Multiple filters combine with **AND** logic ---- `ff` prompts for combined filter across kind + message + filename ---- `fc` clears all filters and shows `vim.notify` ---- Active filters displayed in **winbar** ---- Event count shown as `filtered/total` when filters active --- ---## Events Structure --- ---Events loaded from `std.dbg.file` (JSONL). Each event: ---```lua ---{ --- kind = "DEBUG" | "TRACE" | "ERROR", --- date = os.time() integer, --- message = formatted string, --- data = { ... }, -- raw user data --- filename = "@source.lua" or "[string ...]", --- fn = "function_name", --- line = 123, ---} ---``` --- ---## Highlights --- ---| Highlight Group | Links To | Applied To | ---| ------------------------ | ---------------- | ------------------- | ---| `DbgViewerKindIcon` | `Title` | Kind icon in header | ---| `DbgViewerKind` | `Comment` | Kind name in header | ---| `DbgViewerTime` | `Normal` | Timestamp in header | ---| `DbgViewerMessage` | `Comment` | Message lines | ---| `DbgViewerLocation` | `Comment` | Location lines | ---| (kind-specific) | `DiagnosticInfo/Hint/Error` | Icon backgrounds | --- ---## Internal Architecture --- ---- `H.state` — module-local state: --- ```lua --- { --- buf_id = number, --- win_id = number, --- events = event[], --- filtered_indices = number[] | nil, --- filters = { all = "", kind = "", message = "", filename = "" }, --- line_events = table, -- line_idx (1-based) -> event_idx (1-based) --- filtered_count = number, -- how many events currently displayed --- event_count = number, -- total events read from file --- watcher = uv_fs_event | nil, --- highlights = highlight[], -- all active highlights for re-apply on prepend --- } --- ``` ---- `H.ns_id` — namespace for buffer highlights ---- `H.BUF_NAME_PREFIX` — buffer name pattern: `dbgviewer:///debug-events` ---- `H.FT` — filetype set on viewer buffer: `DbgViewer` local file = require 'std.dbg.file' local kinds = require 'std.dbg.kinds' local timeago = require 'std.timeago' local map = require 'std.map' local start_watcher local on_file_change local H = { state = nil, } H.ns_id = vim.api.nvim_create_namespace 'std.dbg.viewer' H.BUF_NAME_PREFIX = 'dbgviewer://' H.FT = 'DbgViewer' local NS_HL = { kind_icon = 'DbgViewerKindIcon', kind_name = 'DbgViewerKind', time = 'DbgViewerTime', message = 'DbgViewerMessage', location = 'DbgViewerLocation', } local KIND_TO_HL = { DEBUG = 'DiagnosticInfo', TRACE = 'DiagnosticHint', ERROR = 'DiagnosticError', } local WINBAR_FORMAT = '%%#%s#%%{v:lua.require("std.dbg.viewer").get_winbar()}%%#Normal#' ---@type { key: string, prefix: string, suffix: string }[] local FILTER_WINBAR_SPECS = { { key = 'all', prefix = ' all:"', suffix = '"' }, { key = 'kind', prefix = ' kind:', suffix = '' }, { key = 'message', prefix = ' msg:"', suffix = '"' }, { key = 'filename',prefix = ' file:', suffix = '' }, } local function sanitize_src(src) if not src or src == '' then return 'unknown' end return (src:gsub('^@', ''):gsub('^%[string .+%]$', 'unknown')) end local FILTER_SPECS = { { key = 'all', extract = function(ev) return ev.kind .. ' ' .. ev.message .. ' ' .. sanitize_src(ev.filename) end, }, { key = 'kind', extract = function(ev) return ev.kind end, }, { key = 'message', extract = function(ev) return ev.message end, }, { key = 'filename', extract = function(ev) return sanitize_src(ev.filename) end, }, } local function get_kind_hl_group(kind_name) return KIND_TO_HL[kind_name] or 'DiagnosticInfo' end local function make_winbar() return string.format(WINBAR_FORMAT, NS_HL.kind_icon) end local function get_winbar() if not H.state or not H.state.win_id then return '' end local s = assert(H.state) local parts = {} local has_filter = false local filters = s.filters or {} for _, spec in ipairs(FILTER_WINBAR_SPECS) do local val = filters[spec.key] if val and val ~= '' then parts[#parts + 1] = spec.prefix .. val .. spec.suffix has_filter = true end end local filter_str = has_filter and table.concat(parts, '') or '' local count = s.filtered_count or 0 local total = #(s.events or {}) local count_str = count == total and tostring(total) or count .. '/' .. total return ' ' .. kinds.get('DEBUG').icon .. ' Debug [' .. count_str .. ' events]' .. filter_str end local function format_event_for_display(event) local kind_info = kinds.get(event.kind) local icon = kind_info.icon local rel_time = timeago.format(event.date) local src = sanitize_src(event.filename) local loc = src .. ':' .. (event.line or '?') local msg = event.message or '(no message)' return { icon = icon, kind_name = event.kind, time = rel_time, message = msg, location = loc, } end local function build_lines_and_hl(events) local lines = {} local line_events = {} local highlights = {} if #events == 0 then lines[1] = ' (no debug events)' line_events[1] = nil return lines, line_events, highlights end for ei, event in ipairs(events) do local d = format_event_for_display(event) local header = string.format(' %s %s · %s', d.icon, d.kind_name, d.time) local msg_line = ' ' .. d.message local loc_line = ' ' .. d.location local header_line_idx = #lines + 1 lines[#lines + 1] = header line_events[header_line_idx] = ei highlights[#highlights + 1] = { line = header_line_idx, kind = event.kind, icon_start = 1, icon_end = #d.icon + 1, kind_name_start = #d.icon + 2, kind_name_end = #d.icon + 2 + #d.kind_name, time_start = #header - #d.time, time_end = #header, } local msg_line_idx = #lines + 1 lines[#lines + 1] = msg_line line_events[msg_line_idx] = ei local loc_line_idx = #lines + 1 lines[#lines + 1] = loc_line line_events[loc_line_idx] = ei highlights[#highlights + 1] = { line = loc_line_idx, location = true, } local blank_idx = #lines + 1 lines[#lines + 1] = '' line_events[blank_idx] = nil end return lines, line_events, highlights end local function apply_highlight(buf_id, group, lnum, start_col, end_col) if end_col == -1 then end_col = #(vim.api.nvim_buf_get_lines(buf_id, lnum, lnum + 1, false)[1] or '') end vim.hl.range(buf_id, H.ns_id, group, { lnum, start_col }, { lnum, end_col }) end local function apply_highlights(buf_id, highlights) vim.api.nvim_buf_clear_namespace(buf_id, H.ns_id, 0, -1) for _, hl in ipairs(highlights) do local lnum = hl.line - 1 if hl.location then apply_highlight(buf_id, NS_HL.location, lnum, 0, -1) elseif hl.kind then apply_highlight(buf_id, get_kind_hl_group(hl.kind), lnum, hl.icon_start - 1, hl.icon_end - 1) apply_highlight(buf_id, NS_HL.kind_name, lnum, hl.kind_name_start - 1, hl.kind_name_end - 1) apply_highlight(buf_id, NS_HL.time, lnum, hl.time_start - 1, hl.time_end - 1) else apply_highlight(buf_id, NS_HL.message, lnum, 0, -1) end end end local function create_buf() local buf_id = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_name(buf_id, H.BUF_NAME_PREFIX .. buf_id .. '/debug-events') vim.bo[buf_id].buftype = 'nofile' vim.bo[buf_id].bufhidden = 'wipe' vim.bo[buf_id].buflisted = false vim.bo[buf_id].modifiable = false vim.bo[buf_id].swapfile = false vim.bo[buf_id].filetype = H.FT vim.bo[buf_id].modeline = false return buf_id end local function open_win(buf_id) local height = math.max(5, math.floor(0.618 * vim.o.lines)) vim.cmd('botright split') local win_id = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(win_id, buf_id) vim.wo[win_id].wrap = true vim.wo[win_id].scrolloff = 0 vim.wo[win_id].cursorline = true vim.wo[win_id].winbar = make_winbar() vim.api.nvim_win_set_height(win_id, height) return win_id end local function event_passes_filters(event, filters) for _, spec in ipairs(FILTER_SPECS) do local fval = filters[spec.key] if fval and fval ~= '' then local ftext = spec.extract(event) if #vim.fn.matchfuzzy({ ftext }, fval) == 0 then return false end end end return true end local function compute_filtered_indices(events, filters) if not filters then return nil end local has_any_filter = false for _, spec in ipairs(FILTER_SPECS) do local fval = filters[spec.key] if fval and fval ~= '' then has_any_filter = true break end end if not has_any_filter then return nil end local result = {} for i, ev in ipairs(events) do if event_passes_filters(ev, filters) then result[#result + 1] = i end end return result end local function render() if not H.state or not H.state.buf_id then return end local events = H.state.events or {} local filtered_indices = H.state.filtered_indices local display_events = filtered_indices and (function() local r = {} for _, idx in ipairs(filtered_indices) do r[#r+1] = events[idx] end return r end)() or events local lines, line_events, highlights = build_lines_and_hl(display_events) H.state.line_events = line_events H.state.highlights = highlights H.state.filtered_count = #display_events local was_modifiable = vim.bo[H.state.buf_id].modifiable vim.bo[H.state.buf_id].modifiable = true vim.api.nvim_buf_set_lines(H.state.buf_id, 0, -1, false, lines) vim.bo[H.state.buf_id].modifiable = was_modifiable apply_highlights(H.state.buf_id, highlights) if H.state.win_id and vim.api.nvim_win_is_valid(H.state.win_id) then vim.wo[H.state.win_id].winbar = make_winbar() end end local function prepend_new_events(new_events) if not H.state or not H.state.buf_id then return end if #new_events == 0 then return end local new_display_events = {} for _, ev in ipairs(new_events) do if event_passes_filters(ev, H.state.filters) then new_display_events[#new_display_events + 1] = ev end end if #new_display_events == 0 then return end local new_lines = {} local new_highlights = {} local new_line_events = {} local base_idx = 1 for ei, event in ipairs(new_display_events) do local d = format_event_for_display(event) local header = string.format(' %s %s · %s', d.icon, d.kind_name, d.time) local msg_line = ' ' .. d.message local loc_line = ' ' .. d.location local header_line_idx = #new_lines + 1 new_lines[#new_lines + 1] = header new_line_events[header_line_idx] = base_idx + ei - 1 new_highlights[#new_highlights + 1] = { line = header_line_idx, kind = event.kind, icon_start = 1, icon_end = #d.icon + 1, kind_name_start = #d.icon + 2, kind_name_end = #d.icon + 2 + #d.kind_name, time_start = #header - #d.time, time_end = #header, } local msg_line_idx = #new_lines + 1 new_lines[#new_lines + 1] = msg_line new_line_events[msg_line_idx] = base_idx + ei - 1 local loc_line_idx = #new_lines + 1 new_lines[#new_lines + 1] = loc_line new_line_events[loc_line_idx] = base_idx + ei - 1 new_highlights[#new_highlights + 1] = { line = loc_line_idx, location = true, } local blank_idx = #new_lines + 1 new_lines[#new_lines + 1] = '' new_line_events[blank_idx] = nil end local was_modifiable = vim.bo[H.state.buf_id].modifiable vim.bo[H.state.buf_id].modifiable = true vim.api.nvim_buf_set_lines(H.state.buf_id, 0, 0, false, new_lines) vim.bo[H.state.buf_id].modifiable = was_modifiable local shift = #new_lines local shifted_line_events = {} for line_idx, event_idx in pairs(H.state.line_events) do shifted_line_events[line_idx + shift] = event_idx + #new_display_events end for line_idx, event_idx in pairs(new_line_events) do shifted_line_events[line_idx] = event_idx end H.state.line_events = shifted_line_events local all_highlights = {} for _, hl in ipairs(H.state.highlights or {}) do all_highlights[#all_highlights + 1] = { line = hl.line + shift, kind = hl.kind, location = hl.location, icon_start = hl.icon_start, icon_end = hl.icon_end, kind_name_start = hl.kind_name_start, kind_name_end = hl.kind_name_end, time_start = hl.time_start, time_end = hl.time_end, } end for _, hl in ipairs(new_highlights) do all_highlights[#all_highlights + 1] = hl end H.state.highlights = all_highlights apply_highlights(H.state.buf_id, all_highlights) H.state.filtered_count = H.state.filtered_count + #new_display_events if H.state.win_id and vim.api.nvim_win_is_valid(H.state.win_id) then vim.wo[H.state.win_id].winbar = make_winbar() end end local function on_file_change() if not H.state then return end local watcher = H.state.watcher if watcher then watcher:stop() H.state.watcher = nil end local new_events, new_total = file.read_new(H.state.event_count) if #new_events == 0 and new_total == H.state.event_count then H.state.watcher = start_watcher() return end local reversed = {} for i = #new_events, 1, -1 do reversed[#reversed + 1] = new_events[i] end new_events = reversed for i = #new_events, 1, -1 do table.insert(H.state.events, 1, new_events[i]) end H.state.event_count = new_total prepend_new_events(new_events) H.state.watcher = start_watcher() end start_watcher = function() local path = file.get_path() local callback = function(err) if err then return end on_file_change() end local watcher = assert(vim.uv.new_fs_event()) watcher:start(path, {}, callback) return watcher end local function setup_keymaps(buf_id, win_id) map.normal { keys = 'q', command = close, buffer = buf_id, desc = 'Close debug viewer' } map.normal { keys = '', command = close, buffer = buf_id, desc = 'Close debug viewer' } map.normal { keys = 'r', command = refresh, buffer = buf_id, desc = 'Refresh events from disk' } map.normal { keys = '', command = function() jump_to_source(win_id) end, buffer = buf_id, desc = 'Jump to source location' } map.normal { keys = 'ff', command = function() filter_by_all() end, buffer = buf_id, desc = 'Filter combined (all)' } map.normal { keys = 'fc', command = function() clear_filters() vim.notify('Filters cleared') end, buffer = buf_id, desc = 'Clear filters + notify' } map.normal { keys = 'fk', command = function() filter_by_kind() end, buffer = buf_id, desc = 'Filter by kind' } map.normal { keys = 'fm', command = function() filter_by_message() end, buffer = buf_id, desc = 'Filter by message' } map.normal { keys = 'fl', command = function() filter_by_location() end, buffer = buf_id, desc = 'Filter by location' } end function open() if H.state and H.state.win_id and vim.api.nvim_win_is_valid(H.state.win_id) then vim.api.nvim_set_current_win(H.state.win_id) return end local events = file.read_all() for i = 1, math.floor(#events / 2) do events[i], events[#events - i + 1] = events[#events - i + 1], events[i] end local buf_id = create_buf() local win_id = open_win(buf_id) setup_keymaps(buf_id, win_id) local watcher = start_watcher() H.state = { buf_id = buf_id, win_id = win_id, events = events, filtered_indices = nil, filters = { all = '', kind = '', message = '', filename = '' }, line_events = {}, filtered_count = #events, event_count = #events, watcher = watcher, } render() end function close() if not H.state then return end if H.state.watcher then H.state.watcher:stop() H.state.watcher = nil end if H.state.win_id and vim.api.nvim_win_is_valid(H.state.win_id) then pcall(vim.api.nvim_win_close, H.state.win_id, true) end if H.state.buf_id and vim.api.nvim_buf_is_valid(H.state.buf_id) then pcall(vim.api.nvim_buf_delete, H.state.buf_id, { force = true }) end H.state = nil end function refresh() if not H.state then return end local events = file.read_all() H.state.events = events H.state.event_count = #events H.state.filtered_indices = compute_filtered_indices(events, H.state.filters) render() end function is_open() return H.state ~= nil and H.state.win_id ~= nil and vim.api.nvim_win_is_valid(H.state.win_id) end function jump_to_source(win_id) if not H.state or not H.state.buf_id then return end local cursor = vim.api.nvim_win_get_cursor(win_id or H.state.win_id) local line_idx = cursor[1] local event_idx = H.state.line_events[line_idx] if not event_idx then event_idx = H.state.line_events[line_idx - 1] end if not event_idx then event_idx = H.state.line_events[line_idx + 1] end if not event_idx then return end local event = H.state.events[event_idx] if not event then return end local path = sanitize_src(event.filename) if path == 'unknown' or path == '' then return end close() vim.cmd.edit(path) if event.line and event.line > 0 then pcall(vim.api.nvim_win_set_cursor, 0, { event.line, 0 }) end end function clear_filters() if not H.state then return end H.state.filters = { all = '', kind = '', message = '', filename = '' } H.state.filtered_indices = nil H.state.filtered_count = #H.state.events render() end function filter_by_all() vim.ui.input({ prompt = 'Filter (all): ' }, function(value) if value == nil then return end if not H.state then return end H.state.filters.all = value or '' H.state.filtered_indices = compute_filtered_indices(H.state.events, H.state.filters) render() end) end local function do_filter(field, prompt_msg) vim.ui.input({ prompt = prompt_msg .. ': ' }, function(value) if value == nil then return end if not H.state then return end H.state.filters[field] = value or '' H.state.filtered_indices = compute_filtered_indices(H.state.events, H.state.filters) render() end) end function filter_by_kind() do_filter('kind', 'Filter kind') end function filter_by_message() do_filter('message', 'Filter message') end function filter_by_location() do_filter('filename', 'Filter location') end return { open = open, close = close, refresh = refresh, is_open = is_open, jump_to_source = jump_to_source, clear_filters = clear_filters, filter_by_all = filter_by_all, filter_by_kind = filter_by_kind, filter_by_message = filter_by_message, filter_by_location = filter_by_location, get_winbar = get_winbar, _on_file_change = on_file_change, _get_state = function() return H.state end, }