Repository navigation
Expand file tree
/
Copy pathdialog.lua
More file actions
602 lines (522 loc) · 17.8 KB
/
Copy pathdialog.lua
File metadata and controls
602 lines (522 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
---@class DialogConfig
---@field buffer integer Buffer ID where keymaps should be set
---@field on_select function(index: integer) Called when an option is selected
---@field on_dismiss? function() Called when dialog is dismissed
---@field on_navigate? function() Called when selection changes
---@field on_navigate_group? function(index: integer) Called when group selection changes
---@field get_option_count function(): integer Returns the total number of options
---@field get_shortcut_count? function(): integer Returns the options addressable by toggle and number shortcuts
---@field get_group_count? function(): integer Returns the total number of groups
---@field check_focused? function(): boolean Returns whether dialog should be active
---@field is_multiple? boolean Whether dialog is in multi-select mode
---@field keymaps? DialogKeymaps Custom keymap configuration
---@field namespace_prefix? string Prefix for vim.on_key namespace (default: 'opencode_dialog')
---@field hide_input? boolean Whether to hide the input window when dialog is active (default: true)
---@field show_dismiss_legend? boolean Whether to render the generic dismiss hint (default: true)
---@class DialogKeymaps
---@field up? string[] Keys for navigating up (default: {'k', '<Up>'})
---@field down? string[] Keys for navigating down (default: {'j', '<Down>'})
---@field left? string[] Keys for navigating left between groups
---@field right? string[] Keys for navigating right between groups
---@field select? string Key for selecting current option (default: '<CR>')
---@field select_aliases? string[] Additional keys for selecting the current option
---@field dismiss? string Key for dismissing dialog (default: '<Esc>')
---@field toggle? string Key for toggling multi-select options (default: '<Tab>')
---@field toggle_aliases? string[] Additional keys for toggling multi-select options
---@field number_shortcuts? boolean Enable 1-9 number shortcuts (default: true)
---@class Dialog
---@field private _config DialogConfig
---@field private _keymaps string[] List of key bindings for cleanup
---@field private _key_capture_ns integer? Namespace for vim.on_key
---@field private _selected_index integer Currently selected option index
---@field private _active boolean Whether dialog is currently active
---@field private _group_index integer Currently selected group index
---@field private _option_positions table<integer, {line: integer, col: integer}>?
---@field private _is_multiple boolean Whether dialog is in multi-select mode
local Dialog = {}
Dialog.__index = Dialog
---Create a new dialog instance
---@param config DialogConfig Dialog configuration
---@return Dialog
function Dialog.new(config)
local self = setmetatable({}, Dialog)
-- Set up default keymaps if not provided
local default_keymaps = {
up = { 'k', '<Up>' },
down = { 'j', '<Down>' },
left = {},
right = {},
select = '<CR>',
dismiss = '<Esc>',
toggle = '<Tab>',
number_shortcuts = true,
}
self._config = vim.tbl_deep_extend('force', {
keymaps = default_keymaps,
namespace_prefix = 'opencode_dialog',
check_focused = function()
return true
end,
hide_input = true,
show_dismiss_legend = true,
} --[[@as DialogConfig]], config)
self._keymaps = {}
self._key_capture_ns = nil
self._selected_index = 1
self._group_index = 1
self._active = false
self._is_multiple = self._config.is_multiple or false
return self
end
---Get the currently selected option index
---@return integer
function Dialog:get_selection()
return self._selected_index
end
---Set the selected option index
---@param index integer Option index to select
function Dialog:set_selection(index)
local option_count = self._config.get_option_count()
if index >= 1 and index <= option_count then
self._selected_index = index
end
end
---@return integer
function Dialog:get_group_selection()
return self._group_index
end
---@param index integer
function Dialog:set_group_selection(index)
local group_count = self._config.get_group_count and self._config.get_group_count() or 0
if group_count == 0 then
self._group_index = 1
return
end
if index >= 1 and index <= group_count then
self._group_index = index
end
end
---Navigate selection by delta (positive for down, negative for up)
---@param delta integer Amount to move selection
function Dialog:navigate(delta)
if not self._active or not self._config.check_focused() then
return
end
local option_count = self._config.get_option_count()
if option_count == 0 then
return
end
self._selected_index = self._selected_index + delta
-- Wrap around selection
if self._selected_index < 1 then
self._selected_index = option_count
elseif self._selected_index > option_count then
self._selected_index = 1
end
if self._config.on_navigate then
self._config.on_navigate()
end
end
---@param delta integer
function Dialog:navigate_group(delta)
if not self._active or not self._config.check_focused() or not self._config.on_navigate_group then
return
end
local group_count = self._config.get_group_count and self._config.get_group_count() or 0
if group_count <= 1 then
return
end
self._group_index = self._group_index + delta
if self._group_index < 1 then
self._group_index = group_count
elseif self._group_index > group_count then
self._group_index = 1
end
self._config.on_navigate_group(self._group_index)
end
---Select the current option
function Dialog:select()
if not self._active or not self._config.check_focused() then
return
end
local option_count = self._config.get_option_count()
if option_count == 0 then
return
end
self._config.on_select(self._selected_index)
end
---Act on the current multi-select choice without activating trailing action rows.
function Dialog:toggle()
if not self._active or not self._config.check_focused() then
return
end
local shortcut_count = self._config.get_shortcut_count and self._config.get_shortcut_count()
or self._config.get_option_count()
if self._selected_index < 1 or self._selected_index > shortcut_count then
return
end
self._config.on_select(self._selected_index)
end
---Dismiss the dialog
function Dialog:dismiss()
if not self._active or not self._config.check_focused() then
return
end
if self._config.on_dismiss then
self._config.on_dismiss()
end
end
---Set up keymaps and activate the dialog
function Dialog:setup()
if self._active then
self:teardown()
end
self._active = true
-- Hide input window if configured
if self._config.hide_input then
local input_window = require('opencode.ui.input_window')
input_window._hide()
end
self:_setup_keymaps()
end
---Clean up keymaps and deactivate the dialog
function Dialog:teardown()
self._active = false
self:_clear_keymaps()
-- Show input window if it was hidden, but only if auto_hide is disabled
if self._config.hide_input then
local config = require('opencode.config')
local input_window = require('opencode.ui.input_window')
if not config.ui.input.auto_hide then
input_window._show()
end
end
end
---Check if dialog is currently active
---@return boolean
function Dialog:is_active()
return self._active
end
---Format the legend/instructions for this dialog
---@param output Output Output object to write to
---@param options? table Options for legend formatting
function Dialog:format_legend(output, options)
options = options or {}
local ui = require('opencode.ui.ui')
if not self._active then
return
end
local option_count = self._config.get_option_count()
if option_count == 0 then
return
end
if ui.is_opencode_focused() then
local keymaps = self._config.keymaps
if not keymaps then
return
end
if keymaps.up and #keymaps.up > 0 and keymaps.down and #keymaps.down > 0 then
local line = output:add_line('Move: `j/k` or `↑/↓`')
end
if keymaps.left and #keymaps.left > 0 and keymaps.right and #keymaps.right > 0 then
local line = output:add_line('Question: `h/l` or `<-/->`')
end
if self._is_multiple then
local action_keys = { keymaps.select, keymaps.toggle }
for _, key in ipairs(keymaps.toggle_aliases or {}) do
if not vim.tbl_contains(action_keys, key) then
table.insert(action_keys, key)
end
end
local action_text = 'Toggle/Edit: `' .. table.concat(action_keys, '` or `') .. '`'
local shortcut_count = self._config.get_shortcut_count and self._config.get_shortcut_count() or option_count
if keymaps.number_shortcuts and shortcut_count > 0 then
local max_shortcut = math.min(shortcut_count, 9)
action_text = action_text .. string.format(' or `1-%d`', max_shortcut)
end
output:add_line(action_text)
output:add_line('Submit: select Confirm and press `' .. keymaps.select .. '`')
elseif keymaps.select and keymaps.select ~= '' then
local select_keys = { keymaps.select }
for _, key in ipairs(keymaps.select_aliases or {}) do
table.insert(select_keys, key)
end
local action = #select_keys > 1 and 'Choose/Edit' or 'Select'
local select_text = action .. ': `' .. table.concat(select_keys, '` or `') .. '`'
if keymaps.number_shortcuts and option_count > 0 then
local max_shortcut = math.min(option_count, 9)
select_text = select_text .. string.format(' or `1-%d`', max_shortcut)
end
output:add_line(select_text)
end
if self._config.show_dismiss_legend and keymaps.dismiss and keymaps.dismiss ~= '' then
output:add_line('Close: `<Esc>`')
end
if options.legend_lines then
for _, line in ipairs(options.legend_lines) do
output:add_line(line)
end
end
else
local message = options.unfocused_message or 'Focus Opencode window to interact'
output:add_line(message)
end
end
---Format a complete dialog with title, options, legend, and border
---@param output Output Output object to write to
---@param config table Configuration for dialog rendering
--- - title: string - Dialog title
--- - title_hl: string - Highlight group for title
--- - border_hl: string - Highlight group for border
--- - options: table[] - Array of option objects with {label: string, description?: string}
--- - unfocused_message: string - Message to show when not focused
--- - progress?: string - Progress indicator (e.g., "(1/3)")
--- - content?: string[] - Array of lines to render before options
--- - render_content?: function(output: Output) - Custom function to render content before options
function Dialog:format_dialog(output, config)
if not self._active then
return
end
local formatter = require('opencode.ui.formatter')
local start_line = output:get_line_count()
local title = config.title or 'Dialog'
if config.progress then
title = title .. config.progress
end
output:add_line(title)
if config.title_hl then
output:add_extmark(start_line, { line_hl_group = config.title_hl } --[[@as OutputExtmark]])
end
output:add_line('')
if config.render_content then
config.render_content(output)
output:add_line('')
elseif config.content then
for _, line in ipairs(config.content) do
output:add_line(line)
end
output:add_line('')
end
self:format_options(output, config.options or {})
output:add_line('')
self:format_legend(output, { unfocused_message = config.unfocused_message, legend_lines = config.legend_lines })
local end_line = output:get_line_count()
if config.border_hl then
local border_end = end_line
if config.extend_border_to_trailing_blank then
border_end = border_end + 1
end
formatter.add_vertical_border(output, start_line + 1, border_end, config.border_hl, -2)
end
output:add_line('')
end
---Format options list with selection indicator
---@param output Output Output object to write to
---@param options table[] Array of option objects with {label: string, description?: string}
function Dialog:format_options(output, options)
self._option_positions = {}
for i, option in ipairs(options) do
local label = option.label
if option.description and option.description ~= '' then
label = label .. ' - ' .. option.description
end
local is_cursor = self._selected_index == i
local prefix
if self._is_multiple then
if option.confirm then
prefix = ' → '
else
local icons = require('opencode.ui.icons')
local checkbox = option.checked and icons.get('checkbox_checked') or icons.get('checkbox_unchecked')
prefix = string.format(' %s %d. ', checkbox, i)
end
else
prefix = string.format(' %d. ', i)
end
local line_text = is_cursor and (prefix .. label .. ' ') or (prefix .. label)
local added_idx = output:add_line(line_text)
local extmark_idx = added_idx - 1
self._option_positions[i] = { line = extmark_idx, col = #prefix }
if is_cursor then
output:add_extmark(extmark_idx, { line_hl_group = 'OpencodeDialogOptionHover' } --[[@as OutputExtmark]])
if not self._is_multiple then
output:add_extmark(extmark_idx, {
start_col = 2,
virt_text = { { '› ', 'OpencodeDialogOptionHover' } },
virt_text_pos = 'overlay',
} --[[@as OutputExtmark]])
end
end
end
end
---Get the (line, col) of a rendered option from the most recent
---format_options call. 0-based, relative to this dialog's Output block —
---callers must add the containing part's `line_start` (from render_state)
---to get an absolute buffer row.
---@param index integer
---@return { line: integer, col: integer }|nil
function Dialog:get_option_position(index)
return self._option_positions and self._option_positions[index]
end
---Set up buffer-scoped keymaps
function Dialog:_setup_keymaps()
self:_clear_keymaps()
local buf = self._config.buffer
if not buf or not vim.api.nvim_buf_is_valid(buf) then
return
end
local keymaps = self._config.keymaps
local keymap_opts = { buffer = buf, silent = true }
local function map_select(key)
if not key or key == '' then
return
end
vim.keymap.set(
'n',
key,
function()
self:select()
end,
vim.tbl_extend('force', keymap_opts, {
desc = 'Dialog: select option',
})
)
table.insert(self._keymaps, key)
end
if keymaps.up then
for _, key in ipairs(keymaps.up) do
if key and key ~= '' then
vim.keymap.set(
'n',
key,
function()
self:navigate(-1)
end,
vim.tbl_extend('force', keymap_opts, {
desc = 'Dialog: navigate up',
})
)
table.insert(self._keymaps, key)
end
end
end
if keymaps.down then
for _, key in ipairs(keymaps.down) do
if key and key ~= '' then
vim.keymap.set(
'n',
key,
function()
self:navigate(1)
end,
vim.tbl_extend('force', keymap_opts, {
desc = 'Dialog: navigate down',
})
)
table.insert(self._keymaps, key)
end
end
end
if keymaps.left then
for _, key in ipairs(keymaps.left) do
if key and key ~= '' then
vim.keymap.set(
'n',
key,
function()
self:navigate_group(-1)
end,
vim.tbl_extend('force', keymap_opts, {
desc = 'Dialog: navigate left',
})
)
table.insert(self._keymaps, key)
end
end
end
if keymaps.right then
for _, key in ipairs(keymaps.right) do
if key and key ~= '' then
vim.keymap.set(
'n',
key,
function()
self:navigate_group(1)
end,
vim.tbl_extend('force', keymap_opts, {
desc = 'Dialog: navigate right',
})
)
table.insert(self._keymaps, key)
end
end
end
if self._is_multiple then
map_select(keymaps.select)
local function map_toggle(key)
if not key or key == '' or key == keymaps.select then
return
end
vim.keymap.set('n', key, function()
self:toggle()
end, vim.tbl_extend('force', keymap_opts, { desc = 'Dialog: toggle option', nowait = true }))
table.insert(self._keymaps, key)
end
map_toggle(keymaps.toggle)
for _, key in ipairs(keymaps.toggle_aliases or {}) do
map_toggle(key)
end
elseif keymaps.select and keymaps.select ~= '' then
map_select(keymaps.select)
for _, key in ipairs(keymaps.select_aliases or {}) do
map_select(key)
end
end
if keymaps.dismiss and keymaps.dismiss ~= '' then
vim.keymap.set(
'n',
keymaps.dismiss,
function()
self:dismiss()
end,
vim.tbl_extend('force', keymap_opts, {
desc = 'Dialog: dismiss',
})
)
table.insert(self._keymaps, keymaps.dismiss)
end
if keymaps.number_shortcuts then
local option_count = self._config.get_shortcut_count and self._config.get_shortcut_count()
or self._config.get_option_count()
local number_keymap_opts = vim.tbl_extend('force', keymap_opts, { nowait = true })
for i = 1, math.min(option_count, 9) do
local key = tostring(i)
vim.keymap.set(
'n',
key,
function()
if not self._active or not self._config.check_focused() then
return
end
self._selected_index = i
self._config.on_select(i)
end,
vim.tbl_extend('force', number_keymap_opts, {
desc = 'Dialog: select option ' .. key,
})
)
table.insert(self._keymaps, key)
end
end
end
---Clear all buffer-scoped keymaps
function Dialog:_clear_keymaps()
local buf = self._config.buffer
if buf and vim.api.nvim_buf_is_valid(buf) then
for _, key in ipairs(self._keymaps) do
pcall(vim.keymap.del, 'n', key, { buffer = buf })
end
end
self._keymaps = {}
end
return Dialog