Skip to content

Commit 8fd3d29

Browse files
committed
feat: add multi-select mass editing in TUI
- Add space key to toggle bean selection in list view - Support batch updates for status (s), type (t), priority (P), and parent (p) - Highlight selected bean IDs in yellow - Show "(N selected)" count in footer with matching yellow color - Clear selection with Esc before clearing filter - Pickers show "N selected beans" title for multi-select operations - Parent picker validates type constraints across all selected beans
1 parent 1ab750e commit 8fd3d29

File tree

9 files changed

+364
-149
lines changed

9 files changed

+364
-149
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
title: Multi-select mass editing in TUI
3+
status: completed
4+
type: feature
5+
created_at: 2025-12-13T02:27:04Z
6+
updated_at: 2025-12-13T02:27:04Z
7+
---
8+
9+
Add ability to mark multiple beans using space key and apply status/type/priority changes to all selected beans at once.
10+
11+
## Implementation
12+
- Added selection state (map[string]bool) to list model
13+
- Space key toggles selection on current bean
14+
- Green * marker shows before selected beans
15+
- s/t/P keys now work with multiple selected beans
16+
- Selection count shown in footer
17+
- Esc clears selection before clearing filter
18+
- Selection persists when entering detail view

internal/tui/detail.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -321,9 +321,9 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) {
321321
// Open parent picker
322322
return m, func() tea.Msg {
323323
return openParentPickerMsg{
324-
beanID: m.bean.ID,
324+
beanIDs: []string{m.bean.ID},
325325
beanTitle: m.bean.Title,
326-
beanType: m.bean.Type,
326+
beanTypes: []string{m.bean.Type},
327327
currentParent: m.bean.Parent,
328328
}
329329
}
@@ -332,7 +332,7 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) {
332332
// Open status picker
333333
return m, func() tea.Msg {
334334
return openStatusPickerMsg{
335-
beanID: m.bean.ID,
335+
beanIDs: []string{m.bean.ID},
336336
beanTitle: m.bean.Title,
337337
currentStatus: m.bean.Status,
338338
}
@@ -342,7 +342,7 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) {
342342
// Open type picker
343343
return m, func() tea.Msg {
344344
return openTypePickerMsg{
345-
beanID: m.bean.ID,
345+
beanIDs: []string{m.bean.ID},
346346
beanTitle: m.bean.Title,
347347
currentType: m.bean.Type,
348348
}
@@ -352,7 +352,7 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) {
352352
// Open priority picker
353353
return m, func() tea.Msg {
354354
return openPriorityPickerMsg{
355-
beanID: m.bean.ID,
355+
beanIDs: []string{m.bean.ID},
356356
beanTitle: m.bean.Title,
357357
currentPriority: m.bean.Priority,
358358
}

internal/tui/list.go

Lines changed: 138 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ func (i beanItem) FilterValue() string { return i.bean.Title + " " + i.bean.ID }
2929

3030
// itemDelegate handles rendering of list items
3131
type itemDelegate struct {
32-
cfg *config.Config
33-
hasTags bool
34-
width int
35-
cols ui.ResponsiveColumns // cached responsive columns
36-
idColWidth int // ID column width (accounts for tree prefix)
32+
cfg *config.Config
33+
hasTags bool
34+
width int
35+
cols ui.ResponsiveColumns // cached responsive columns
36+
idColWidth int // ID column width (accounts for tree prefix)
37+
selectedBeans *map[string]bool // pointer to marked beans for multi-select
3738
}
3839

3940
func newItemDelegate(cfg *config.Config) itemDelegate {
@@ -64,6 +65,12 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
6465
}
6566
maxTitleWidth := max(0, m.Width()-baseWidth)
6667

68+
// Check if bean is marked for multi-select
69+
var isMarked bool
70+
if d.selectedBeans != nil {
71+
isMarked = (*d.selectedBeans)[item.bean.ID]
72+
}
73+
6774
str := ui.RenderBeanRow(
6875
item.bean.ID,
6976
item.bean.Status,
@@ -78,6 +85,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
7885
MaxTitleWidth: maxTitleWidth,
7986
ShowCursor: true,
8087
IsSelected: index == m.Index(),
88+
IsMarked: isMarked,
8189
Tags: item.bean.Tags,
8290
ShowTags: d.cols.ShowTags,
8391
TagsColWidth: d.cols.Tags,
@@ -107,10 +115,14 @@ type listModel struct {
107115

108116
// Active filters
109117
tagFilter string // if set, only show beans with this tag
118+
119+
// Multi-select state
120+
selectedBeans map[string]bool // IDs of beans marked for multi-edit
110121
}
111122

112123
func newListModel(resolver *graph.Resolver, cfg *config.Config) listModel {
113-
delegate := newItemDelegate(cfg)
124+
selectedBeans := make(map[string]bool)
125+
delegate := itemDelegate{cfg: cfg, selectedBeans: &selectedBeans}
114126

115127
l := list.New([]list.Item{}, delegate, 0, 0)
116128
l.Title = "Beans"
@@ -123,9 +135,10 @@ func newListModel(resolver *graph.Resolver, cfg *config.Config) listModel {
123135
l.Styles.FilterCursor = lipgloss.NewStyle().Foreground(ui.ColorPrimary)
124136

125137
return listModel{
126-
list: l,
127-
resolver: resolver,
128-
config: cfg,
138+
list: l,
139+
resolver: resolver,
140+
config: cfg,
141+
selectedBeans: selectedBeans,
129142
}
130143
}
131144

@@ -251,52 +264,119 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
251264
case tea.KeyMsg:
252265
if m.list.FilterState() != list.Filtering {
253266
switch msg.String() {
267+
case " ":
268+
// Toggle selection for multi-select
269+
if item, ok := m.list.SelectedItem().(beanItem); ok {
270+
if m.selectedBeans[item.bean.ID] {
271+
delete(m.selectedBeans, item.bean.ID)
272+
} else {
273+
m.selectedBeans[item.bean.ID] = true
274+
}
275+
}
276+
return m, nil
254277
case "enter":
255278
if item, ok := m.list.SelectedItem().(beanItem); ok {
256279
return m, func() tea.Msg {
257280
return selectBeanMsg{bean: item.bean}
258281
}
259282
}
260283
case "p":
261-
// Open parent picker for selected bean
262-
if item, ok := m.list.SelectedItem().(beanItem); ok {
284+
// Open parent picker for selected bean(s)
285+
if len(m.selectedBeans) > 0 {
286+
// Multi-select mode
287+
ids := make([]string, 0, len(m.selectedBeans))
288+
types := make([]string, 0, len(m.selectedBeans))
289+
for id := range m.selectedBeans {
290+
ids = append(ids, id)
291+
// Find the bean to get its type
292+
for _, item := range m.list.Items() {
293+
if bi, ok := item.(beanItem); ok && bi.bean.ID == id {
294+
types = append(types, bi.bean.Type)
295+
break
296+
}
297+
}
298+
}
263299
return m, func() tea.Msg {
264300
return openParentPickerMsg{
265-
beanID: item.bean.ID,
301+
beanIDs: ids,
302+
beanTitle: fmt.Sprintf("%d selected beans", len(ids)),
303+
beanTypes: types,
304+
}
305+
}
306+
} else if item, ok := m.list.SelectedItem().(beanItem); ok {
307+
return m, func() tea.Msg {
308+
return openParentPickerMsg{
309+
beanIDs: []string{item.bean.ID},
266310
beanTitle: item.bean.Title,
267-
beanType: item.bean.Type,
311+
beanTypes: []string{item.bean.Type},
268312
currentParent: item.bean.Parent,
269313
}
270314
}
271315
}
272316
case "s":
273-
// Open status picker for selected bean
274-
if item, ok := m.list.SelectedItem().(beanItem); ok {
317+
// Open status picker for selected bean(s)
318+
if len(m.selectedBeans) > 0 {
319+
// Multi-select mode
320+
ids := make([]string, 0, len(m.selectedBeans))
321+
for id := range m.selectedBeans {
322+
ids = append(ids, id)
323+
}
275324
return m, func() tea.Msg {
276325
return openStatusPickerMsg{
277-
beanID: item.bean.ID,
326+
beanIDs: ids,
327+
beanTitle: fmt.Sprintf("%d selected beans", len(ids)),
328+
}
329+
}
330+
} else if item, ok := m.list.SelectedItem().(beanItem); ok {
331+
return m, func() tea.Msg {
332+
return openStatusPickerMsg{
333+
beanIDs: []string{item.bean.ID},
278334
beanTitle: item.bean.Title,
279335
currentStatus: item.bean.Status,
280336
}
281337
}
282338
}
283339
case "t":
284-
// Open type picker for selected bean
285-
if item, ok := m.list.SelectedItem().(beanItem); ok {
340+
// Open type picker for selected bean(s)
341+
if len(m.selectedBeans) > 0 {
342+
// Multi-select mode
343+
ids := make([]string, 0, len(m.selectedBeans))
344+
for id := range m.selectedBeans {
345+
ids = append(ids, id)
346+
}
347+
return m, func() tea.Msg {
348+
return openTypePickerMsg{
349+
beanIDs: ids,
350+
beanTitle: fmt.Sprintf("%d selected beans", len(ids)),
351+
}
352+
}
353+
} else if item, ok := m.list.SelectedItem().(beanItem); ok {
286354
return m, func() tea.Msg {
287355
return openTypePickerMsg{
288-
beanID: item.bean.ID,
356+
beanIDs: []string{item.bean.ID},
289357
beanTitle: item.bean.Title,
290358
currentType: item.bean.Type,
291359
}
292360
}
293361
}
294362
case "P":
295-
// Open priority picker for selected bean
296-
if item, ok := m.list.SelectedItem().(beanItem); ok {
363+
// Open priority picker for selected bean(s)
364+
if len(m.selectedBeans) > 0 {
365+
// Multi-select mode
366+
ids := make([]string, 0, len(m.selectedBeans))
367+
for id := range m.selectedBeans {
368+
ids = append(ids, id)
369+
}
297370
return m, func() tea.Msg {
298371
return openPriorityPickerMsg{
299-
beanID: item.bean.ID,
372+
beanIDs: ids,
373+
beanTitle: fmt.Sprintf("%d selected beans", len(ids)),
374+
}
375+
}
376+
} else if item, ok := m.list.SelectedItem().(beanItem); ok {
377+
return m, func() tea.Msg {
378+
return openPriorityPickerMsg{
379+
beanIDs: []string{item.bean.ID},
300380
beanTitle: item.bean.Title,
301381
currentPriority: item.bean.Priority,
302382
}
@@ -329,7 +409,12 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
329409
}
330410
}
331411
case "esc", "backspace":
332-
// If we have an active filter, clear it instead of quitting
412+
// First clear selection if any beans are selected
413+
if len(m.selectedBeans) > 0 {
414+
clear(m.selectedBeans)
415+
return m, nil
416+
}
417+
// Then clear active filter if any
333418
if m.hasActiveFilter() {
334419
return m, func() tea.Msg {
335420
return clearFilterMsg{}
@@ -347,11 +432,12 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
347432
// updateDelegate updates the list delegate with current responsive columns
348433
func (m *listModel) updateDelegate() {
349434
delegate := itemDelegate{
350-
cfg: m.config,
351-
hasTags: m.hasTags,
352-
width: m.width,
353-
cols: m.cols,
354-
idColWidth: m.idColWidth,
435+
cfg: m.config,
436+
hasTags: m.hasTags,
437+
width: m.width,
438+
cols: m.cols,
439+
idColWidth: m.idColWidth,
440+
selectedBeans: &m.selectedBeans,
355441
}
356442
m.list.SetDelegate(delegate)
357443
}
@@ -381,10 +467,28 @@ func (m listModel) View() string {
381467

382468
content := border.Render(m.list.View())
383469

384-
// Footer - show different help based on filter state
470+
// Footer - show different help based on filter/selection state
385471
var help string
386-
if m.hasActiveFilter() {
387-
help = helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " +
472+
473+
// Show selection count if any beans are selected
474+
var selectionPrefix string
475+
if len(m.selectedBeans) > 0 {
476+
selectionStyle := lipgloss.NewStyle().Foreground(ui.ColorWarning).Bold(true)
477+
selectionPrefix = selectionStyle.Render(fmt.Sprintf("(%d selected) ", len(m.selectedBeans)))
478+
}
479+
480+
if len(m.selectedBeans) > 0 {
481+
// When beans are selected, show esc to clear selection
482+
help = helpKeyStyle.Render("space") + " " + helpStyle.Render("toggle") + " " +
483+
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
484+
helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " +
485+
helpKeyStyle.Render("P") + " " + helpStyle.Render("priority") + " " +
486+
helpKeyStyle.Render("esc") + " " + helpStyle.Render("clear selection") + " " +
487+
helpKeyStyle.Render("?") + " " + helpStyle.Render("help") + " " +
488+
helpKeyStyle.Render("q") + " " + helpStyle.Render("quit")
489+
} else if m.hasActiveFilter() {
490+
help = helpKeyStyle.Render("space") + " " + helpStyle.Render("select") + " " +
491+
helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " +
388492
helpKeyStyle.Render("c") + " " + helpStyle.Render("create") + " " +
389493
helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " +
390494
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
@@ -396,7 +500,8 @@ func (m listModel) View() string {
396500
helpKeyStyle.Render("?") + " " + helpStyle.Render("help") + " " +
397501
helpKeyStyle.Render("q") + " " + helpStyle.Render("quit")
398502
} else {
399-
help = helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " +
503+
help = helpKeyStyle.Render("space") + " " + helpStyle.Render("select") + " " +
504+
helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " +
400505
helpKeyStyle.Render("c") + " " + helpStyle.Render("create") + " " +
401506
helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " +
402507
helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " +
@@ -409,6 +514,6 @@ func (m listModel) View() string {
409514
helpKeyStyle.Render("q") + " " + helpStyle.Render("quit")
410515
}
411516

412-
return content + "\n" + help
517+
return content + "\n" + selectionPrefix + help
413518
}
414519

0 commit comments

Comments
 (0)