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
+122
View File
@@ -1,5 +1,10 @@
package ticket
import (
"strings"
"unicode"
)
// BoardList is a project-scoped view/filter over tickets by status.
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.
func (b BoardList) Index() int {
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.
func (b BoardList) Matches(status Status) bool {
switch b {
+35
View File
@@ -41,3 +41,38 @@ func TestBoardListDefaultStatus(t *testing.T) {
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")
}
}