feat(picker): add hierarchical fuzzy project picker

Interactive bubbletea picker that renders projects as an indented tree (siblings by position then title, orphans re-parented to root) and fuzzy-filters as you type, keeping matched rows' ancestors visible as dimmed context. Pure tree/flatten logic is split from the TUI and unit-tested.
This commit is contained in:
kolaente 2026-06-09 14:21:29 +02:00
parent a221a15ec3
commit 3462e24ec7
8 changed files with 820 additions and 0 deletions

View File

@ -3,9 +3,12 @@ module code.vikunja.io/veans
go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b
github.com/magefile/mage v1.17.2
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/sahilm/fuzzy v0.1.2
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/sys v0.43.0
@ -14,8 +17,24 @@ require (
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/text v0.3.8 // indirect
)

View File

@ -1,3 +1,17 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
@ -5,17 +19,40 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b h1:qZ21OofI7zneC9dOEqul4FmIWz/YjJJMrf6fL7jrFYQ=
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww=
github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
@ -24,14 +61,22 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -0,0 +1,118 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package picker
import (
"unicode/utf8"
"code.vikunja.io/veans/internal/client"
"github.com/sahilm/fuzzy"
)
// row is one visible line in the picker. matches holds rune indexes into the
// title for highlighting; dimmed rows are kept only as context for a matching
// descendant and are skipped by the cursor.
type row struct {
project *client.Project
depth int
dimmed bool
matches []int
}
// flatten walks the forest depth-first into a render list. An empty query
// returns every node undimmed. A non-empty query fuzzy-matches each title
// (case-insensitive, via sahilm/fuzzy) and keeps a node iff it matches or any
// descendant is kept; a kept-but-non-matching node is dimmed context.
func flatten(forest []*node, query string) []row {
if query == "" {
var rows []row
var walk func(nodes []*node)
walk = func(nodes []*node) {
for _, n := range nodes {
rows = append(rows, row{project: n.project, depth: n.depth})
walk(n.children)
}
}
walk(forest)
return rows
}
var rows []row
var walk func(n *node) bool
walk = func(n *node) bool {
matches, matched := matchTitle(query, n.project.Title)
start := len(rows)
rows = append(rows, row{}) // placeholder; finalized only if kept
descendantKept := false
for _, c := range n.children {
if walk(c) {
descendantKept = true
}
}
if !matched && !descendantKept {
rows = rows[:start]
return false
}
rows[start] = row{
project: n.project,
depth: n.depth,
dimmed: !matched,
matches: matches,
}
return true
}
for _, n := range forest {
walk(n)
}
return rows
}
// matchTitle reports whether title fuzzy-matches query and, if so, the rune
// indexes of the matched characters. sahilm/fuzzy reports byte indexes, so we
// translate them to rune offsets for correct highlighting of multibyte titles.
func matchTitle(query, title string) (runeMatches []int, matched bool) {
results := fuzzy.Find(query, []string{title})
if len(results) == 0 {
return nil, false
}
return byteToRuneIndexes(title, results[0].MatchedIndexes), true
}
func byteToRuneIndexes(s string, byteIdx []int) []int {
if len(byteIdx) == 0 {
return nil
}
want := make(map[int]bool, len(byteIdx))
for _, b := range byteIdx {
want[b] = true
}
out := make([]int, 0, len(byteIdx))
runePos := 0
for b := 0; b < len(s); {
if want[b] {
out = append(out, runePos)
}
_, size := utf8.DecodeRuneInString(s[b:])
b += size
runePos++
}
return out
}

View File

@ -0,0 +1,122 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package picker
import (
"reflect"
"testing"
"code.vikunja.io/veans/internal/client"
)
func sampleForest() []*node {
return buildForest([]*client.Project{
proj(1, 0, 1, "Backend"),
proj(2, 1, 1, "Frontend"),
proj(3, 1, 2, "Database"),
proj(4, 0, 2, "Marketing"),
})
}
func rowTitles(rows []row) []string {
out := make([]string, len(rows))
for i, r := range rows {
out[i] = r.project.Title
}
return out
}
func TestFlatten_EmptyQuery(t *testing.T) {
rows := flatten(sampleForest(), "")
wantTitles := []string{"Backend", "Frontend", "Database", "Marketing"}
if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) {
t.Fatalf("titles: got %v, want %v", got, wantTitles)
}
wantDepths := []int{0, 1, 1, 0}
for i, r := range rows {
if r.depth != wantDepths[i] {
t.Errorf("row %d depth = %d, want %d", i, r.depth, wantDepths[i])
}
if r.dimmed {
t.Errorf("row %d should not be dimmed on empty query", i)
}
if r.matches != nil {
t.Errorf("row %d should have nil matches on empty query", i)
}
}
}
func TestFlatten_DeepChildSurfacesDimmedAncestor(t *testing.T) {
// "Frontend" is a child of "Backend"; matching it must keep "Backend"
// as a dimmed context row.
rows := flatten(sampleForest(), "frontend")
wantTitles := []string{"Backend", "Frontend"}
if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) {
t.Fatalf("titles: got %v, want %v", got, wantTitles)
}
if !rows[0].dimmed {
t.Error("ancestor Backend should be dimmed (context only)")
}
if rows[1].dimmed {
t.Error("matching Frontend should not be dimmed")
}
}
func TestFlatten_MatchingNodeCarriesMatchIndexes(t *testing.T) {
rows := flatten(sampleForest(), "front")
var frontend *row
for i := range rows {
if rows[i].project.Title == "Frontend" {
frontend = &rows[i]
}
}
if frontend == nil {
t.Fatal("Frontend row missing")
}
// "front" should match the leading runes of "Frontend".
want := []int{0, 1, 2, 3, 4}
if !reflect.DeepEqual(frontend.matches, want) {
t.Fatalf("matches: got %v, want %v", frontend.matches, want)
}
}
func TestFlatten_NonMatchingSiblingsDropped(t *testing.T) {
// Matching "Marketing" must not pull in "Backend"/"Frontend"/"Database".
rows := flatten(sampleForest(), "marketing")
wantTitles := []string{"Marketing"}
if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) {
t.Fatalf("titles: got %v, want %v", got, wantTitles)
}
}
func TestFlatten_NoMatchYieldsEmpty(t *testing.T) {
rows := flatten(sampleForest(), "zzzzz")
if len(rows) != 0 {
t.Fatalf("expected no rows, got %v", rowTitles(rows))
}
}
func TestFlatten_CaseInsensitive(t *testing.T) {
lower := flatten(sampleForest(), "backend")
upper := flatten(sampleForest(), "BACKEND")
if !reflect.DeepEqual(rowTitles(lower), rowTitles(upper)) {
t.Fatalf("case sensitivity differs: %v vs %v", rowTitles(lower), rowTitles(upper))
}
if len(lower) == 0 {
t.Fatal("expected at least one match for 'backend'")
}
}

View File

@ -0,0 +1,238 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package picker
import (
"fmt"
"strings"
"code.vikunja.io/veans/internal/client"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const maxVisibleRows = 12
var (
dimStyle = lipgloss.NewStyle().Faint(true)
matchStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
cursorMark = ""
)
// model is the bubbletea state for the picker. The pinned "create a new
// project" entry is the trailing row with a nil project; it is always
// selectable and never filtered out.
type model struct {
forest []*node
query string
rows []row
cursor int // index into rows, always on a selectable row
offset int // first visible row index
result *client.Project
createNew bool
canceled bool
}
func newModel(forest []*node) *model {
m := &model{forest: forest}
m.recompute()
return m
}
func (m *model) recompute() {
rows := flatten(m.forest, m.query)
rows = append(rows, row{project: nil}) // pinned create row
m.rows = rows
// recompute only runs when the query changes (or on init), so snap to the
// first match. Keeping the old cursor could leave it on the trailing create
// row after the list narrows, making Enter create a project instead of
// picking the visible match.
m.cursor = 0
m.offset = 0
m.clampCursor()
m.ensureVisible()
}
func (r row) isCreate() bool { return r.project == nil }
func (r row) selectable() bool { return r.isCreate() || !r.dimmed }
func (m *model) clampCursor() {
if m.cursor >= len(m.rows) {
m.cursor = len(m.rows) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
if m.rows[m.cursor].selectable() {
return
}
// Snap to the nearest selectable row, preferring downward.
for i := m.cursor; i < len(m.rows); i++ {
if m.rows[i].selectable() {
m.cursor = i
return
}
}
for i := m.cursor; i >= 0; i-- {
if m.rows[i].selectable() {
m.cursor = i
return
}
}
}
func (m *model) moveCursor(delta int) {
i := m.cursor
for {
i += delta
if i < 0 || i >= len(m.rows) {
return
}
if m.rows[i].selectable() {
m.cursor = i
m.ensureVisible()
return
}
}
}
func (m *model) ensureVisible() {
if m.cursor < m.offset {
m.offset = m.cursor
}
if m.cursor >= m.offset+maxVisibleRows {
m.offset = m.cursor - maxVisibleRows + 1
}
if m.offset < 0 {
m.offset = 0
}
}
func (m *model) Init() tea.Cmd { return nil }
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
key, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch key.String() {
case "ctrl+c", "esc":
m.canceled = true
return m, tea.Quit
case "enter":
sel := m.rows[m.cursor]
if sel.isCreate() {
m.createNew = true
} else {
m.result = sel.project
}
return m, tea.Quit
case "up":
m.moveCursor(-1)
case "down":
m.moveCursor(1)
case "backspace":
if m.query != "" {
r := []rune(m.query)
m.query = string(r[:len(r)-1])
m.recompute()
}
default:
// Treat printable runes and space as query input.
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
runes := key.Runes
// KeySpace is not guaranteed to populate key.Runes; substitute a
// literal space so multi-word fuzzy queries still work.
if key.Type == tea.KeySpace && len(runes) == 0 {
runes = []rune{' '}
}
m.query += string(runes)
m.recompute()
}
}
return m, nil
}
func (m *model) View() string {
var b strings.Builder
fmt.Fprintf(&b, "> %s\n", m.query)
end := min(m.offset+maxVisibleRows, len(m.rows))
for i := m.offset; i < end; i++ {
b.WriteString(m.renderRow(i))
b.WriteByte('\n')
}
fmt.Fprintf(&b, "%d/%d ↑↓ move ⏎ pick esc cancel\n", m.cursor+1, len(m.rows))
return b.String()
}
func (m *model) renderRow(i int) string {
r := m.rows[i]
marker := " "
if i == m.cursor {
marker = cursorMark + " "
}
indent := strings.Repeat(" ", r.depth)
var label string
switch {
case r.isCreate():
label = "Create a new project"
case r.dimmed:
label = dimStyle.Render(r.project.Title + projectSuffix(r.project))
default:
label = highlight(r.project.Title, r.matches) + dimStyle.Render(projectSuffix(r.project))
}
return marker + indent + label
}
// projectSuffix is the dimmed metadata appended to a project row. Titles aren't
// unique in Vikunja, so the id (and identifier when set) keeps duplicate-titled
// projects distinguishable during init.
func projectSuffix(p *client.Project) string {
s := fmt.Sprintf(" #%d", p.ID)
if p.Identifier != "" {
s += " " + p.Identifier
}
return s
}
// highlight bolds the matched runes of title. matches are rune indexes.
func highlight(title string, matches []int) string {
if len(matches) == 0 {
return title
}
matchSet := make(map[int]bool, len(matches))
for _, idx := range matches {
matchSet[idx] = true
}
var b strings.Builder
for i, r := range []rune(title) {
if matchSet[i] {
b.WriteString(matchStyle.Render(string(r)))
} else {
b.WriteRune(r)
}
}
return b.String()
}

View File

@ -0,0 +1,71 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package picker
import (
"errors"
"fmt"
"os"
"code.vikunja.io/veans/internal/client"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
)
// Result is what the user chose: an existing project or the create-new action.
type Result struct {
Project *client.Project
CreateNew bool
}
var (
// ErrCanceled is returned when the user dismisses the picker (Esc / Ctrl-C).
ErrCanceled = errors.New("selection canceled")
// ErrNotATerminal is returned when stdin is not a TTY, so the interactive
// picker can't run — callers should fall back to `--project <id>`.
ErrNotATerminal = errors.New("not a terminal")
)
// Pick runs the interactive project picker over projects and returns the
// user's choice. Output is written to stderr (prompts go to stderr by
// convention) and the terminal is left in canonical mode on exit.
func Pick(projects []*client.Project) (Result, error) {
// The picker reads stdin and draws to stderr; both must be a TTY, else it
// would run invisibly (e.g. stderr redirected to a file) and look hung.
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) {
return Result{}, ErrNotATerminal
}
m := newModel(buildForest(projects))
prog := tea.NewProgram(m, tea.WithInput(os.Stdin), tea.WithOutput(os.Stderr))
final, err := prog.Run()
if err != nil {
return Result{}, fmt.Errorf("run project picker: %w", err)
}
fm, ok := final.(*model)
if !ok {
return Result{}, fmt.Errorf("project picker returned unexpected model type %T", final)
}
if fm.canceled {
return Result{}, ErrCanceled
}
if fm.createNew {
return Result{CreateNew: true}, nil
}
return Result{Project: fm.result}, nil
}

View File

@ -0,0 +1,78 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package picker renders an interactive, hierarchical, fuzzy-searchable
// project picker for `veans init`. The pure tree/flatten logic is split from
// the bubbletea TUI so it stays unit-testable.
package picker
import (
"sort"
"code.vikunja.io/veans/internal/client"
)
type node struct {
project *client.Project
depth int
children []*node
}
// buildForest turns a flat project slice into a depth-annotated forest. A
// project whose ParentProjectID is absent from the input becomes a root —
// this mirrors the frontend's effective-parent behavior so children of a
// hidden or archived parent don't vanish. Siblings are ordered by Position,
// tie-broken by Title.
func buildForest(projects []*client.Project) []*node {
byID := make(map[int64]*node, len(projects))
for _, p := range projects {
if p == nil {
continue
}
byID[p.ID] = &node{project: p}
}
var roots []*node
for _, p := range projects {
if p == nil {
continue
}
n := byID[p.ID]
parent, ok := byID[p.ParentProjectID]
if p.ParentProjectID == 0 || !ok {
roots = append(roots, n)
continue
}
parent.children = append(parent.children, n)
}
sortAndAssignDepth(roots, 0)
return roots
}
func sortAndAssignDepth(nodes []*node, depth int) {
sort.SliceStable(nodes, func(i, j int) bool {
a, b := nodes[i].project, nodes[j].project
if a.Position != b.Position {
return a.Position < b.Position
}
return a.Title < b.Title
})
for _, n := range nodes {
n.depth = depth
sortAndAssignDepth(n.children, depth+1)
}
}

View File

@ -0,0 +1,129 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package picker
import (
"reflect"
"strconv"
"testing"
"code.vikunja.io/veans/internal/client"
)
func proj(id, parent int64, pos float64, title string) *client.Project {
return &client.Project{ID: id, ParentProjectID: parent, Position: pos, Title: title}
}
// titlesWithDepth flattens a forest depth-first into "title@depth" tokens.
func titlesWithDepth(forest []*node) []string {
var out []string
var walk func(nodes []*node)
walk = func(nodes []*node) {
for _, n := range nodes {
out = append(out, n.project.Title+"@"+strconv.Itoa(n.depth))
walk(n.children)
}
}
walk(forest)
return out
}
func TestBuildForest_SingleRoot(t *testing.T) {
forest := buildForest([]*client.Project{proj(1, 0, 1, "Root")})
got := titlesWithDepth(forest)
want := []string{"Root@0"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestBuildForest_Nested(t *testing.T) {
forest := buildForest([]*client.Project{
proj(1, 0, 1, "Root"),
proj(2, 1, 1, "Child"),
proj(3, 2, 1, "Grandchild"),
})
got := titlesWithDepth(forest)
want := []string{"Root@0", "Child@1", "Grandchild@2"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestBuildForest_MultipleRoots(t *testing.T) {
forest := buildForest([]*client.Project{
proj(1, 0, 2, "Beta"),
proj(2, 0, 1, "Alpha"),
})
got := titlesWithDepth(forest)
// Roots are sorted by position: Alpha (pos 1) before Beta (pos 2).
want := []string{"Alpha@0", "Beta@0"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestBuildForest_SiblingOrderPositionThenTitle(t *testing.T) {
forest := buildForest([]*client.Project{
proj(1, 0, 0, "Root"),
proj(2, 1, 2, "C"),
proj(3, 1, 1, "B"),
// same position as B — tie-break by title puts A before B.
proj(4, 1, 1, "A"),
})
got := titlesWithDepth(forest)
want := []string{"Root@0", "A@1", "B@1", "C@1"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestBuildForest_OrphanBecomesRoot(t *testing.T) {
// Parent 99 is not in the input set — child should surface as a root.
forest := buildForest([]*client.Project{
proj(1, 0, 1, "Root"),
proj(2, 99, 2, "Orphan"),
})
got := titlesWithDepth(forest)
want := []string{"Root@0", "Orphan@0"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestBuildForest_DepthCorrectness(t *testing.T) {
forest := buildForest([]*client.Project{
proj(1, 0, 1, "A"),
proj(2, 1, 1, "B"),
proj(3, 2, 1, "C"),
proj(4, 3, 1, "D"),
})
depthOf := map[string]int{}
var walk func(nodes []*node)
walk = func(nodes []*node) {
for _, n := range nodes {
depthOf[n.project.Title] = n.depth
walk(n.children)
}
}
walk(forest)
for title, want := range map[string]int{"A": 0, "B": 1, "C": 2, "D": 3} {
if depthOf[title] != want {
t.Errorf("depth of %q = %d, want %d", title, depthOf[title], want)
}
}
}