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>
202 lines
4.2 KiB
Go
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
|
|
}
|
|
}
|