Add multiselect board filters and letter-jump on picker menus.

Persist comma-separated list filters per project, toggle with space, and jump by first letter in board and project pickers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Skruecken
2026-10-19 10:42:59 +02:00
co-authored by Cursor
parent aa8f89b43a
commit 41feba11de
9 changed files with 305 additions and 39 deletions
+27 -11
View File
@@ -52,7 +52,7 @@ type Model struct {
projectList list.Model
statusMenu views.StatusMenu
boardMenu views.BoardMenu
boardList ticket.BoardList
boardFilter ticket.BoardFilter
input textinput.Model
width int
height int
@@ -80,7 +80,7 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, exec co
projects: projects,
exec: exec,
mode: ModeList,
boardList: ticket.ParseBoardList(cfg.ActiveBoardList(cfg.ActiveProject)),
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
tickets: views.TicketList{
Styles: views.TicketListStyles{
Card: styles.Card,
@@ -208,7 +208,7 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Board):
m.clearStatus()
m.boardMenu = views.NewBoardMenu(m.boardList, m.width, views.StatusMenuStyles{
m.boardMenu = views.NewBoardMenu(m.boardFilter, m.width, views.StatusMenuStyles{
Box: m.styles.StatusMenuBox,
Title: m.styles.StatusMenuTitle,
Selected: m.styles.StatusMenuSelected,
@@ -329,13 +329,17 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
case key.Matches(msg, m.keys.Enter):
if err := m.applyBoardList(m.boardMenu.Selected()); err != nil {
if err := m.applyBoardFilter(m.boardMenu.Selected()); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeList
return m, nil
case key.Matches(msg, m.keys.Toggle):
m.boardMenu.Toggle()
return m, nil
case key.Matches(msg, m.keys.Down):
m.boardMenu.MoveDown()
return m, nil
@@ -344,6 +348,12 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.boardMenu.MoveUp()
return m, nil
}
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
if m.boardMenu.JumpToShortcut(msg.Runes[0]) {
return m, nil
}
}
return m, nil
}
@@ -475,6 +485,12 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
if views.JumpProjectByLetter(&m.projectList, msg.Runes[0]) {
return m, nil
}
}
var cmd tea.Cmd
m.projectList, cmd = m.projectList.Update(msg)
return m, cmd
@@ -575,7 +591,7 @@ func (m *Model) createTicket(title, description string) error {
Title: title,
Description: description,
ProjectID: m.cfg.ActiveProject,
Status: m.boardList.DefaultStatus(),
Status: m.boardFilter.DefaultStatus(),
},
}
if err := m.exec.Do(cmd); err != nil {
@@ -661,14 +677,14 @@ func (m *Model) redo() error {
return nil
}
func (m *Model) applyBoardList(bl ticket.BoardList) error {
m.boardList = bl
m.cfg.SetActiveBoardList(m.cfg.ActiveProject, string(bl))
func (m *Model) applyBoardFilter(f ticket.BoardFilter) error {
m.boardFilter = ticket.NormalizeFilter(f)
m.cfg.SetActiveBoardLists(m.cfg.ActiveProject, ticket.FormatBoardLists(m.boardFilter))
if err := config.Save(m.cfg); err != nil {
return err
}
m.applyFilter()
m.setStatus(fmt.Sprintf("list → %s", bl.Label()))
m.setStatus(fmt.Sprintf("list → %s", m.boardFilter.Label()))
return nil
}
@@ -677,7 +693,7 @@ func (m *Model) switchProject(id string) error {
return err
}
m.cfg.ActiveProject = id
m.boardList = ticket.ParseBoardList(m.cfg.ActiveBoardList(id))
m.boardFilter = ticket.ParseBoardLists(m.cfg.ActiveBoardLists(id))
if err := config.Save(m.cfg); err != nil {
return err
}
@@ -721,7 +737,7 @@ func (m *Model) reloadTickets() error {
func (m *Model) applyFilter() {
filtered := make([]ticket.Ticket, 0, len(m.allTickets))
for _, t := range m.allTickets {
if m.boardList.Matches(t.Status) {
if m.boardFilter.Matches(t.Status) {
filtered = append(filtered, t)
}
}
+4 -1
View File
@@ -30,6 +30,7 @@ func (m Model) helpEntries() []helpEntry {
case ModeProjects:
return []helpEntry{
{"j/k", "navigate"},
{"a-z", "jump"},
{"enter", "select"},
{"n", "new project"},
{"esc", "cancel"},
@@ -43,7 +44,9 @@ func (m Model) helpEntries() []helpEntry {
}
case ModeBoard:
return []helpEntry{
{"j/k", "select"},
{"j/k", "navigate"},
{"a/b/i/d", "jump"},
{"space", "toggle"},
{"enter", "apply"},
{"esc", "cancel"},
}
+5
View File
@@ -16,6 +16,7 @@ type KeyMap struct {
Delete key.Binding // x — confirm first
DeleteForce key.Binding // X — immediate
ConfirmYes key.Binding
Toggle key.Binding // space — toggle multi-select
Undo key.Binding
Redo key.Binding
Quit key.Binding
@@ -74,6 +75,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("y", "Y"),
key.WithHelp("y", "confirm"),
),
Toggle: key.NewBinding(
key.WithKeys(" "),
key.WithHelp("space", "toggle"),
),
Undo: key.NewBinding(
key.WithKeys("u", "ctrl+z"),
key.WithHelp("u", "undo"),
+1 -1
View File
@@ -12,7 +12,7 @@ import (
func (m Model) renderHeader() string {
brand := m.styles.HeaderBrand.Render(config.AppName)
project := m.styles.HeaderMeta.Render(fmt.Sprintf("project: %s", m.cfg.ActiveProject))
board := m.styles.FilterHint.Render(fmt.Sprintf("list: %s", m.boardList.Label()))
board := m.styles.FilterHint.Render(fmt.Sprintf("list: %s", m.boardFilter.Label()))
count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))
parts := []string{brand, project, board, count}
+75 -20
View File
@@ -1,24 +1,40 @@
package views
import (
"fmt"
"strings"
"unicode"
"task/internal/ticket"
)
// BoardMenu renders a board-list picker overlay.
// BoardMenu renders a multi-select board-list picker overlay.
type BoardMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
Cursor int
Width int
Styles StatusMenuStyles
selected map[ticket.BoardList]bool
}
// NewBoardMenu creates a menu with the given board list pre-selected.
func NewBoardMenu(current ticket.BoardList, width int, styles StatusMenuStyles) BoardMenu {
// NewBoardMenu creates a menu with the given filter pre-selected.
func NewBoardMenu(current ticket.BoardFilter, width int, styles StatusMenuStyles) BoardMenu {
current = ticket.NormalizeFilter(current)
sel := map[ticket.BoardList]bool{}
for _, bl := range current {
sel[bl] = true
}
cursor := 0
for i, bl := range ticket.AllBoardLists {
if sel[bl] {
cursor = i
break
}
}
return BoardMenu{
Cursor: current.Index(),
Width: width,
Styles: styles,
Cursor: cursor,
Width: width,
Styles: styles,
selected: sel,
}
}
@@ -36,31 +52,70 @@ func (m *BoardMenu) MoveUp() {
}
}
// Selected returns the currently highlighted board list.
func (m BoardMenu) Selected() ticket.BoardList {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllBoardLists) {
return ticket.BoardAll
// JumpToShortcut moves the cursor to the list whose label starts with r.
func (m *BoardMenu) JumpToShortcut(r rune) bool {
r = unicode.ToLower(r)
for i, bl := range ticket.AllBoardLists {
if bl.Shortcut() == r {
m.Cursor = i
return true
}
}
return ticket.AllBoardLists[m.Cursor]
return false
}
// Toggle flips the checked state of the highlighted list.
func (m *BoardMenu) Toggle() {
if m.selected == nil {
m.selected = map[ticket.BoardList]bool{}
}
bl := ticket.AllBoardLists[m.Cursor]
if bl == ticket.BoardAll {
m.selected = map[ticket.BoardList]bool{ticket.BoardAll: true}
return
}
delete(m.selected, ticket.BoardAll)
if m.selected[bl] {
delete(m.selected, bl)
} else {
m.selected[bl] = true
}
if len(m.selected) == 0 {
m.selected[ticket.BoardAll] = true
}
}
// Selected returns the current multi-select filter.
func (m BoardMenu) Selected() ticket.BoardFilter {
var out ticket.BoardFilter
for _, bl := range ticket.AllBoardLists {
if m.selected[bl] {
out = append(out, bl)
}
}
return ticket.NormalizeFilter(out)
}
// View renders the board menu.
func (m BoardMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("Switch list"))
lines = append(lines, m.Styles.Title.Render("Filter lists"))
for i, bl := range ticket.AllBoardLists {
label := " " + bl.Label()
mark := "[ ]"
if m.selected[bl] {
mark = "[x]"
}
entry := fmt.Sprintf("%s %c %s", mark, bl.Shortcut(), bl.Label())
if i == m.Cursor {
label = m.Styles.Selected.Render("▸ " + bl.Label())
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
} else {
label = m.Styles.Normal.Render(label)
lines = append(lines, m.Styles.Normal.Render(" "+entry))
}
lines = append(lines, label)
}
body := strings.Join(lines, "\n")
w := m.Width
if w < 20 {
w = 20
}
return m.Styles.Box.Width(min(40, w-2)).Render(body)
return m.Styles.Box.Width(min(44, w-2)).Render(body)
}
+29
View File
@@ -1,6 +1,8 @@
package views
import (
"unicode"
"github.com/charmbracelet/bubbles/list"
"task/internal/project"
@@ -46,6 +48,33 @@ func NewProjectList(projects []project.Project, activeID string, width, height i
return l
}
// JumpProjectByLetter cycles to the next project whose name starts with r.
func JumpProjectByLetter(l *list.Model, r rune) bool {
r = unicode.ToLower(r)
items := l.Items()
n := len(items)
if n == 0 {
return false
}
start := l.Index()
for step := 1; step <= n; step++ {
i := (start + step) % n
pi, ok := items[i].(ProjectItem)
if !ok {
continue
}
name := []rune(pi.Project.Name)
if len(name) == 0 {
continue
}
if unicode.ToLower(name[0]) == r {
l.Select(i)
return true
}
}
return false
}
// ProjectsToItems converts projects to list items.
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
items := make([]list.Item, len(projects))