From 3462e24ec7ba3d0d970d56e9433f50b624ca9ce8 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 14:21:29 +0200 Subject: [PATCH] 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. --- veans/go.mod | 19 ++ veans/go.sum | 45 +++++ veans/internal/picker/flatten.go | 118 +++++++++++++ veans/internal/picker/flatten_test.go | 122 +++++++++++++ veans/internal/picker/model.go | 238 ++++++++++++++++++++++++++ veans/internal/picker/picker.go | 71 ++++++++ veans/internal/picker/tree.go | 78 +++++++++ veans/internal/picker/tree_test.go | 129 ++++++++++++++ 8 files changed, 820 insertions(+) create mode 100644 veans/internal/picker/flatten.go create mode 100644 veans/internal/picker/flatten_test.go create mode 100644 veans/internal/picker/model.go create mode 100644 veans/internal/picker/picker.go create mode 100644 veans/internal/picker/tree.go create mode 100644 veans/internal/picker/tree_test.go 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) + } + } +}