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:
@@ -14,7 +14,8 @@ type Config struct {
|
||||
ActiveProject string `json:"activeProject"`
|
||||
DataDir string `json:"dataDir"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -55,8 +56,8 @@ func (c *Config) ApplyDefaults(home string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveBoardList returns the persisted board list for a project (default: all).
|
||||
func (c *Config) ActiveBoardList(projectID string) string {
|
||||
// ActiveBoardLists returns the persisted board filter string for a project (default: "all").
|
||||
func (c *Config) ActiveBoardLists(projectID string) string {
|
||||
if c.ProjectLists == nil {
|
||||
return "all"
|
||||
}
|
||||
@@ -66,10 +67,10 @@ func (c *Config) ActiveBoardList(projectID string) string {
|
||||
return "all"
|
||||
}
|
||||
|
||||
// SetActiveBoardList stores the board list for a project.
|
||||
func (c *Config) SetActiveBoardList(projectID, listID string) {
|
||||
// SetActiveBoardLists stores the board filter string for a project.
|
||||
func (c *Config) SetActiveBoardLists(projectID, listIDs string) {
|
||||
if c.ProjectLists == nil {
|
||||
c.ProjectLists = map[string]string{}
|
||||
}
|
||||
c.ProjectLists[projectID] = listID
|
||||
c.ProjectLists[projectID] = listIDs
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+27
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user