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:
parent
a221a15ec3
commit
3462e24ec7
19
veans/go.mod
19
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
|
||||
)
|
||||
|
|
|
|||
45
veans/go.sum
45
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue