Files
SkrueckenandCursor 41feba11de 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>
2026-10-19 10:42:59 +02:00

202 lines
4.2 KiB
Go

package ticket
import (
"strings"
"unicode"
)
// BoardList is a project-scoped view/filter over tickets by status.
type BoardList string
const (
BoardAll BoardList = "all"
BoardBacklog BoardList = "backlog"
BoardInProgress BoardList = "in_progress"
BoardDone BoardList = "done"
)
// AllBoardLists is the ordered picker list.
var AllBoardLists = []BoardList{BoardAll, BoardBacklog, BoardInProgress, BoardDone}
// Label returns a display name for the board list.
func (b BoardList) Label() string {
switch b {
case BoardAll:
return "All"
case BoardBacklog:
return "Backlog"
case BoardInProgress:
return "In Progress"
case BoardDone:
return "Done"
default:
return string(b)
}
}
// 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 {
if bl == b {
return i
}
}
return 0
}
// ParseBoardList validates a board list id; invalid values become BoardAll.
func ParseBoardList(s string) BoardList {
b := BoardList(s)
switch b {
case BoardAll, BoardBacklog, BoardInProgress, BoardDone:
return b
default:
return BoardAll
}
}
// 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 {
case BoardAll:
return true
case BoardBacklog:
return status == StatusTodo
case BoardInProgress:
return status == StatusInProgress
case BoardDone:
return status == StatusDone
default:
return true
}
}
// DefaultStatus is the status used when creating a ticket on this list.
func (b BoardList) DefaultStatus() Status {
switch b {
case BoardInProgress:
return StatusInProgress
case BoardDone:
return StatusDone
default:
return StatusTodo
}
}