Skip to content

Commit 9b6335c

Browse files
committed
feat(watches): do not use virtual lines
top level variables improve handling of errors refactor: no need for stopped_thread_id state feat: colors, handle deleting expressions feat: edit expressions refactor: simplify implementation by indexing with strings The downside is that the ordering is kinda awkward, but that's fine by me feat: update and highlight changed expressions refactor(watches): cleaner eval variables feat: allow collapsing via foldmethod = indent feat: copy expression to clipboard chore: appease type checker feat(watches): highlight updated variables some fixes for keymaps chore: add type annotation update docs
1 parent c60babd commit 9b6335c

File tree

10 files changed

+223
-103
lines changed

10 files changed

+223
-103
lines changed

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,12 @@ return {
7777
The plugin provides 6 "views" that share the same window (so there's clutter)
7878

7979
- Watches view
80-
- Shows a list of (user defined) expressions, that are evaluated by the debug adapter
80+
- Shows a list of user defined expressions, that are evaluated by the debug adapter
8181
- Add, edit and delete expressions from the watch list
82-
- Including adding the variable under the cursor
82+
- Add variable under the cursor using a command
83+
- Copy the value of an expression
8384

84-
![watches view](https://github.com/user-attachments/assets/c6838700-95ed-4b39-9ab5-e0ed0e753995)
85+
![watches view](https://github.com/user-attachments/assets/381a5c9c-7eea-4cdc-8358-a2afe9f247b2)
8586

8687
- Exceptions view
8788
- Control when the debugger should stop, outside of breakpoints (e.g.,
@@ -173,10 +174,11 @@ in the `'winbar'` (e.g., `B` for the breakpoints view).
173174

174175
The breakpoints view, the exceptions view and the scopes view only have 1
175176
mapping: `<CR>`. It jumps to a breakpoint, toggles an exception filter, and
176-
expands a variable, respectively. The watches view comes with 3 mappings:
177+
expands a variable, respectively. The watches view comes with 4 mappings:
177178

178179
- `i` to insert a new expression
179180
- `e` to edit an expression
181+
- `c` to copy an expression (can't copy inner variables for now)
180182
- `d` to delete an expression
181183

182184
Though, the preferred way of adding a new expression is using the

lua/dap-view/events.lua

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,11 @@ dap.listeners.after.stackTrace[SUBSCRIPTION_ID] = function()
7777
end
7878
end
7979

80-
dap.listeners.after.event_stopped[SUBSCRIPTION_ID] = function(_, body)
81-
state.stopped_thread = body.threadId
82-
80+
dap.listeners.after.event_stopped[SUBSCRIPTION_ID] = function()
8381
require("dap-view.threads").get_threads()
8482

85-
for i, expr in ipairs(state.watched_expressions) do
86-
eval.eval_expr(expr, function(result)
87-
local has_changed = state.expression_results[i] ~= result
88-
state.updated_evaluations[i] = state.expression_results[i] and has_changed
89-
state.expression_results[i] = result
90-
end)
83+
for expr, _ in pairs(state.watched_expressions) do
84+
eval.eval_expr(expr)
9185
end
9286
end
9387

@@ -116,9 +110,4 @@ dap.listeners.after.event_terminated[SUBSCRIPTION_ID] = function()
116110
if state.current_section == "threads" then
117111
threads.show()
118112
end
119-
120-
-- Clear evaluations so new sessions don't get highlighted as changed
121-
for k in ipairs(state.expression_results) do
122-
state.expression_results[k] = nil
123-
end
124113
end

lua/dap-view/highlight.lua

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,24 @@ end
88

99
local define_base_links = function()
1010
hl_create("MissingData", "DapBreakpoint")
11-
hl_create("WatchText", "Comment")
12-
hl_create("WatchTextChanged", "DiagnosticVirtualTextWarn")
13-
hl_create("ExceptionFilterEnabled", "DiagnosticOk")
14-
hl_create("ExceptionFilterDisabled", "DiagnosticError")
1511
hl_create("FileName", "qfFileName")
1612
hl_create("LineNumber", "qfLineNr")
1713
hl_create("Separator", "Comment")
1814
hl_create("Thread", "@namespace")
1915
hl_create("ThreadStopped", "@conditional")
16+
17+
hl_create("ExceptionFilterEnabled", "DiagnosticOk")
18+
hl_create("ExceptionFilterDisabled", "DiagnosticError")
19+
20+
hl_create("WatchExpr", "Identifier")
21+
hl_create("WatchError", "DiagnosticError")
22+
hl_create("WatchUpdated", "DiagnosticVirtualTextWarn")
23+
24+
hl_create("Boolean", "Boolean")
25+
hl_create("String", "String")
26+
hl_create("Number", "Number")
27+
hl_create("Float", "Float")
28+
hl_create("Function", "Function")
2029
end
2130

2231
define_base_links()

lua/dap-view/state.lua

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,24 @@
1010
---@field winnr? integer
1111
---@field term_bufnr? integer
1212
---@field term_winnr? integer
13-
---@field stopped_thread? integer
1413
---@field last_active_adapter? string
1514
---@field current_section? SectionType
1615
---@field exceptions_options ExceptionsOption[]
1716
---@field threads ThreadWithErr[]
1817
---@field threads_err? string
1918
---@field frames_by_line {[number]: dap.StackFrame[]}
19+
---@field expressions_by_line {[integer]: string}
20+
---@field variables_by_reference table<integer, {variable: dap.Variable, updated: boolean}[] | string>
2021
---@field subtle_frames boolean
21-
---@field watched_expressions string[]
22-
---@field expression_results string[]
23-
---@field updated_evaluations boolean[]
22+
---@field watched_expressions table<string,{response?: (dap.EvaluateResponse | string), updated?: boolean}>
2423
local M = {
2524
exceptions_options = {},
2625
threads = {},
2726
frames_by_line = {},
27+
expressions_by_line = {},
28+
variables_by_reference = {},
2829
subtle_frames = false,
2930
watched_expressions = {},
30-
expression_results = {},
31-
updated_evaluations = {},
3231
}
3332

3433
return M

lua/dap-view/threads/view.lua

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ M.show = function()
1616
-- Clear previous content
1717
api.nvim_buf_set_lines(state.bufnr, 0, -1, true, {})
1818

19-
if views.cleanup_view(not dap.session(), "No active session") then
19+
local session = dap.session()
20+
-- Redundant check to appease the type checker
21+
if views.cleanup_view(session == nil, "No active session") or session == nil then
2022
return
2123
end
2224

@@ -35,7 +37,7 @@ M.show = function()
3537
local line = 0
3638
for _, thread in pairs(state.threads) do
3739
api.nvim_buf_set_lines(state.bufnr, line, -1, false, { thread.name })
38-
local is_stopped_thread = state.stopped_thread == thread.id
40+
local is_stopped_thread = session.stopped_thread_id == thread.id
3941
hl.hl_range(is_stopped_thread and "ThreadStopped" or "Thread", { line, 0 }, { line, -1 })
4042

4143
local valid_frames = vim.iter(thread.frames or {})

lua/dap-view/views/keymaps.lua

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,36 +30,44 @@ M.set_keymaps = function()
3030

3131
vim.keymap.set("n", "d", function()
3232
if state.current_section == "watches" then
33-
local line = vim.api.nvim_win_get_cursor(state.winnr)[1]
33+
local cursor_line = vim.api.nvim_win_get_cursor(state.winnr)[1]
3434

35-
watches_actions.remove_watch_expr(line)
35+
watches_actions.remove_watch_expr(cursor_line)
3636

3737
watches_view.show()
3838
end
3939
end, { buffer = state.bufnr, nowait = true })
4040

4141
vim.keymap.set("n", "e", function()
4242
if state.current_section == "watches" then
43-
local line = vim.api.nvim_win_get_cursor(state.winnr)[1]
43+
local cursor_line = vim.api.nvim_win_get_cursor(state.winnr)[1]
4444

4545
vim.ui.input(
46-
{ prompt = "Expression: ", default = state.watched_expressions[line] },
46+
{ prompt = "Expression: ", default = state.expressions_by_line[cursor_line] },
4747
function(input)
4848
if input then
49-
watches_actions.edit_watch_expr(input, line)
49+
watches_actions.edit_watch_expr(input, cursor_line)
5050
end
5151
end
5252
)
5353
end
5454
end, { buffer = state.bufnr })
5555

56+
vim.keymap.set("n", "c", function()
57+
if state.current_section == "watches" then
58+
local cursor_line = vim.api.nvim_win_get_cursor(state.winnr)[1]
59+
60+
watches_actions.copy_watch_expr(cursor_line)
61+
end
62+
end, { buffer = state.bufnr, nowait = true })
63+
5664
vim.keymap.set("n", "t", function()
5765
if state.current_section == "threads" then
5866
state.subtle_frames = not state.subtle_frames
5967

6068
threads_view.show()
6169
end
62-
end, { buffer = state.bufnr })
70+
end, { buffer = state.bufnr, nowait = true })
6371
end
6472

6573
return M

lua/dap-view/views/options.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ M.set_options = function()
1313
win.cursorline = true
1414
win.statuscolumn = ""
1515
win.foldcolumn = "0"
16+
win.foldmethod = "indent"
1617

1718
local buf = vim.bo[state.bufnr]
1819
buf.buftype = "nofile"

lua/dap-view/watches/actions.lua

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ local M = {}
77
---@param expr string
88
local is_expr_valid = function(expr)
99
-- Avoid duplicate expressions
10-
return #expr > 0 and not vim.tbl_contains(state.watched_expressions, expr)
10+
return #expr > 0 and state.watched_expressions[expr] == nil
1111
end
1212

1313
---@param expr string
@@ -17,19 +17,40 @@ M.add_watch_expr = function(expr)
1717
return false
1818
end
1919

20-
eval.eval_expr(expr, function(result)
21-
table.insert(state.expression_results, result)
22-
end)
23-
24-
table.insert(state.watched_expressions, expr)
20+
eval.eval_expr(expr)
2521

2622
return true
2723
end
2824

2925
---@param line number
3026
M.remove_watch_expr = function(line)
31-
table.remove(state.watched_expressions, line)
32-
table.remove(state.expression_results, line)
27+
local expr = state.expressions_by_line[line]
28+
if expr then
29+
local eval_result = state.watched_expressions[expr].response
30+
31+
state.watched_expressions[expr] = nil
32+
state.expressions_by_line[line] = nil
33+
34+
-- If the result is a string, it's the error
35+
if type(eval_result) ~= "string" then
36+
local ref = eval_result and eval_result.variablesReference
37+
if ref then
38+
state.variables_by_reference[ref] = nil
39+
end
40+
end
41+
else
42+
vim.notify("No expression under the under cursor")
43+
end
44+
end
45+
46+
---@param line number
47+
M.copy_watch_expr = function(line)
48+
local expr = state.expressions_by_line[line]
49+
if expr then
50+
eval.copy_expr(expr)
51+
else
52+
vim.notify("No expression under the under cursor")
53+
end
3354
end
3455

3556
---@param expr string
@@ -39,11 +60,10 @@ M.edit_watch_expr = function(expr, line)
3960
return
4061
end
4162

42-
state.watched_expressions[line] = expr
63+
-- The easiest way to edit is to delete and insert again
64+
M.remove_watch_expr(line)
4365

44-
eval.eval_expr(expr, function(result)
45-
state.expression_results[line] = result
46-
end)
66+
eval.eval_expr(expr)
4767
end
4868

4969
return M

lua/dap-view/watches/eval.lua

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,37 @@
1+
local state = require("dap-view.state")
2+
13
local M = {}
24

5+
---@param variables_reference number
6+
---@param frame_id? number
7+
local eval_variables = function(variables_reference, frame_id)
8+
local session = assert(require("dap").session(), "has active session")
9+
10+
local err, result = session:request(
11+
"variables",
12+
{ variablesReference = variables_reference, context = "watch", frameId = frame_id }
13+
)
14+
15+
local response = err and tostring(err) or result and result.variables
16+
17+
--[[@type {variable?: dap.Variable, updated?: boolean}[] | string]]
18+
local variables = type(response) == "string" and response or {}
19+
20+
local original = state.variables_by_reference[variables_reference]
21+
22+
-- Lua's type checking is a lackluster
23+
if type(variables) ~= "string" and type(response) ~= "string" then
24+
for k, var in pairs(response or {}) do
25+
local updated = type(original) == "table" and original[k].variable.value ~= var.value or false
26+
table.insert(variables, { variable = var, updated = updated })
27+
end
28+
end
29+
30+
state.variables_by_reference[variables_reference] = variables
31+
end
32+
333
---@param expr string
4-
---@param callback fun(result: string): nil
5-
M.eval_expr = function(expr, callback)
34+
M.eval_expr = function(expr)
635
local session = assert(require("dap").session(), "has active session")
736

837
coroutine.wrap(function()
@@ -11,35 +40,37 @@ M.eval_expr = function(expr, callback)
1140
local err, result =
1241
session:request("evaluate", { expression = expr, context = "watch", frameId = frame_id })
1342

14-
local expr_result = result and result.result or err and tostring(err):gsub("%s+", " ") or ""
43+
local original = state.watched_expressions[expr] and state.watched_expressions[expr].response.result
44+
local response = err and tostring(err) or result
45+
local updated = original and response and original ~= response.result
46+
state.watched_expressions[expr] = { response = response, updated = updated }
1547

16-
-- TODO currently, we only check for variables reference for the top level expression
17-
-- It would be nice to expose functionality to let the user control the depth
18-
-- This could be a config parameter (eg, base depth) but could also extend with an action (BFS)
1948
local variables_reference = result and result.variablesReference
2049
if variables_reference and variables_reference > 0 then
21-
local enhanced_expr_result = { expr_result }
22-
23-
local var_ref_err, var_ref_result = session:request(
24-
"variables",
25-
{ variablesReference = variables_reference, context = "watch", frameId = frame_id }
26-
)
27-
28-
if var_ref_err then
29-
table.insert(enhanced_expr_result, tostring(err))
30-
elseif var_ref_result then
31-
for _, k in pairs(var_ref_result.variables) do
32-
table.insert(enhanced_expr_result, "\t" .. k.name .. " = " .. k.value)
33-
end
34-
end
35-
36-
local final_result = table.concat(enhanced_expr_result, "\n")
37-
38-
callback(final_result)
39-
else
40-
callback(expr_result)
50+
eval_variables(variables_reference, frame_id)
4151
end
4252
end)()
4353
end
4454

55+
---@param expr string
56+
M.copy_expr = function(expr)
57+
local session = assert(require("dap").session(), "has active session")
58+
59+
if session.capabilities.supportsClipboardContext then
60+
coroutine.wrap(function()
61+
local frame_id = session.current_frame and session.current_frame.id
62+
63+
local err, result =
64+
session:request("evaluate", { expression = expr, context = "clipboard", frameId = frame_id })
65+
66+
if err == nil and result then
67+
-- TODO uses system clipboard, could be a parameter instead
68+
vim.fn.setreg("+", result.result)
69+
end
70+
end)()
71+
else
72+
vim.notify("Adapter doesn't support clipboard evaluation")
73+
end
74+
end
75+
4576
return M

0 commit comments

Comments
 (0)