diff --git a/veans/go.mod b/veans/go.mod
index c025994dc..88e1cfca7 100644
--- a/veans/go.mod
+++ b/veans/go.mod
@@ -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
)
diff --git a/veans/go.sum b/veans/go.sum
index 3e0c9d612..5490b412a 100644
--- a/veans/go.sum
+++ b/veans/go.sum
@@ -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=
diff --git a/veans/internal/picker/flatten.go b/veans/internal/picker/flatten.go
new file mode 100644
index 000000000..bb9561a4a
--- /dev/null
+++ b/veans/internal/picker/flatten.go
@@ -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 .
+
+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
+}
diff --git a/veans/internal/picker/flatten_test.go b/veans/internal/picker/flatten_test.go
new file mode 100644
index 000000000..fe60411fb
--- /dev/null
+++ b/veans/internal/picker/flatten_test.go
@@ -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 .
+
+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'")
+ }
+}
diff --git a/veans/internal/picker/model.go b/veans/internal/picker/model.go
new file mode 100644
index 000000000..4139d0f14
--- /dev/null
+++ b/veans/internal/picker/model.go
@@ -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 .
+
+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()
+}
diff --git a/veans/internal/picker/picker.go b/veans/internal/picker/picker.go
new file mode 100644
index 000000000..ee373e1a4
--- /dev/null
+++ b/veans/internal/picker/picker.go
@@ -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 .
+
+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 `.
+ 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
+}
diff --git a/veans/internal/picker/tree.go b/veans/internal/picker/tree.go
new file mode 100644
index 000000000..1835badea
--- /dev/null
+++ b/veans/internal/picker/tree.go
@@ -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 .
+
+// 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)
+ }
+}
diff --git a/veans/internal/picker/tree_test.go b/veans/internal/picker/tree_test.go
new file mode 100644
index 000000000..d2874f949
--- /dev/null
+++ b/veans/internal/picker/tree_test.go
@@ -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 .
+
+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)
+ }
+ }
+}