Now that WinResized does a simple `redraw!` on every window-geometry change, the accumulated defensive autocmds are redundant. Removed: - `OptionSet showtabline` handler — when bufferline uses an expr tabline format, the option itself never changes, so this never fired for the primary case we cared about. When it DOES fire (explicit `:set showtabline=N`), a window geometry change immediately follows and WinResized handles the redraw. - `BufEnter/BufWinEnter/TabEnter` fallback — was guarding against bars going missing on buffer transitions. In practice bars only get killed on editor resize (VimResized covers that), not on buffer changes. - Orphan bar cleanup in recover_chrome — earlier theory that the "duplicate turbo-debug" rows came from stale windows. User correctly diagnosed it as leftover render pixels; WinResized's redraw! is the actual fix. No orphans to clean up. - Internal `redraw!` in recover_chrome — redundant because when recover_chrome runs (VimResized), it creates splits which trigger WinResized which does the redraw for us. What's left: - VimResized: recreates bars killed by editor shrink + re-renders - WinResized: single `redraw!` to clear stale pixels on any shift Cleaner and easier to reason about. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1196 lines
48 KiB
Lua
1196 lines
48 KiB
Lua
local config = require("turbo-debug.config")
|
|
local help = require("turbo-debug.help")
|
|
|
|
local M = {}
|
|
|
|
local active = false
|
|
local active_win = nil
|
|
local saved_winbar = nil
|
|
|
|
-- Forward declaration so listeners registered in ensure_dapui (which is
|
|
-- defined earlier in the file than source_window) can still reference it
|
|
-- as an upvalue rather than a nil global.
|
|
local source_window
|
|
|
|
-- two floating chrome bars (both independent of lualine):
|
|
-- sbar = status bar pinned at the TOP of the editor.
|
|
-- Row 0: turbo-debug · {dap status} · DEBUG · STATE
|
|
-- Row 1: ─── separator
|
|
-- cbar = control bar pinned just above the statusline.
|
|
-- Row 0: ─── separator
|
|
-- Row 1: {controls} ❓ help
|
|
local sbar_win, sbar_buf = nil, nil
|
|
local cbar_win, cbar_buf = nil, nil
|
|
|
|
-- installed[buf][name] = { key = lhs, prev_n = <prev n-mode map>, prev_v = <prev v-mode map> }
|
|
local installed = {}
|
|
|
|
local dapui_initialized = false
|
|
local vt_initialized = false
|
|
|
|
-- ─── icon constants (Nerd Font Codicons, byte-escaped) ───────────────────────
|
|
-- All in the PUA range U+EA60..U+EC1E. Encoded as UTF-8 bytes so the source
|
|
-- file stays ASCII through any tool pipeline that strips PUA codepoints.
|
|
|
|
local ICON = {
|
|
-- Control-bar emoji — strictly supplementary-plane (U+1F000+) or
|
|
-- standalone-emoji-default (like 🛑) codepoints. No VS16-dependent
|
|
-- glyphs: those render as text-mode in half of modern terminals, which
|
|
-- is exactly the "some icons are blocks, some are chars" inconsistency
|
|
-- the last iteration had. Every icon here is guaranteed to render as a
|
|
-- colored emoji in Ghostty + any modern terminal with emoji support.
|
|
go = "\xf0\x9f\x9f\xa2", -- 🟢 green circle (U+1F7E2) — go / continue / running
|
|
start_rkt = "\xf0\x9f\x9a\x80", -- 🚀 rocket (U+1F680) — launch a new session
|
|
step_over = "\xf0\x9f\x91\xa3", -- 👣 footprints (U+1F463) — step
|
|
step_into = "\xf0\x9f\x94\xbd", -- 🔽 down-pointing red triangle (U+1F53D) — into
|
|
step_out = "\xf0\x9f\x94\xbc", -- 🔼 up-pointing red triangle (U+1F53C) — out
|
|
restart = "\xf0\x9f\x94\x84", -- 🔄 counterclockwise arrows (U+1F504). ♻️
|
|
-- (U+267B + VS16) doesn't render in the
|
|
-- user's font — VS16 forces emoji only
|
|
-- when the font has an emoji glyph, and
|
|
-- many don't. Supplementary plane is
|
|
-- guaranteed color.
|
|
stop = "\xf0\x9f\x9b\x91", -- 🛑 octagonal stop sign (U+1F6D1)
|
|
|
|
-- pane-title emoji (supplementary plane — reliably colorful)
|
|
scopes = "\xf0\x9f\x94\x8e", -- 🔎 magnifier (U+1F50E)
|
|
watches = "\xf0\x9f\x91\x81\xef\xb8\x8f", -- 👁️ eye (U+1F441 + VS16)
|
|
stacks = "\xf0\x9f\x93\x9a", -- 📚 books (U+1F4DA)
|
|
breakpoints = "\xf0\x9f\x94\xb4", -- 🔴 red circle (U+1F534)
|
|
terminal = "\xf0\x9f\x92\xbb", -- 💻 laptop (U+1F4BB)
|
|
repl = "\xf0\x9f\x92\xac", -- 💬 speech balloon (U+1F4AC)
|
|
|
|
-- brand / state emoji
|
|
bug = "\xf0\x9f\x90\x9b", -- 🐛 bug (U+1F41B)
|
|
help = "\xe2\x9d\x93", -- ❓ black question mark ornament
|
|
-- (U+2753). It's BMP but is
|
|
-- Emoji_Presentation by default,
|
|
-- so modern terminals render it
|
|
-- as color. The previous attempt
|
|
-- appeared broken only because
|
|
-- of a misaligned extmark; with
|
|
-- that gone, ❓ renders fine.
|
|
|
|
-- IP arrows
|
|
ip_left = "\xf0\x9f\x91\x88", -- 👈 backhand pointing left (U+1F448)
|
|
|
|
-- misc
|
|
divider = "\xc2\xb7", -- · middle dot
|
|
ellipsis = "\xe2\x80\xa6", -- … horizontal ellipsis
|
|
wrap_mark = "\xe2\x86\xb3", -- ↳ downward arrow w/ tip right
|
|
hline = "\xe2\x94\x80", -- ─ light horizontal
|
|
}
|
|
|
|
-- ─── layout ──────────────────────────────────────────────────────────────────
|
|
|
|
local function build_default_layouts()
|
|
local pos = config.opts.sidebar == "right" and "right" or "left"
|
|
|
|
-- Console height: max(8 rows, 20% of total editor lines). More
|
|
-- generous than the previous 15% — user reported the Console kept
|
|
-- feeling "laughably small" so we're allocating real estate.
|
|
local console_size = config.opts.console_height
|
|
or math.max(8, math.floor(vim.o.lines * 0.2))
|
|
|
|
-- Sidebar width: ideal 40 cols, never less than 25, never more than 1/3
|
|
-- of tty width. Scales down gracefully on narrow terminals, stops at 40
|
|
-- on wide ones so the source area stays the star.
|
|
local sidebar_size = math.max(25, math.min(40, math.floor(vim.o.columns / 3)))
|
|
|
|
return {
|
|
{
|
|
position = pos,
|
|
size = sidebar_size,
|
|
-- Order: Call Stack → Scopes → Watches → Breakpoints.
|
|
-- Equal 25% share per user preference.
|
|
elements = {
|
|
{ id = "stacks", size = 0.25 },
|
|
{ id = "scopes", size = 0.25 },
|
|
{ id = "watches", size = 0.25 },
|
|
{ id = "breakpoints", size = 0.25 },
|
|
},
|
|
},
|
|
{
|
|
-- Console only. REPL removed — hit E in debug mode to open it
|
|
-- as a floating widget when needed.
|
|
position = "bottom",
|
|
size = console_size,
|
|
elements = {
|
|
{ id = "console", size = 1.0 },
|
|
},
|
|
},
|
|
}
|
|
end
|
|
|
|
-- ─── highlights ──────────────────────────────────────────────────────────────
|
|
|
|
local function define_highlights()
|
|
local hl = vim.api.nvim_set_hl
|
|
hl(0, "TurboDebugBorderActive", { default = true, link = "Function" })
|
|
hl(0, "TurboDebugBorderInactive", { default = true, link = "Comment" })
|
|
hl(0, "TurboDebugWinbar", { default = true, link = "DiagnosticError" })
|
|
-- Bar background: link to Normal so the bar blends with dapui's own
|
|
-- pane borders (which use Normal or WinBar — both share the editor's
|
|
-- default background). StatusLine was too dark and created a visible
|
|
-- seam between the bar and the surrounding panes.
|
|
hl(0, "TurboDebugBar", { default = true, link = "Normal" })
|
|
-- WinSeparator (not FloatBorder) so the bar edges match the dividers
|
|
-- dapui draws between its own panes. FloatBorder tends to read darker
|
|
-- than WinSeparator in most colorschemes, which created a visible
|
|
-- mismatch between our bar's bottom and the adjacent dapui pane top.
|
|
hl(0, "TurboDebugBarSeparator", { default = true, link = "WinSeparator" })
|
|
-- Control labels: link to Normal so the text blends with the bar's
|
|
-- Normal-bg surface. StatusLine bg was creating a visible color patch
|
|
-- behind each label. The key letter still pops via TurboDebugBarKey
|
|
-- (Special fg, bold), and icons carry their own emoji color.
|
|
hl(0, "TurboDebugBarCtrl", { default = true, link = "Normal" })
|
|
hl(0, "TurboDebugBarStatus", { default = true, link = "Comment" })
|
|
hl(0, "TurboDebugBarBrand", { default = true, link = "Title" })
|
|
hl(0, "TurboDebugBarReady", { default = true, link = "DiagnosticHint" })
|
|
hl(0, "TurboDebugBarRunning", { default = true, link = "DiagnosticInfo" })
|
|
hl(0, "TurboDebugBarPaused", { default = true, link = "DiagnosticError" })
|
|
|
|
-- The key letter inside (c)ontinue etc. gets bold + Special's fg.
|
|
-- No underline — the descender on lowercase q/g/p/y overlaps the
|
|
-- underline and makes the glyph unreadable (q reads as g, etc.).
|
|
-- nvim_set_hl can't combine link + bold, so copy Special's fg explicitly.
|
|
-- Re-applied on ColorScheme so theme swaps don't blank it out.
|
|
local function apply_key_hl()
|
|
local ok, src = pcall(vim.api.nvim_get_hl, 0, { name = "Special", link = false })
|
|
if not ok or not src or not src.fg then
|
|
vim.api.nvim_set_hl(0, "TurboDebugBarKey", { default = true, link = "Special" })
|
|
return
|
|
end
|
|
vim.api.nvim_set_hl(0, "TurboDebugBarKey", {
|
|
fg = src.fg, bg = src.bg, bold = true, default = true,
|
|
})
|
|
end
|
|
apply_key_hl()
|
|
vim.api.nvim_create_autocmd("ColorScheme", {
|
|
group = vim.api.nvim_create_augroup("TurboDebugBarKeyHL", { clear = true }),
|
|
callback = apply_key_hl,
|
|
})
|
|
end
|
|
|
|
local function setup_active_win_highlights()
|
|
local active_hl = "WinSeparator:TurboDebugBorderActive,FloatBorder:TurboDebugBorderActive"
|
|
local inactive_hl = "WinSeparator:TurboDebugBorderInactive,FloatBorder:TurboDebugBorderInactive"
|
|
|
|
local group = vim.api.nvim_create_augroup("TurboDebugActiveWin", { clear = true })
|
|
local function apply(win, hl)
|
|
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_config(win).relative == "" then
|
|
local ft = vim.bo[vim.api.nvim_win_get_buf(win)].filetype or ""
|
|
if ft:match("^dapui_") or ft == "dap-repl" then
|
|
vim.wo[win].winhighlight = hl
|
|
end
|
|
end
|
|
end
|
|
vim.api.nvim_create_autocmd("WinEnter", {
|
|
group = group,
|
|
callback = function() apply(vim.api.nvim_get_current_win(), active_hl) end,
|
|
})
|
|
vim.api.nvim_create_autocmd("WinLeave", {
|
|
group = group,
|
|
callback = function() apply(vim.api.nvim_get_current_win(), inactive_hl) end,
|
|
})
|
|
end
|
|
|
|
-- ─── chrome bars (top = status, bottom = controls) ─────────────────────────
|
|
--
|
|
-- Both bars are floating windows with buffer-rendered content (winbar
|
|
-- doesn't render reliably in minimal-style floats). Highlights via
|
|
-- extmarks; mouse click dispatch via buffer-local <LeftMouse> keymap +
|
|
-- per-zone display-column lookup.
|
|
|
|
local bar_ns = vim.api.nvim_create_namespace("turbo-debug.bar")
|
|
|
|
-- click zones, one table per bar. Each entry: { line_1based, dcol_start, dcol_end, fn }
|
|
local sbar_zones = {}
|
|
local cbar_zones = {}
|
|
|
|
local function dap_state()
|
|
local ok, dap = pcall(require, "dap")
|
|
if not ok then return "READY", "TurboDebugBarReady" end
|
|
local s = dap.session()
|
|
if not s then return "READY", "TurboDebugBarReady" end
|
|
if s.stopped_thread_id then return "PAUSED", "TurboDebugBarPaused" end
|
|
return "RUNNING", "TurboDebugBarRunning"
|
|
end
|
|
|
|
local function action(name)
|
|
return function() local a = M.actions(); if a[name] then a[name]() end end
|
|
end
|
|
|
|
local function keyof(name, fallback)
|
|
local k = config.opts.keys[name]
|
|
if type(k) == "string" then return k end
|
|
return fallback
|
|
end
|
|
|
|
-- Build a label's byte-level representation: returns { text, hl_spans, key_col, key_end_col }
|
|
-- Format: "<icon> (<key>)<tail>" e.g. ` (c)ontinue` with byte offsets for highlights.
|
|
local function build_control_text(icon, key, tail)
|
|
local parts = {}
|
|
local function add(s) parts[#parts + 1] = s end
|
|
local function col() return #table.concat(parts) end
|
|
|
|
add(icon); add(" ")
|
|
add("(")
|
|
local key_col = col()
|
|
add(key)
|
|
local key_end_col = col()
|
|
add(")")
|
|
add(tail or "")
|
|
|
|
return table.concat(parts), key_col, key_end_col
|
|
end
|
|
|
|
local function render_sbar()
|
|
if not (sbar_buf and vim.api.nvim_buf_is_valid(sbar_buf)) then return end
|
|
if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then return end
|
|
|
|
local width = vim.api.nvim_win_get_width(sbar_win)
|
|
if width < 1 then width = vim.o.columns end
|
|
local sep = string.rep(ICON.hline, width)
|
|
|
|
local state, state_hl = dap_state()
|
|
|
|
-- layout on content row: [brand] [status msg] [state chip]
|
|
local brand_text = " turbo-debug "
|
|
local status_ok, status_msg = pcall(function() return require("dap").status() end)
|
|
if not status_ok then status_msg = "" end
|
|
|
|
local state_chip = " DEBUG " .. ICON.divider .. " " .. state .. " "
|
|
|
|
local brand_dw = vim.fn.strdisplaywidth(brand_text)
|
|
local chip_dw = vim.fn.strdisplaywidth(state_chip)
|
|
|
|
local status_dw = vim.fn.strdisplaywidth(status_msg)
|
|
local available = width - brand_dw - chip_dw - 4
|
|
if status_dw > available and available > 5 then
|
|
status_msg = status_msg:sub(1, available - 1) .. ICON.ellipsis
|
|
status_dw = vim.fn.strdisplaywidth(status_msg)
|
|
elseif available <= 5 then
|
|
status_msg = ""
|
|
status_dw = 0
|
|
end
|
|
|
|
local remain = width - brand_dw - status_dw - chip_dw
|
|
if remain < 2 then remain = 2 end
|
|
local pad_left = math.floor(remain / 2)
|
|
local pad_right = remain - pad_left
|
|
local content_line = brand_text .. string.rep(" ", pad_left) .. status_msg .. string.rep(" ", pad_right) .. state_chip
|
|
|
|
-- 2 rows: sep / content. No bottom separator — dapui renders its own
|
|
-- pane border above its top elements, which serves as our bottom
|
|
-- edge. Drawing ours too was producing two stacked separator rows.
|
|
vim.bo[sbar_buf].modifiable = true
|
|
vim.api.nvim_buf_set_lines(sbar_buf, 0, -1, false, { sep, content_line })
|
|
vim.bo[sbar_buf].modifiable = false
|
|
|
|
vim.api.nvim_buf_clear_namespace(sbar_buf, bar_ns, 0, -1)
|
|
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 0, 0, {
|
|
end_row = 0, end_col = #sep, hl_group = "TurboDebugBarSeparator",
|
|
})
|
|
-- content
|
|
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, 0, {
|
|
end_row = 1, end_col = #brand_text, hl_group = "TurboDebugBarBrand",
|
|
})
|
|
if status_dw > 0 then
|
|
local status_byte_start = #brand_text + pad_left
|
|
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, status_byte_start, {
|
|
end_row = 1, end_col = status_byte_start + #status_msg, hl_group = "TurboDebugBarStatus",
|
|
})
|
|
end
|
|
local chip_byte_start = #content_line - #state_chip
|
|
pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, chip_byte_start, {
|
|
end_row = 1, end_col = #content_line, hl_group = state_hl,
|
|
})
|
|
|
|
sbar_zones = {}
|
|
end
|
|
|
|
local function render_cbar()
|
|
if not (cbar_buf and vim.api.nvim_buf_is_valid(cbar_buf)) then return end
|
|
if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then return end
|
|
|
|
local width = vim.api.nvim_win_get_width(cbar_win)
|
|
if width < 1 then width = vim.o.columns end
|
|
local sep = string.rep(ICON.hline, width)
|
|
|
|
local state, _ = dap_state()
|
|
local help_text = ICON.help .. " help "
|
|
local help_dw = vim.fn.strdisplaywidth(help_text)
|
|
|
|
-- Modal control labels. State-dependent text so the label shows what
|
|
-- the key will ACTUALLY do right now:
|
|
-- no session: (c) start (q)uit (Restart hidden)
|
|
-- active: (c)ontinue (q) terminate (R)estart shown
|
|
local has_session = state ~= "READY"
|
|
local continue_icon = has_session and ICON.go or ICON.start_rkt
|
|
local continue_tail = has_session and "ontinue" or " start"
|
|
local quit_tail = has_session and " terminate" or "uit"
|
|
|
|
local controls = {
|
|
{ icon = continue_icon, key = keyof("continue", "c"), tail = continue_tail, fn = action("continue") },
|
|
{ icon = ICON.step_over, key = keyof("step_over", "s"), tail = "tep", fn = action("step_over") },
|
|
{ icon = ICON.step_into, key = keyof("step_into", "d"), tail = "escend", fn = action("step_into") },
|
|
{ icon = ICON.step_out, key = keyof("step_out", "r"), tail = "eturn", fn = action("step_out") },
|
|
}
|
|
-- (R)estart is only meaningful during an active session
|
|
if has_session then
|
|
controls[#controls + 1] = { icon = ICON.restart, key = keyof("restart", "R"), tail = "estart", fn = action("restart") }
|
|
end
|
|
controls[#controls + 1] = { icon = ICON.stop, key = keyof("terminate", "q"), tail = quit_tail, fn = action("terminate") }
|
|
|
|
local ctrl_parts = {}
|
|
local ctrl_spans = {}
|
|
local ctrl_key_spans = {}
|
|
local ctrl_zones_bytes = {}
|
|
local gap = " "
|
|
for i, c in ipairs(controls) do
|
|
local prefix_len = #table.concat(ctrl_parts)
|
|
if i > 1 then
|
|
ctrl_parts[#ctrl_parts + 1] = gap
|
|
prefix_len = prefix_len + #gap
|
|
end
|
|
local txt, kcol, kend = build_control_text(c.icon, c.key, c.tail)
|
|
ctrl_parts[#ctrl_parts + 1] = txt
|
|
ctrl_spans[#ctrl_spans + 1] = { prefix_len, prefix_len + #txt, "TurboDebugBarCtrl" }
|
|
ctrl_key_spans[#ctrl_key_spans + 1] = { prefix_len + kcol, prefix_len + kend }
|
|
ctrl_zones_bytes[#ctrl_zones_bytes + 1] = { prefix_len, prefix_len + #txt, c.fn }
|
|
end
|
|
local controls_text = table.concat(ctrl_parts)
|
|
local controls_dw = vim.fn.strdisplaywidth(controls_text)
|
|
|
|
-- controls centered between left edge and help (right anchor)
|
|
local ctrl_zone_width = width - help_dw
|
|
local ctrl_pad_left = math.floor((ctrl_zone_width - controls_dw) / 2)
|
|
if ctrl_pad_left < 1 then ctrl_pad_left = 1 end
|
|
local ctrl_pad_right = ctrl_zone_width - controls_dw - ctrl_pad_left
|
|
if ctrl_pad_right < 1 then ctrl_pad_right = 1 end
|
|
local content_line = string.rep(" ", ctrl_pad_left) .. controls_text .. string.rep(" ", ctrl_pad_right) .. help_text
|
|
|
|
-- 2 rows: content / sep (no top separator — the source/dapui area
|
|
-- above has its own pane borders as the visual break)
|
|
vim.bo[cbar_buf].modifiable = true
|
|
vim.api.nvim_buf_set_lines(cbar_buf, 0, -1, false, { content_line, sep })
|
|
vim.bo[cbar_buf].modifiable = false
|
|
|
|
vim.api.nvim_buf_clear_namespace(cbar_buf, bar_ns, 0, -1)
|
|
-- bottom separator (on row 1 now since content is on row 0)
|
|
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 1, 0, {
|
|
end_row = 1, end_col = #sep, hl_group = "TurboDebugBarSeparator",
|
|
})
|
|
|
|
-- control labels on row 0 (content row)
|
|
local ctrl_byte_offset = ctrl_pad_left
|
|
for _, span in ipairs(ctrl_spans) do
|
|
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, ctrl_byte_offset + span[1], {
|
|
end_row = 0, end_col = ctrl_byte_offset + span[2], hl_group = span[3],
|
|
})
|
|
end
|
|
for _, span in ipairs(ctrl_key_spans) do
|
|
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, ctrl_byte_offset + span[1], {
|
|
end_row = 0, end_col = ctrl_byte_offset + span[2], hl_group = "TurboDebugBarKey",
|
|
})
|
|
end
|
|
|
|
-- help (right-anchored) on row 0
|
|
local help_byte_start = #content_line - #help_text
|
|
pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, help_byte_start, {
|
|
end_row = 0, end_col = #content_line, hl_group = "TurboDebugBarCtrl",
|
|
})
|
|
|
|
cbar_zones = {}
|
|
for _, z in ipairs(ctrl_zones_bytes) do
|
|
local byte_start = ctrl_byte_offset + z[1]
|
|
local byte_end = ctrl_byte_offset + z[2]
|
|
local dcol_start = vim.fn.strdisplaywidth(content_line:sub(1, byte_start))
|
|
local dcol_end = vim.fn.strdisplaywidth(content_line:sub(1, byte_end))
|
|
-- content is on buffer row 0 = window line 1
|
|
cbar_zones[#cbar_zones + 1] = { 1, dcol_start, dcol_end, z[3] }
|
|
end
|
|
local help_dcol_start = vim.fn.strdisplaywidth(content_line:sub(1, help_byte_start))
|
|
local help_dcol_end = vim.fn.strdisplaywidth(content_line)
|
|
cbar_zones[#cbar_zones + 1] = { 1, help_dcol_start, help_dcol_end,
|
|
function() require("turbo-debug.help").open() end }
|
|
end
|
|
|
|
local function render_bars()
|
|
render_sbar()
|
|
render_cbar()
|
|
end
|
|
|
|
-- The bars are SPLITS, not floats. Splits consume real layout rows so
|
|
-- dapui and source windows tuck underneath them naturally — no overlap.
|
|
|
|
local function handle_sbar_click()
|
|
local pos = vim.fn.getmousepos()
|
|
if not pos or pos.winid ~= sbar_win then return end
|
|
for _, zone in ipairs(sbar_zones) do
|
|
if zone[1] == pos.line and pos.wincol - 1 >= zone[2] and pos.wincol - 1 < zone[3] then
|
|
zone[4]()
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
local function handle_cbar_click()
|
|
local pos = vim.fn.getmousepos()
|
|
if not pos or pos.winid ~= cbar_win then return end
|
|
for _, zone in ipairs(cbar_zones) do
|
|
if zone[1] == pos.line and pos.wincol - 1 >= zone[2] and pos.wincol - 1 < zone[3] then
|
|
zone[4]()
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
local function setup_bar_buf(buf)
|
|
vim.bo[buf].bufhidden = "wipe"
|
|
vim.bo[buf].buftype = "nofile"
|
|
vim.bo[buf].swapfile = false
|
|
vim.bo[buf].buflisted = false
|
|
vim.bo[buf].filetype = "TurboDebugBar"
|
|
end
|
|
|
|
local function setup_bar_win(win)
|
|
vim.wo[win].winhighlight = "Normal:TurboDebugBar,EndOfBuffer:TurboDebugBar"
|
|
vim.wo[win].winfixheight = true
|
|
vim.wo[win].list = false
|
|
vim.wo[win].cursorline = false
|
|
vim.wo[win].number = false
|
|
vim.wo[win].relativenumber = false
|
|
vim.wo[win].signcolumn = "no"
|
|
vim.wo[win].statuscolumn = ""
|
|
vim.wo[win].wrap = false
|
|
end
|
|
|
|
local function bounce_out(buf)
|
|
-- if focus lands in a bar buffer (e.g. mouse drag), kick it back out
|
|
-- so the user can't accidentally type into a non-modifiable buffer.
|
|
vim.api.nvim_create_autocmd("BufEnter", {
|
|
buffer = buf,
|
|
callback = function()
|
|
vim.schedule(function()
|
|
if vim.api.nvim_get_current_buf() == buf then
|
|
pcall(vim.cmd, "wincmd p")
|
|
end
|
|
end)
|
|
end,
|
|
})
|
|
end
|
|
|
|
local function open_bars()
|
|
-- Both bars are SPLITS, not floats. Splits consume real layout rows
|
|
-- so dapui windows tuck between them. `wincmd K` / `wincmd J` is
|
|
-- applied after creation to ensure the bars span the FULL editor
|
|
-- width (across the sidebar column too) — otherwise dapui's later
|
|
-- vertical splits would constrain the bars to just the main column.
|
|
local caller_win = vim.api.nvim_get_current_win()
|
|
|
|
-- status bar (top)
|
|
if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then
|
|
vim.cmd("noautocmd keepalt topleft split")
|
|
sbar_win = vim.api.nvim_get_current_win()
|
|
sbar_buf = vim.api.nvim_create_buf(false, true)
|
|
vim.api.nvim_win_set_buf(sbar_win, sbar_buf)
|
|
vim.api.nvim_win_set_height(sbar_win, 2)
|
|
setup_bar_buf(sbar_buf)
|
|
setup_bar_win(sbar_win)
|
|
vim.wo[sbar_win].winfixheight = true
|
|
vim.keymap.set("n", "<LeftMouse>", handle_sbar_click, { buffer = sbar_buf, silent = true, nowait = true })
|
|
vim.keymap.set("n", "<LeftRelease>", "<Nop>", { buffer = sbar_buf, silent = true, nowait = true })
|
|
bounce_out(sbar_buf)
|
|
end
|
|
|
|
if vim.api.nvim_win_is_valid(caller_win) then
|
|
pcall(vim.api.nvim_set_current_win, caller_win)
|
|
end
|
|
|
|
-- control bar (bottom)
|
|
if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then
|
|
vim.cmd("noautocmd keepalt botright split")
|
|
cbar_win = vim.api.nvim_get_current_win()
|
|
cbar_buf = vim.api.nvim_create_buf(false, true)
|
|
vim.api.nvim_win_set_buf(cbar_win, cbar_buf)
|
|
vim.api.nvim_win_set_height(cbar_win, 2)
|
|
setup_bar_buf(cbar_buf)
|
|
setup_bar_win(cbar_win)
|
|
vim.wo[cbar_win].winfixheight = true
|
|
vim.keymap.set("n", "<LeftMouse>", handle_cbar_click, { buffer = cbar_buf, silent = true, nowait = true })
|
|
vim.keymap.set("n", "<LeftRelease>", "<Nop>", { buffer = cbar_buf, silent = true, nowait = true })
|
|
bounce_out(cbar_buf)
|
|
end
|
|
|
|
if vim.api.nvim_win_is_valid(caller_win) then
|
|
pcall(vim.api.nvim_set_current_win, caller_win)
|
|
end
|
|
|
|
render_bars()
|
|
end
|
|
|
|
-- Set dapui pane heights to their initial proportions. Called ONCE
|
|
-- per M.enter, after dapui.open(). No winfixheight — the user is free
|
|
-- to manually resize panes (bigger Breakpoints list, taller Console to
|
|
-- tail long output, etc.) and their resize sticks because we don't
|
|
-- fight subsequent WinResized events. Initial sizing only.
|
|
local function pin_dapui_sizes()
|
|
local pane_wins = {}
|
|
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
|
if vim.api.nvim_buf_is_valid(buf) then
|
|
local ft = vim.bo[buf].filetype
|
|
if ft == "dapui_stacks" or ft == "dapui_scopes" or ft == "dapui_watches"
|
|
or ft == "dapui_breakpoints" or ft == "dapui_console" or ft == "dap-repl" then
|
|
local wins = vim.fn.win_findbuf(buf)
|
|
if wins and wins[1] and vim.api.nvim_win_is_valid(wins[1]) then
|
|
pane_wins[ft] = wins[1]
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Console to configured height
|
|
if pane_wins.dapui_console then
|
|
local target = config.opts.console_height
|
|
or math.max(8, math.floor(vim.o.lines * 0.2))
|
|
pcall(vim.api.nvim_win_set_height, pane_wins.dapui_console, target)
|
|
end
|
|
|
|
-- Sidebar panes distributed equally (residual to Breakpoints)
|
|
local order = { "dapui_stacks", "dapui_scopes", "dapui_watches", "dapui_breakpoints" }
|
|
local present = {}
|
|
local total = 0
|
|
for _, ft in ipairs(order) do
|
|
local w = pane_wins[ft]
|
|
if w then
|
|
present[#present + 1] = w
|
|
total = total + vim.api.nvim_win_get_height(w)
|
|
end
|
|
end
|
|
if #present > 1 and total > 0 then
|
|
local each = math.floor(total / #present)
|
|
local residual = total - (each * #present)
|
|
for i, w in ipairs(present) do
|
|
local h = each + (i == #present and residual or 0)
|
|
pcall(vim.api.nvim_win_set_height, w, h)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function close_bars()
|
|
if sbar_win and vim.api.nvim_win_is_valid(sbar_win) then
|
|
pcall(vim.api.nvim_win_close, sbar_win, true)
|
|
end
|
|
if cbar_win and vim.api.nvim_win_is_valid(cbar_win) then
|
|
pcall(vim.api.nvim_win_close, cbar_win, true)
|
|
end
|
|
sbar_win, sbar_buf, cbar_win, cbar_buf = nil, nil, nil, nil
|
|
sbar_zones, cbar_zones = {}, {}
|
|
end
|
|
|
|
local function reposition_bars()
|
|
-- Just re-render bar content. We intentionally DON'T re-pin dapui
|
|
-- sizes here — the user may have manually resized panes, and
|
|
-- fighting their resize on every event is a worse UX than occasional
|
|
-- layout drift.
|
|
render_bars()
|
|
end
|
|
|
|
-- ─── dap-ui setup ────────────────────────────────────────────────────────────
|
|
|
|
local function set_dapui_window_opts(win)
|
|
-- Wrap long values inside the pane so register dumps etc. don't bleed into
|
|
-- adjacent windows. breakindent + showbreak make continuation lines visually
|
|
-- attached to their parent. The `format_value` wrap below truncates only
|
|
-- pathologically-long values.
|
|
--
|
|
-- Also kill line numbers, the statuscolumn (which snacks overrides with a
|
|
-- rendered gutter), and the `~` end-of-buffer markers — these are pure
|
|
-- noise in debug-info panes where there's no file to navigate.
|
|
if vim.api.nvim_win_is_valid(win) then
|
|
vim.wo[win].wrap = true
|
|
vim.wo[win].breakindent = true
|
|
vim.wo[win].linebreak = true
|
|
vim.wo[win].showbreak = " " .. ICON.wrap_mark .. " "
|
|
vim.wo[win].sidescrolloff = 0
|
|
vim.wo[win].cursorline = true
|
|
vim.wo[win].number = false
|
|
vim.wo[win].relativenumber = false
|
|
vim.wo[win].signcolumn = "no"
|
|
vim.wo[win].statuscolumn = ""
|
|
vim.wo[win].foldcolumn = "0"
|
|
-- make end-of-buffer `~` invisible by setting EOB fillchar to space
|
|
vim.wo[win].fillchars = "eob: "
|
|
end
|
|
end
|
|
|
|
local function patch_format_value()
|
|
local ok, util = pcall(require, "dapui.util")
|
|
if not ok or not util or type(util.format_value) ~= "function" then return end
|
|
if util._turbo_debug_wrapped then return end
|
|
local orig = util.format_value
|
|
util.format_value = function(value_start, value)
|
|
if type(value) ~= "string" then return orig(value_start, value) end
|
|
local cap = config.opts.max_value_width or 2000
|
|
if cap <= 0 or #value <= cap then return orig(value_start, value) end
|
|
return orig(value_start, value:sub(1, cap - 1) .. ICON.ellipsis)
|
|
end
|
|
util._turbo_debug_wrapped = true
|
|
end
|
|
|
|
local redraw_pending = false
|
|
local function schedule_console_redraw()
|
|
if redraw_pending then return end
|
|
redraw_pending = true
|
|
vim.defer_fn(function()
|
|
redraw_pending = false
|
|
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
|
if vim.api.nvim_buf_is_valid(buf) and vim.bo[buf].filetype == "dapui_console" then
|
|
for _, win in ipairs(vim.fn.win_findbuf(buf)) do
|
|
if vim.api.nvim_win_is_valid(win) then
|
|
pcall(vim.api.nvim_win_call, win, function() vim.cmd("redraw!") end)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end, config.opts.console_refresh_ms or 50)
|
|
end
|
|
|
|
local function ensure_dapui()
|
|
-- One-time setup. Calling dapui.setup on every enter was tearing down
|
|
-- internal state (windows, event listeners, buffer refs) while the
|
|
-- adapter handshake was in flight — "Debug adapter didn't respond"
|
|
-- timeouts and overall slowness. dapui's layouts use the absolute
|
|
-- sizes computed once here; if the terminal resizes significantly
|
|
-- between toggles, proportions can drift slightly, but that's
|
|
-- acceptable compared to breaking the adapter.
|
|
if dapui_initialized then return end
|
|
dapui_initialized = true
|
|
local dapui_opts = vim.deepcopy(config.opts.dapui)
|
|
if not dapui_opts.layouts then dapui_opts.layouts = build_default_layouts() end
|
|
require("dapui").setup(dapui_opts)
|
|
patch_format_value()
|
|
|
|
local dap = require("dap")
|
|
dap.listeners.after.event_initialized["turbo-debug"] = function()
|
|
require("dapui").open()
|
|
end
|
|
|
|
-- K = hover during a live dap session (global, not per-buffer)
|
|
dap.listeners.after.event_initialized["turbo-debug-hover"] = function()
|
|
vim.keymap.set("n", "K", function()
|
|
require("dap.ui.widgets").hover(nil, { border = "rounded" })
|
|
end, { silent = true, desc = "DAP hover" })
|
|
end
|
|
dap.listeners.after.event_terminated["turbo-debug-hover"] = function()
|
|
pcall(vim.keymap.del, "n", "K")
|
|
end
|
|
|
|
-- console refresh: trailing-edge throttled so chatty programs don't DOS us
|
|
dap.listeners.after.event_output["turbo-debug-redraw"] = schedule_console_redraw
|
|
|
|
-- IP marker: paired arrows. Gutter sign 👉 + EOL vtext 👈 REASON flanking
|
|
-- the paused line. clear_ip() wipes both our own extmarks AND nvim-dap's
|
|
-- DapStopped gutter signs, so a restart (or a natural session end) doesn't
|
|
-- leave a stale 👉 behind on the last-paused line.
|
|
local ip_ns = vim.api.nvim_create_namespace("turbo-debug.ip")
|
|
local function clear_ip()
|
|
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
|
if vim.api.nvim_buf_is_valid(buf) then
|
|
vim.api.nvim_buf_clear_namespace(buf, ip_ns, 0, -1)
|
|
-- unplace any lingering DapStopped signs (nvim-dap doesn't always
|
|
-- clear these on restart — it's the exact bug the user hit).
|
|
local ok, placed = pcall(vim.fn.sign_getplaced, buf, { group = "*" })
|
|
if ok and placed and placed[1] then
|
|
for _, s in ipairs(placed[1].signs or {}) do
|
|
if s.name == "DapStopped" then
|
|
pcall(vim.fn.sign_unplace, s.group or "", { buffer = buf, id = s.id })
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- re-render the control bar on any dap state change so the READY/RUNNING/
|
|
-- PAUSED chip and dap.status() text stay live.
|
|
local function refresh_bars() vim.schedule(render_bars) end
|
|
dap.listeners.after.event_initialized["turbo-debug-bar"] = refresh_bars
|
|
dap.listeners.after.event_stopped["turbo-debug-bar"] = refresh_bars
|
|
dap.listeners.after.event_continued["turbo-debug-bar"] = refresh_bars
|
|
dap.listeners.after.event_terminated["turbo-debug-bar"] = refresh_bars
|
|
dap.listeners.after.event_exited["turbo-debug-bar"] = refresh_bars
|
|
|
|
-- When a stopped event fires, dap will jump to the source frame using
|
|
-- its switchbuf logic. `uselast` (the common default) targets the
|
|
-- CURRENT window — but if focus happens to be on a dapui pane or one
|
|
-- of our bars, dap falls back to `winnr('#')` which can land anywhere
|
|
-- (including bufferline's tabline area or a wrong split). By shifting
|
|
-- focus to a real source window BEFORE dap processes the jump, we
|
|
-- guarantee new source files (stepped-into library/stdlib files) open
|
|
-- in the intended source window and the chrome stays intact.
|
|
dap.listeners.before.event_stopped["turbo-debug-focus-source"] = function()
|
|
local w = source_window()
|
|
if w and w ~= vim.api.nvim_get_current_win() then
|
|
pcall(vim.api.nvim_set_current_win, w)
|
|
end
|
|
end
|
|
|
|
dap.listeners.after.event_stopped["turbo-debug-ip"] = function(session, body)
|
|
vim.defer_fn(function()
|
|
clear_ip()
|
|
local frame = session and session.current_frame
|
|
if not (frame and frame.source and frame.source.path) then return end
|
|
local buf = vim.fn.bufnr(frame.source.path)
|
|
if buf == -1 or not vim.api.nvim_buf_is_valid(buf) then return end
|
|
local line = math.max(0, (frame.line or 1) - 1)
|
|
local reason = ((body and body.reason) or "stopped"):upper()
|
|
-- Extmark carries BOTH the virt_text (👈 REASON at EOL) AND a
|
|
-- sign_text (👉 in the gutter) with high priority. nvim-dap also
|
|
-- places its own DapStopped sign, but at the same priority as the
|
|
-- breakpoint sign — which meant whichever was placed later won.
|
|
-- Our extmark sign at priority 500 always wins, guaranteeing the
|
|
-- paused line has the finger-pointing icon in the gutter.
|
|
pcall(vim.api.nvim_buf_set_extmark, buf, ip_ns, line, 0, {
|
|
sign_text = "\xf0\x9f\x91\x89", -- 👉 U+1F449
|
|
sign_hl_group = "DapStopped",
|
|
virt_text = {
|
|
{ " " .. ICON.ip_left .. " ", "DapStopped" },
|
|
{ reason, "TurboDebugIPReason" },
|
|
},
|
|
virt_text_pos = "eol",
|
|
hl_mode = "combine",
|
|
priority = 500,
|
|
})
|
|
end, 50)
|
|
end
|
|
dap.listeners.after.event_continued["turbo-debug-ip"] = function() clear_ip() end
|
|
dap.listeners.before.event_terminated["turbo-debug-ip"] = function() clear_ip() end
|
|
dap.listeners.before.event_exited["turbo-debug-ip"] = function() clear_ip() end
|
|
|
|
-- Clear the Console pane on session start/restart.
|
|
--
|
|
-- Strategy: keep the buffer (crucially, keep the attached terminal
|
|
-- channel intact so dap's output stream isn't broken), briefly flip
|
|
-- `modifiable` on and call nvim_buf_set_lines to wipe the scrollback.
|
|
-- The terminal's PTY state and cursor position are preserved — only
|
|
-- the displayed lines are removed. Next output from the program
|
|
-- appends from the top of the empty buffer.
|
|
--
|
|
-- The previous "delete the whole buffer" approach only worked on
|
|
-- fresh launches (new terminal opens anyway) and broke on Restart
|
|
-- because the adapter reuses the existing terminal channel — deleting
|
|
-- the buffer orphaned the channel and left the Console unresponsive.
|
|
local function clear_console()
|
|
if not config.opts.clear_console_on_start then return end
|
|
local ok, dapui = pcall(require, "dapui")
|
|
if not (ok and dapui.elements and dapui.elements.console) then return end
|
|
local bok, buf = pcall(dapui.elements.console.buffer)
|
|
if not bok or not buf or not vim.api.nvim_buf_is_valid(buf) then return end
|
|
pcall(function()
|
|
local was_mod = vim.bo[buf].modifiable
|
|
vim.bo[buf].modifiable = true
|
|
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {})
|
|
vim.bo[buf].modifiable = was_mod
|
|
end)
|
|
end
|
|
-- Fire before every session-starting verb so old scrollback is gone
|
|
-- before the new session's output begins.
|
|
dap.listeners.before.launch["turbo-debug-clear-console"] = clear_console
|
|
dap.listeners.before.attach["turbo-debug-clear-console"] = clear_console
|
|
dap.listeners.before.restart["turbo-debug-clear-console"] = clear_console
|
|
|
|
-- Default-collapse scopes the user considers noisy (Registers on
|
|
-- codelldb, for example). We intercept the scopes response and set
|
|
-- `expensive = true` on any matching scope — dapui's own component
|
|
-- auto-collapses anything marked expensive (see dapui/components/
|
|
-- scopes.lua line 22-24), so this reuses their existing mechanism.
|
|
dap.listeners.before.scopes["turbo-debug-collapse"] = function(_, _, response, _)
|
|
local collapse = config.opts.collapsed_scopes
|
|
if not (collapse and #collapse > 0) then return end
|
|
if not (response and response.scopes) then return end
|
|
for _, scope in ipairs(response.scopes) do
|
|
for _, name in ipairs(collapse) do
|
|
if scope.name == name then
|
|
scope.expensive = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- winbar titles on dapui panes — colorful emoji for instant pane
|
|
-- recognition. REPL is iconless per user preference (its prompt is
|
|
-- already visually distinct enough).
|
|
local titles = {
|
|
dapui_scopes = " " .. ICON.scopes .. " Scopes",
|
|
dapui_watches = " " .. ICON.watches .. " Watches",
|
|
dapui_stacks = " " .. ICON.stacks .. " Call Stack",
|
|
dapui_breakpoints = " " .. ICON.breakpoints .. " Breakpoints",
|
|
dapui_console = " " .. ICON.terminal .. " Console",
|
|
["dap-repl"] = " REPL",
|
|
}
|
|
vim.api.nvim_create_autocmd("FileType", {
|
|
pattern = { "dapui_*", "dap-repl" },
|
|
callback = function(args)
|
|
-- install keys synchronously (don't defer) so they're ready the moment
|
|
-- the buffer is usable. dapui's own `apply_mapping` pass only touches
|
|
-- <CR>/o/d/e/r/t; we don't conflict except on d and r which we skip.
|
|
if active then M._install_for_buf(args.buf) end
|
|
local ft = vim.bo[args.buf].filetype
|
|
local title = titles[ft]
|
|
vim.schedule(function()
|
|
local win = vim.fn.bufwinid(args.buf)
|
|
if win ~= -1 then
|
|
if title then vim.wo[win].winbar = title end
|
|
set_dapui_window_opts(win)
|
|
end
|
|
end)
|
|
end,
|
|
})
|
|
|
|
if config.opts.active_window_highlight then setup_active_win_highlights() end
|
|
end
|
|
|
|
local function ensure_vt()
|
|
if vt_initialized then return end
|
|
vt_initialized = true
|
|
require("nvim-dap-virtual-text").setup(config.opts.virtual_text)
|
|
end
|
|
|
|
-- ─── actions ────────────────────────────────────────────────────────────────
|
|
|
|
local actions_cache
|
|
local visual_actions = { watch = true, hover = true }
|
|
|
|
local function has_any_breakpoint()
|
|
local ok, bps = pcall(require, "dap.breakpoints")
|
|
if not ok then return false end
|
|
local all = bps.get()
|
|
for _, lines in pairs(all or {}) do
|
|
if #lines > 0 then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Find a source window (non-dapui, non-repl, non-bar). nvim-dap reads the
|
|
-- current buffer's filetype to pick a launch configuration, so when the user
|
|
-- presses `c` while focused in a dapui pane (filetype=dapui_scopes, etc.),
|
|
-- we need to temporarily borrow a source window's context.
|
|
-- NOTE: assigned (not declared) — forward-declared at top of file.
|
|
source_window = function()
|
|
-- prefer the current window if it's a source
|
|
local cur = vim.api.nvim_get_current_win()
|
|
local function is_source(w)
|
|
if not vim.api.nvim_win_is_valid(w) then return false end
|
|
if vim.api.nvim_win_get_config(w).relative ~= "" then return false end
|
|
local ft = vim.bo[vim.api.nvim_win_get_buf(w)].filetype or ""
|
|
return ft ~= ""
|
|
and not ft:match("^dapui_")
|
|
and ft ~= "dap-repl"
|
|
and ft ~= "TurboDebugBar"
|
|
end
|
|
if is_source(cur) then return cur end
|
|
for _, w in ipairs(vim.api.nvim_list_wins()) do
|
|
if is_source(w) then return w end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function run_in_source(fn)
|
|
-- We can't use nvim_win_call here: dap.continue() may open a vim.ui.select
|
|
-- picker asynchronously, and by the time the picker opens, nvim_win_call
|
|
-- has already returned focus to the original window — the picker opens
|
|
-- but nothing has focus on it, forcing the user to click in to interact.
|
|
-- Actually shift focus to the source window so the picker takes focus
|
|
-- properly.
|
|
local w = source_window()
|
|
if w and w ~= vim.api.nvim_get_current_win() then
|
|
pcall(vim.api.nvim_set_current_win, w)
|
|
end
|
|
return fn()
|
|
end
|
|
|
|
local function launch_with_stop_on_entry()
|
|
local dap = require("dap")
|
|
local ft = vim.bo.filetype or ""
|
|
local cfgs = dap.configurations[ft]
|
|
if not (cfgs and cfgs[1]) then
|
|
dap.continue()
|
|
return
|
|
end
|
|
local cfg = vim.deepcopy(cfgs[1])
|
|
cfg.stopOnEntry = true
|
|
cfg.name = (cfg.name or "debug") .. " (stop on entry)"
|
|
dap.run(cfg)
|
|
end
|
|
|
|
local function smart_continue()
|
|
local dap = require("dap")
|
|
if dap.session() then dap.continue(); return end
|
|
run_in_source(function()
|
|
if config.opts.stop_on_entry_when_no_breakpoints ~= false and not has_any_breakpoint() then
|
|
launch_with_stop_on_entry()
|
|
else
|
|
dap.continue()
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function step_or_launch(step_fn)
|
|
if require("dap").session() then
|
|
step_fn()
|
|
else
|
|
smart_continue()
|
|
end
|
|
end
|
|
|
|
local function smart_terminate()
|
|
-- q is context-aware: if a session is running, kill it and stay in debug
|
|
-- mode so the user can read the console / post-mortem. If no session is
|
|
-- running, q exits debug mode entirely.
|
|
local dap = require("dap")
|
|
if dap.session() then
|
|
pcall(dap.terminate)
|
|
else
|
|
M.exit()
|
|
end
|
|
end
|
|
|
|
local function setup_actions()
|
|
if actions_cache then return actions_cache end
|
|
local dap = require("dap")
|
|
local pb = require("persistent-breakpoints.api")
|
|
|
|
actions_cache = {
|
|
continue = smart_continue,
|
|
step_over = function() step_or_launch(dap.step_over) end,
|
|
step_into = function() step_or_launch(dap.step_into) end,
|
|
step_out = function() step_or_launch(dap.step_out) end,
|
|
run_to_cursor = function() dap.run_to_cursor() end,
|
|
restart = function() dap.restart() end,
|
|
terminate = smart_terminate,
|
|
help = function()
|
|
if help.is_open() then help.close() else help.open() end
|
|
end,
|
|
breakpoint = function() pb.toggle_breakpoint() end,
|
|
cond_breakpoint = function() pb.set_conditional_breakpoint() end,
|
|
clear_breakpoints = function() pb.clear_all_breakpoints() end,
|
|
watch = function()
|
|
local expr
|
|
if vim.fn.mode() == "v" or vim.fn.mode() == "V" then
|
|
vim.cmd('noautocmd normal! "vy')
|
|
expr = vim.fn.getreg("v")
|
|
else
|
|
expr = vim.fn.expand("<cexpr>")
|
|
end
|
|
if expr and expr ~= "" then
|
|
require("dapui").elements.watches.add(expr)
|
|
end
|
|
end,
|
|
hover = function() require("dapui").eval() end,
|
|
eval = function() require("dapui").float_element("repl") end,
|
|
}
|
|
return actions_cache
|
|
end
|
|
|
|
function M.actions() return actions_cache or setup_actions() end
|
|
|
|
-- ─── keymap install / clear ─────────────────────────────────────────────────
|
|
|
|
local function find_buf_mapping(buf, lhs, mode)
|
|
for _, m in ipairs(vim.api.nvim_buf_get_keymap(buf, mode)) do
|
|
if m.lhs == lhs then return m end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function restore_mapping(buf, mode, map)
|
|
local rhs = map.rhs or map.callback
|
|
if not rhs then return end
|
|
pcall(vim.keymap.set, mode, map.lhs, rhs, {
|
|
buffer = buf,
|
|
silent = map.silent == 1,
|
|
noremap = map.noremap == 1,
|
|
expr = map.expr == 1,
|
|
desc = map.desc,
|
|
})
|
|
end
|
|
|
|
function M._install_for_buf(buf)
|
|
if not vim.api.nvim_buf_is_valid(buf) then return end
|
|
-- Skip the control-bar buffer entirely — its own <LeftMouse> keymap handles
|
|
-- button clicks, and debug keys like `c` would fire accidentally on click.
|
|
if vim.bo[buf].filetype == "TurboDebugBar" then return end
|
|
|
|
local acts = setup_actions()
|
|
local ft = vim.bo[buf].filetype or ""
|
|
local is_dapui = ft:match("^dapui_") or ft == "dap-repl"
|
|
installed[buf] = installed[buf] or {}
|
|
for name, key in pairs(config.opts.keys) do
|
|
if key and acts[name] then
|
|
-- yield d/r to dapui's native remove/repl inside its own panes
|
|
if not (is_dapui and (name == "step_into" or name == "step_out")) then
|
|
-- Only record prev-mapping on first install; subsequent installs
|
|
-- (force-reinstall on focus change) keep the original stash so
|
|
-- exit-time restoration still works.
|
|
local entry = installed[buf][name]
|
|
if not entry then
|
|
local prev_n = find_buf_mapping(buf, key, "n")
|
|
local prev_v = visual_actions[name] and find_buf_mapping(buf, key, "v") or nil
|
|
entry = { key = key, prev_n = prev_n, prev_v = prev_v }
|
|
installed[buf][name] = entry
|
|
end
|
|
local opts = { buffer = buf, silent = true, nowait = true, desc = "turbo-debug: " .. name }
|
|
pcall(vim.keymap.set, "n", key, acts[name], opts)
|
|
if visual_actions[name] then
|
|
pcall(vim.keymap.set, "v", key, acts[name], opts)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function clear_all_modal_keys()
|
|
for buf, entries in pairs(installed) do
|
|
if vim.api.nvim_buf_is_valid(buf) then
|
|
for name, entry in pairs(entries) do
|
|
pcall(vim.keymap.del, "n", entry.key, { buffer = buf })
|
|
if visual_actions[name] then
|
|
pcall(vim.keymap.del, "v", entry.key, { buffer = buf })
|
|
end
|
|
if entry.prev_n then restore_mapping(buf, "n", entry.prev_n) end
|
|
if entry.prev_v then restore_mapping(buf, "v", entry.prev_v) end
|
|
end
|
|
end
|
|
end
|
|
installed = {}
|
|
end
|
|
|
|
-- ─── source-window debug chrome ─────────────────────────────────────────────
|
|
|
|
local function set_debug_chrome()
|
|
active_win = vim.api.nvim_get_current_win()
|
|
saved_winbar = vim.wo[active_win].winbar
|
|
-- Source file's winbar is just the filename now — the "DEBUG · STATE"
|
|
-- chip lives in the top status bar (sbar). Keeps the source context
|
|
-- minimal and leaves more room for filename paths.
|
|
vim.wo[active_win].winbar = " %f"
|
|
end
|
|
|
|
local function clear_debug_chrome()
|
|
if active_win and vim.api.nvim_win_is_valid(active_win) then
|
|
vim.wo[active_win].winbar = saved_winbar or ""
|
|
end
|
|
active_win = nil
|
|
saved_winbar = nil
|
|
end
|
|
|
|
-- ─── public API ─────────────────────────────────────────────────────────────
|
|
|
|
function M.enter()
|
|
if active then return end
|
|
active = true
|
|
define_highlights()
|
|
setup_actions()
|
|
|
|
local group = vim.api.nvim_create_augroup("TurboDebugModalKeys", { clear = true })
|
|
vim.api.nvim_create_autocmd("BufEnter", {
|
|
group = group,
|
|
callback = function(args)
|
|
if active then M._install_for_buf(args.buf) end
|
|
end,
|
|
})
|
|
-- Editor resize: recreate any bar that got killed (shrink-below-min),
|
|
-- then re-render. dapui sizes are left alone so user manual resizes
|
|
-- stick.
|
|
vim.api.nvim_create_autocmd("VimResized", {
|
|
group = group,
|
|
callback = function()
|
|
vim.schedule(function()
|
|
if not active then return end
|
|
if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then
|
|
sbar_win, sbar_buf = nil, nil
|
|
end
|
|
if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then
|
|
cbar_win, cbar_buf = nil, nil
|
|
end
|
|
open_bars()
|
|
render_bars()
|
|
end)
|
|
end,
|
|
})
|
|
-- WinResized fires on any geometry change: mouse-drags on split
|
|
-- borders, bufferline's tabline appearing/disappearing (which shifts
|
|
-- all windows), `wincmd =`, etc. A single `redraw!` clears stale
|
|
-- pixels from the previous layout. Cheap and covers every
|
|
-- layout-shift case without touching sizes.
|
|
vim.api.nvim_create_autocmd("WinResized", {
|
|
group = group,
|
|
callback = function()
|
|
if not active then return end
|
|
vim.schedule(function() pcall(vim.cmd, "redraw!") end)
|
|
end,
|
|
})
|
|
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
|
if vim.api.nvim_buf_is_loaded(buf) then M._install_for_buf(buf) end
|
|
end
|
|
|
|
set_debug_chrome()
|
|
ensure_dapui()
|
|
-- { reset = true } is critical: without it, dapui's WindowLayout:resize
|
|
-- uses whatever `win_state.size` drifted to last time (see
|
|
-- dapui/windows/layout.lua:82-116 — `update_sizes` writes current
|
|
-- ratios back to state, so sizes accumulate drift across close/open
|
|
-- cycles). `reset` tells it to ignore cached state and use init_size
|
|
-- (our original 0.25 per pane).
|
|
require("dapui").open({ reset = true })
|
|
open_bars()
|
|
pin_dapui_sizes()
|
|
|
|
-- second install pass after dapui has created its buffers
|
|
vim.schedule(function()
|
|
if not active then return end
|
|
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
|
if vim.api.nvim_buf_is_loaded(buf) then M._install_for_buf(buf) end
|
|
end
|
|
pin_dapui_sizes()
|
|
render_bars()
|
|
end)
|
|
|
|
ensure_vt()
|
|
require("nvim-dap-virtual-text").enable()
|
|
|
|
if config.opts.help_on_enter then help.open() end
|
|
end
|
|
|
|
function M.exit()
|
|
if not active then return end
|
|
active = false
|
|
|
|
if require("dap").session() then pcall(require("dap").terminate) end
|
|
|
|
pcall(vim.api.nvim_del_augroup_by_name, "TurboDebugModalKeys")
|
|
clear_all_modal_keys()
|
|
clear_debug_chrome()
|
|
help.close()
|
|
|
|
-- Close dapui FIRST so dapui's windows tear down into a state we
|
|
-- control. Closing our bars first would let dapui's remaining panes
|
|
-- expand into the freed rows, which dapui then "remembers" and
|
|
-- breaks the next open's layout.
|
|
if dapui_initialized then require("dapui").close() end
|
|
close_bars()
|
|
if vt_initialized then require("nvim-dap-virtual-text").disable() end
|
|
end
|
|
|
|
function M.toggle()
|
|
if active then M.exit() else M.enter() end
|
|
end
|
|
|
|
function M.is_active() return active end
|
|
|
|
return M
|