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
+7 -6
View File
@@ -14,7 +14,8 @@ type Config struct {
ActiveProject string `json:"activeProject"` ActiveProject string `json:"activeProject"`
DataDir string `json:"dataDir"` DataDir string `json:"dataDir"`
Theme string `json:"theme"` Theme string `json:"theme"`
// ProjectLists maps project ID → board list id (all|backlog|in_progress|done). // ProjectLists maps project ID → comma-separated board list ids
// (e.g. "all" or "backlog,in_progress").
ProjectLists map[string]string `json:"projectLists,omitempty"` ProjectLists map[string]string `json:"projectLists,omitempty"`
} }
@@ -55,8 +56,8 @@ func (c *Config) ApplyDefaults(home string) {
} }
} }
// ActiveBoardList returns the persisted board list for a project (default: all). // ActiveBoardLists returns the persisted board filter string for a project (default: "all").
func (c *Config) ActiveBoardList(projectID string) string { func (c *Config) ActiveBoardLists(projectID string) string {
if c.ProjectLists == nil { if c.ProjectLists == nil {
return "all" return "all"
} }
@@ -66,10 +67,10 @@ func (c *Config) ActiveBoardList(projectID string) string {
return "all" return "all"
} }
// SetActiveBoardList stores the board list for a project. // SetActiveBoardLists stores the board filter string for a project.
func (c *Config) SetActiveBoardList(projectID, listID string) { func (c *Config) SetActiveBoardLists(projectID, listIDs string) {
if c.ProjectLists == nil { if c.ProjectLists == nil {
c.ProjectLists = map[string]string{} c.ProjectLists = map[string]string{}
} }
c.ProjectLists[projectID] = listID c.ProjectLists[projectID] = listIDs
} }
+122
View File
@@ -1,5 +1,10 @@
package ticket package ticket
import (
"strings"
"unicode"
)
// BoardList is a project-scoped view/filter over tickets by status. // BoardList is a project-scoped view/filter over tickets by status.
type BoardList string type BoardList string
@@ -29,6 +34,15 @@ func (b BoardList) Label() string {
} }
} }
// Shortcut returns the board-menu hotkey (first letter of the label).
func (b BoardList) Shortcut() rune {
label := b.Label()
if label == "" {
return 0
}
return unicode.ToLower([]rune(label)[0])
}
// Index returns the position in AllBoardLists. // Index returns the position in AllBoardLists.
func (b BoardList) Index() int { func (b BoardList) Index() int {
for i, bl := range AllBoardLists { for i, bl := range AllBoardLists {
@@ -50,6 +64,114 @@ func ParseBoardList(s string) BoardList {
} }
} }
// BoardFilter is a multi-select set of board lists (OR semantics).
type BoardFilter []BoardList
// ParseBoardLists parses a comma-separated list id string (legacy single values work).
func ParseBoardLists(s string) BoardFilter {
s = strings.TrimSpace(s)
if s == "" {
return BoardFilter{BoardAll}
}
parts := strings.Split(s, ",")
var out BoardFilter
seen := map[BoardList]bool{}
for _, p := range parts {
bl := ParseBoardList(strings.TrimSpace(p))
if seen[bl] {
continue
}
seen[bl] = true
out = append(out, bl)
}
return NormalizeFilter(out)
}
// FormatBoardLists serializes a filter for config persistence.
func FormatBoardLists(f BoardFilter) string {
f = NormalizeFilter(f)
parts := make([]string, len(f))
for i, bl := range f {
parts[i] = string(bl)
}
return strings.Join(parts, ",")
}
// NormalizeFilter ensures a non-empty filter; All alone if empty or only All.
// If All is combined with others, All is dropped.
func NormalizeFilter(f BoardFilter) BoardFilter {
if len(f) == 0 {
return BoardFilter{BoardAll}
}
hasAll := false
var others BoardFilter
seen := map[BoardList]bool{}
for _, bl := range f {
if bl == BoardAll {
hasAll = true
continue
}
if seen[bl] {
continue
}
seen[bl] = true
others = append(others, bl)
}
if len(others) > 0 {
return others
}
if hasAll {
return BoardFilter{BoardAll}
}
return BoardFilter{BoardAll}
}
// Contains reports whether bl is in the filter.
func (f BoardFilter) Contains(bl BoardList) bool {
for _, x := range f {
if x == bl {
return true
}
}
return false
}
// Label returns a short header label for the active filter.
func (f BoardFilter) Label() string {
f = NormalizeFilter(f)
if len(f) == 1 {
return f[0].Label()
}
parts := make([]string, len(f))
for i, bl := range f {
parts[i] = bl.Label()
}
return strings.Join(parts, "+")
}
// Matches reports whether a ticket status is visible under this filter.
func (f BoardFilter) Matches(status Status) bool {
f = NormalizeFilter(f)
if f.Contains(BoardAll) {
return true
}
for _, bl := range f {
if bl.Matches(status) {
return true
}
}
return false
}
// DefaultStatus is used when creating a ticket under this filter.
func (f BoardFilter) DefaultStatus() Status {
f = NormalizeFilter(f)
if len(f) == 1 {
return f[0].DefaultStatus()
}
return StatusTodo
}
// Matches reports whether a ticket belongs on this board list. // Matches reports whether a ticket belongs on this board list.
func (b BoardList) Matches(status Status) bool { func (b BoardList) Matches(status Status) bool {
switch b { switch b {
+35
View File
@@ -41,3 +41,38 @@ func TestBoardListDefaultStatus(t *testing.T) {
t.Fatal("all default") t.Fatal("all default")
} }
} }
func TestBoardFilterMultiSelect(t *testing.T) {
f := ticket.ParseBoardLists("backlog,done")
if !f.Matches(ticket.StatusTodo) || !f.Matches(ticket.StatusDone) {
t.Fatalf("expected backlog+done match, got %v", f)
}
if f.Matches(ticket.StatusInProgress) {
t.Fatal("should not match in progress")
}
if got := ticket.FormatBoardLists(f); got != "backlog,done" {
t.Fatalf("format = %q", got)
}
}
func TestBoardFilterAllDominatesNormalize(t *testing.T) {
f := ticket.NormalizeFilter(ticket.BoardFilter{ticket.BoardAll, ticket.BoardBacklog})
if len(f) != 1 || f[0] != ticket.BoardBacklog {
t.Fatalf("got %v", f)
}
}
func TestBoardListShortcut(t *testing.T) {
if ticket.BoardAll.Shortcut() != 'a' {
t.Fatal("all")
}
if ticket.BoardBacklog.Shortcut() != 'b' {
t.Fatal("backlog")
}
if ticket.BoardInProgress.Shortcut() != 'i' {
t.Fatal("in progress")
}
if ticket.BoardDone.Shortcut() != 'd' {
t.Fatal("done")
}
}
+27 -11
View File
@@ -52,7 +52,7 @@ type Model struct {
projectList list.Model projectList list.Model
statusMenu views.StatusMenu statusMenu views.StatusMenu
boardMenu views.BoardMenu boardMenu views.BoardMenu
boardList ticket.BoardList boardFilter ticket.BoardFilter
input textinput.Model input textinput.Model
width int width int
height int height int
@@ -80,7 +80,7 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, exec co
projects: projects, projects: projects,
exec: exec, exec: exec,
mode: ModeList, mode: ModeList,
boardList: ticket.ParseBoardList(cfg.ActiveBoardList(cfg.ActiveProject)), boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
tickets: views.TicketList{ tickets: views.TicketList{
Styles: views.TicketListStyles{ Styles: views.TicketListStyles{
Card: styles.Card, 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): case key.Matches(msg, m.keys.Board):
m.clearStatus() 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, Box: m.styles.StatusMenuBox,
Title: m.styles.StatusMenuTitle, Title: m.styles.StatusMenuTitle,
Selected: m.styles.StatusMenuSelected, Selected: m.styles.StatusMenuSelected,
@@ -329,13 +329,17 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case key.Matches(msg, m.keys.Enter): 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) m.setError(err)
return m, nil return m, nil
} }
m.mode = ModeList m.mode = ModeList
return m, nil return m, nil
case key.Matches(msg, m.keys.Toggle):
m.boardMenu.Toggle()
return m, nil
case key.Matches(msg, m.keys.Down): case key.Matches(msg, m.keys.Down):
m.boardMenu.MoveDown() m.boardMenu.MoveDown()
return m, nil return m, nil
@@ -344,6 +348,12 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.boardMenu.MoveUp() m.boardMenu.MoveUp()
return m, nil 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 return m, nil
} }
@@ -475,6 +485,12 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil 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 var cmd tea.Cmd
m.projectList, cmd = m.projectList.Update(msg) m.projectList, cmd = m.projectList.Update(msg)
return m, cmd return m, cmd
@@ -575,7 +591,7 @@ func (m *Model) createTicket(title, description string) error {
Title: title, Title: title,
Description: description, Description: description,
ProjectID: m.cfg.ActiveProject, ProjectID: m.cfg.ActiveProject,
Status: m.boardList.DefaultStatus(), Status: m.boardFilter.DefaultStatus(),
}, },
} }
if err := m.exec.Do(cmd); err != nil { if err := m.exec.Do(cmd); err != nil {
@@ -661,14 +677,14 @@ func (m *Model) redo() error {
return nil return nil
} }
func (m *Model) applyBoardList(bl ticket.BoardList) error { func (m *Model) applyBoardFilter(f ticket.BoardFilter) error {
m.boardList = bl m.boardFilter = ticket.NormalizeFilter(f)
m.cfg.SetActiveBoardList(m.cfg.ActiveProject, string(bl)) m.cfg.SetActiveBoardLists(m.cfg.ActiveProject, ticket.FormatBoardLists(m.boardFilter))
if err := config.Save(m.cfg); err != nil { if err := config.Save(m.cfg); err != nil {
return err return err
} }
m.applyFilter() m.applyFilter()
m.setStatus(fmt.Sprintf("list → %s", bl.Label())) m.setStatus(fmt.Sprintf("list → %s", m.boardFilter.Label()))
return nil return nil
} }
@@ -677,7 +693,7 @@ func (m *Model) switchProject(id string) error {
return err return err
} }
m.cfg.ActiveProject = id 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 { if err := config.Save(m.cfg); err != nil {
return err return err
} }
@@ -721,7 +737,7 @@ func (m *Model) reloadTickets() error {
func (m *Model) applyFilter() { func (m *Model) applyFilter() {
filtered := make([]ticket.Ticket, 0, len(m.allTickets)) filtered := make([]ticket.Ticket, 0, len(m.allTickets))
for _, t := range m.allTickets { for _, t := range m.allTickets {
if m.boardList.Matches(t.Status) { if m.boardFilter.Matches(t.Status) {
filtered = append(filtered, t) filtered = append(filtered, t)
} }
} }
+4 -1
View File
@@ -30,6 +30,7 @@ func (m Model) helpEntries() []helpEntry {
case ModeProjects: case ModeProjects:
return []helpEntry{ return []helpEntry{
{"j/k", "navigate"}, {"j/k", "navigate"},
{"a-z", "jump"},
{"enter", "select"}, {"enter", "select"},
{"n", "new project"}, {"n", "new project"},
{"esc", "cancel"}, {"esc", "cancel"},
@@ -43,7 +44,9 @@ func (m Model) helpEntries() []helpEntry {
} }
case ModeBoard: case ModeBoard:
return []helpEntry{ return []helpEntry{
{"j/k", "select"}, {"j/k", "navigate"},
{"a/b/i/d", "jump"},
{"space", "toggle"},
{"enter", "apply"}, {"enter", "apply"},
{"esc", "cancel"}, {"esc", "cancel"},
} }
+5
View File
@@ -16,6 +16,7 @@ type KeyMap struct {
Delete key.Binding // x — confirm first Delete key.Binding // x — confirm first
DeleteForce key.Binding // X — immediate DeleteForce key.Binding // X — immediate
ConfirmYes key.Binding ConfirmYes key.Binding
Toggle key.Binding // space — toggle multi-select
Undo key.Binding Undo key.Binding
Redo key.Binding Redo key.Binding
Quit key.Binding Quit key.Binding
@@ -74,6 +75,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("y", "Y"), key.WithKeys("y", "Y"),
key.WithHelp("y", "confirm"), key.WithHelp("y", "confirm"),
), ),
Toggle: key.NewBinding(
key.WithKeys(" "),
key.WithHelp("space", "toggle"),
),
Undo: key.NewBinding( Undo: key.NewBinding(
key.WithKeys("u", "ctrl+z"), key.WithKeys("u", "ctrl+z"),
key.WithHelp("u", "undo"), key.WithHelp("u", "undo"),
+1 -1
View File
@@ -12,7 +12,7 @@ import (
func (m Model) renderHeader() string { func (m Model) renderHeader() string {
brand := m.styles.HeaderBrand.Render(config.AppName) brand := m.styles.HeaderBrand.Render(config.AppName)
project := m.styles.HeaderMeta.Render(fmt.Sprintf("project: %s", m.cfg.ActiveProject)) 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())) count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))
parts := []string{brand, project, board, count} parts := []string{brand, project, board, count}
+75 -20
View File
@@ -1,24 +1,40 @@
package views package views
import ( import (
"fmt"
"strings" "strings"
"unicode"
"task/internal/ticket" "task/internal/ticket"
) )
// BoardMenu renders a board-list picker overlay. // BoardMenu renders a multi-select board-list picker overlay.
type BoardMenu struct { type BoardMenu struct {
Cursor int Cursor int
Width int Width int
Styles StatusMenuStyles Styles StatusMenuStyles
selected map[ticket.BoardList]bool
} }
// NewBoardMenu creates a menu with the given board list pre-selected. // NewBoardMenu creates a menu with the given filter pre-selected.
func NewBoardMenu(current ticket.BoardList, width int, styles StatusMenuStyles) BoardMenu { 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{ return BoardMenu{
Cursor: current.Index(), Cursor: cursor,
Width: width, Width: width,
Styles: styles, Styles: styles,
selected: sel,
} }
} }
@@ -36,31 +52,70 @@ func (m *BoardMenu) MoveUp() {
} }
} }
// Selected returns the currently highlighted board list. // JumpToShortcut moves the cursor to the list whose label starts with r.
func (m BoardMenu) Selected() ticket.BoardList { func (m *BoardMenu) JumpToShortcut(r rune) bool {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllBoardLists) { r = unicode.ToLower(r)
return ticket.BoardAll 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. // View renders the board menu.
func (m BoardMenu) View() string { func (m BoardMenu) View() string {
var lines []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 { 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 { if i == m.Cursor {
label = m.Styles.Selected.Render("▸ " + bl.Label()) lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
} else { } else {
label = m.Styles.Normal.Render(label) lines = append(lines, m.Styles.Normal.Render(" "+entry))
} }
lines = append(lines, label)
} }
body := strings.Join(lines, "\n") body := strings.Join(lines, "\n")
w := m.Width w := m.Width
if w < 20 { if w < 20 {
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 package views
import ( import (
"unicode"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"task/internal/project" "task/internal/project"
@@ -46,6 +48,33 @@ func NewProjectList(projects []project.Project, activeID string, width, height i
return l 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. // ProjectsToItems converts projects to list items.
func ProjectsToItems(projects []project.Project, activeID string) []list.Item { func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
items := make([]list.Item, len(projects)) items := make([]list.Item, len(projects))