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:
+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