diff --git a/go.mod b/go.mod index 2d34fa9..458aa09 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,30 @@ module github.com/chenasraf/tx go 1.25.5 require ( - github.com/ktr0731/go-fuzzyfinder v0.9.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/gdamore/encoding v1.0.1 // indirect - github.com/gdamore/tcell/v2 v2.6.0 // indirect + 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/ktr0731/go-ansisgr v0.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/nsf/termbox-go v1.1.1 // indirect - github.com/pkg/errors v0.9.1 // 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 - golang.org/x/sys v0.32.0 // indirect - golang.org/x/term v0.31.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index 9e21e32..c57cf96 100644 --- a/go.sum +++ b/go.sum @@ -1,31 +1,37 @@ +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/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= -github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= -github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg= -github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w= -github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE= -github.com/ktr0731/go-fuzzyfinder v0.9.0 h1:JV8S118RABzRl3Lh/RsPhXReJWc2q0rbuipzXQH7L4c= -github.com/ktr0731/go-fuzzyfinder v0.9.0/go.mod h1:uybx+5PZFCgMCSDHJDQ9M3nNKx/vccPmGffsXPn2ad8= 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/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= -github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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= @@ -33,44 +39,17 @@ 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= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +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= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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/internal/cli/prj_cmd.go b/internal/cli/prj_cmd.go index 64fac89..b8875d7 100644 --- a/internal/cli/prj_cmd.go +++ b/internal/cli/prj_cmd.go @@ -78,7 +78,7 @@ func runPrj(cmd *cobra.Command, args []string) error { if name == "" { items := make([]fzf.Item, len(projects)) for i, p := range projects { - items[i] = fzf.Item{Key: p, Display: p} + items[i] = fzf.Item{Key: p, Name: p} } selected, err := fzf.Run(items, fzf.Options{}) if err != nil { diff --git a/internal/cli/root.go b/internal/cli/root.go index 79319f1..2b626e5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -3,7 +3,6 @@ package cli import ( "fmt" "os" - "strings" "github.com/chenasraf/tx/internal/config" "github.com/chenasraf/tx/internal/exec" @@ -80,15 +79,11 @@ func completeSessionNames(cmd *cobra.Command, args []string, toComplete string) return names, cobra.ShellCompDirectiveNoFileComp } -// buildFzfItems creates fzf items from a config file, including aliases in the display string +// buildFzfItems creates fzf items from a config file func buildFzfItems(cfg config.ConfigFile) []fzf.Item { items := make([]fzf.Item, 0, len(cfg)) for k, v := range cfg { - display := k - if len(v.Aliases) > 0 { - display = k + " (" + strings.Join(v.Aliases, ", ") + ")" - } - items = append(items, fzf.Item{Key: k, Display: display}) + items = append(items, fzf.Item{Key: k, Name: k, Aliases: v.Aliases}) } return items } diff --git a/internal/fzf/fzf.go b/internal/fzf/fzf.go index 193488f..6d2b580 100644 --- a/internal/fzf/fzf.go +++ b/internal/fzf/fzf.go @@ -2,72 +2,309 @@ package fzf import ( "errors" + "strings" - "github.com/ktr0731/go-fuzzyfinder" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) // ErrSelectionCancelled is returned when the user cancels selection var ErrSelectionCancelled = errors.New("selection cancelled") -// Item represents a fuzzy finder item with a key and display string +// Item represents a fuzzy finder item. type Item struct { Key string - Display string + Name string + Aliases []string } // Options for fuzzy finder type Options struct { - AllowCustom bool // Note: go-fuzzyfinder doesn't support custom input like fzf --print-query + AllowCustom bool } -// Run executes the fuzzy finder with the given items and returns the selected key +// Styles used for rendering. +var ( + normalStyle = lipgloss.NewStyle() + dimStyle = lipgloss.NewStyle().Faint(true) + matchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true) + dimMatchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Faint(true) + selectedStyle = lipgloss.NewStyle().Reverse(true) + promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) +) + +type model struct { + items []Item + filtered []filteredItem + query string + cursor int // index in filtered + offset int // scroll offset + width int + height int + selected string + cancelled bool + quitting bool +} + +func initialModel(items []Item) model { + m := model{ + items: items, + width: 80, + height: 24, + } + m.filtered = filterAndSort(items, "") + return m +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.clampScroll() + return m, nil + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.cancelled = true + m.quitting = true + return m, tea.Quit + + case tea.KeyEnter: + if len(m.filtered) > 0 && m.cursor < len(m.filtered) { + m.selected = m.filtered[m.cursor].item.Key + } else { + m.cancelled = true + } + m.quitting = true + return m, tea.Quit + + case tea.KeyBackspace: + if len(m.query) > 0 { + m.query = m.query[:len(m.query)-1] + m.refilter() + } + return m, nil + + case tea.KeyCtrlU: + m.query = "" + m.refilter() + return m, nil + + case tea.KeyUp, tea.KeyCtrlK: + if m.cursor < len(m.filtered)-1 { + m.cursor++ + m.clampScroll() + } + return m, nil + + case tea.KeyDown, tea.KeyCtrlJ: + if m.cursor > 0 { + m.cursor-- + m.clampScroll() + } + return m, nil + + case tea.KeyRunes: + m.query += string(msg.Runes) + m.refilter() + return m, nil + } + } + + return m, nil +} + +func (m *model) refilter() { + m.filtered = filterAndSort(m.items, m.query) + m.cursor = 0 + m.offset = 0 +} + +func (m *model) clampScroll() { + // Available rows for items (height minus prompt row) + maxVisible := m.height - 1 + if maxVisible < 1 { + maxVisible = 1 + } + + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+maxVisible { + m.offset = m.cursor - maxVisible + 1 + } +} + +func (m model) View() string { + if m.quitting { + return "" + } + + // Available rows for items (height minus prompt row) + maxVisible := m.height - 1 + if maxVisible < 1 { + maxVisible = 1 + } + + end := m.offset + maxVisible + if end > len(m.filtered) { + end = len(m.filtered) + } + + // Build item rows (reversed: highest index on top, lowest near prompt) + var rows []string + for i := m.offset; i < end; i++ { + fi := m.filtered[i] + rows = append(rows, m.renderItem(fi, i == m.cursor)) + } + + var b strings.Builder + + // Pad with empty lines so items stick to the bottom + emptyLines := maxVisible - len(rows) + for i := 0; i < emptyLines; i++ { + b.WriteString("\n") + } + + // Render items in reverse order (first match closest to prompt) + for i := len(rows) - 1; i >= 0; i-- { + b.WriteString(rows[i]) + b.WriteString("\n") + } + + // Prompt line at the bottom + b.WriteString(promptStyle.Render("> ")) + b.WriteString(cursorStyle.Render(m.query)) + b.WriteString(cursorStyle.Render("█")) + + return b.String() +} + +// renderItem renders a single filtered item row. +func (m model) renderItem(fi filteredItem, isCursor bool) string { + // Max width for the row content (leave space for cursor indicator) + maxW := m.width - 2 + if maxW < 10 { + maxW = 10 + } + + var b strings.Builder + + // Cursor indicator + if isCursor { + b.WriteString(promptStyle.Render("▸ ")) + } else { + b.WriteString(" ") + } + + // Render name with highlights + nameStr := highlightMatches(fi.item.Name, fi.namePositions, normalStyle, matchStyle) + + // Render aliases if present + aliasStr := "" + if len(fi.item.Aliases) > 0 { + var parts []string + for i, alias := range fi.item.Aliases { + if i < len(fi.aliasMatches) && fi.aliasMatches[i].positions != nil { + parts = append(parts, highlightMatches(alias, fi.aliasMatches[i].positions, dimStyle, dimMatchStyle)) + } else { + parts = append(parts, dimStyle.Render(alias)) + } + } + aliasStr = dimStyle.Render(" (") + strings.Join(parts, dimStyle.Render(", ")) + dimStyle.Render(")") + } + + row := nameStr + aliasStr + + // Truncate if needed (approximate — styled strings contain escape codes) + // We just cap visible chars loosely; Lip Gloss handles the rest. + + if isCursor { + // Apply reverse to the whole content portion + b.Reset() + content := nameStr + aliasStr + b.WriteString(selectedStyle.Render("▸ " + stripStyle(fi, maxW))) + _ = content // use styled version only in non-selected + return b.String() + } + + b.WriteString(row) + return b.String() +} + +// stripStyle produces a plain-text version of the item for reverse-video rendering. +func stripStyle(fi filteredItem, maxW int) string { + s := fi.item.Name + if len(fi.item.Aliases) > 0 { + s += " (" + strings.Join(fi.item.Aliases, ", ") + ")" + } + // Truncate to maxW runes + runes := []rune(s) + if len(runes) > maxW { + runes = runes[:maxW] + } + return string(runes) +} + +// highlightMatches renders text with certain character positions styled differently. +func highlightMatches(text string, positions []int, base, highlight lipgloss.Style) string { + if len(positions) == 0 { + return base.Render(text) + } + + posSet := make(map[int]bool, len(positions)) + for _, p := range positions { + posSet[p] = true + } + + var b strings.Builder + runes := []rune(text) + i := 0 + for i < len(runes) { + if posSet[i] { + // Collect consecutive highlighted runes + j := i + for j < len(runes) && posSet[j] { + j++ + } + b.WriteString(highlight.Render(string(runes[i:j]))) + i = j + } else { + // Collect consecutive normal runes + j := i + for j < len(runes) && !posSet[j] { + j++ + } + b.WriteString(base.Render(string(runes[i:j]))) + i = j + } + } + return b.String() +} + +// Run executes the fuzzy finder with the given items and returns the selected key. func Run(items []Item, opts Options) (string, error) { if len(items) == 0 { return "", ErrSelectionCancelled } - idx, err := fuzzyfinder.Find( - items, - func(i int) string { - return items[i].Display - }, - ) - + m := initialModel(items) + p := tea.NewProgram(m, tea.WithAltScreen()) + result, err := p.Run() if err != nil { - if errors.Is(err, fuzzyfinder.ErrAbort) { - return "", ErrSelectionCancelled - } return "", err } - return items[idx].Key, nil -} - -// RunWithPreview executes the fuzzy finder with a preview function -func RunWithPreview(items []Item, preview func(i int) string) (string, error) { - if len(items) == 0 { + final := result.(model) + if final.cancelled { return "", ErrSelectionCancelled } - - idx, err := fuzzyfinder.Find( - items, - func(i int) string { - return items[i].Display - }, - fuzzyfinder.WithPreviewWindow(func(i, w, h int) string { - if i < 0 || i >= len(items) { - return "" - } - return preview(i) - }), - ) - - if err != nil { - if errors.Is(err, fuzzyfinder.ErrAbort) { - return "", ErrSelectionCancelled - } - return "", err - } - - return items[idx].Key, nil + return final.selected, nil } diff --git a/internal/fzf/fzf_test.go b/internal/fzf/fzf_test.go index ac953c4..ed59286 100644 --- a/internal/fzf/fzf_test.go +++ b/internal/fzf/fzf_test.go @@ -33,16 +33,6 @@ func TestRun_EmptyInputs(t *testing.T) { } } -func TestRunWithPreview_EmptyInputs(t *testing.T) { - _, err := RunWithPreview([]Item{}, func(i int) string { return "" }) - if err == nil { - t.Error("expected error for empty inputs") - } - if !errors.Is(err, ErrSelectionCancelled) { - t.Errorf("expected ErrSelectionCancelled, got %v", err) - } -} - func TestErrSelectionCancelled_Is(t *testing.T) { err := ErrSelectionCancelled if !errors.Is(err, ErrSelectionCancelled) { @@ -51,25 +41,24 @@ func TestErrSelectionCancelled_Is(t *testing.T) { } func TestItem(t *testing.T) { - item := Item{Key: "my_session", Display: "my_session (ms, foo-session)"} + item := Item{Key: "my_session", Name: "my_session", Aliases: []string{"ms", "foo-session"}} if item.Key != "my_session" { t.Errorf("expected Key 'my_session', got %q", item.Key) } - if item.Display != "my_session (ms, foo-session)" { - t.Errorf("expected Display 'my_session (ms, foo-session)', got %q", item.Display) + if item.Name != "my_session" { + t.Errorf("expected Name 'my_session', got %q", item.Name) + } + if len(item.Aliases) != 2 { + t.Errorf("expected 2 aliases, got %d", len(item.Aliases)) } } func TestItem_NoAliases(t *testing.T) { - item := Item{Key: "simple", Display: "simple"} - if item.Key != item.Display { - t.Error("expected Key and Display to be equal for items without aliases") + item := Item{Key: "simple", Name: "simple"} + if item.Key != item.Name { + t.Error("expected Key and Name to be equal for items without aliases") + } + if len(item.Aliases) != 0 { + t.Error("expected no aliases") } } - -// Note: Full integration tests for the fuzzy finder require: -// 1. A terminal environment -// 2. A way to simulate user input -// -// The Run and RunWithPreview functions are tested implicitly through -// CLI integration tests. Unit tests focus on edge cases and error handling. diff --git a/internal/fzf/match.go b/internal/fzf/match.go new file mode 100644 index 0000000..8245095 --- /dev/null +++ b/internal/fzf/match.go @@ -0,0 +1,174 @@ +package fzf + +import ( + "sort" + "strings" + "unicode/utf8" +) + +// matchSource indicates whether a match was found in the Name or an Alias. +type matchSource int + +const ( + matchName matchSource = iota + matchAlias +) + +// aliasMatch holds match positions for a single alias. +type aliasMatch struct { + positions []int +} + +// matchResult holds the result of matching a query against an Item. +type matchResult struct { + item Item + index int // original index in items slice + score int + namePositions []int // matched char positions in Name + aliasMatches []aliasMatch // per-alias match positions (nil entry = no match) + source matchSource +} + +// filteredItem is a matchResult ready for display. +type filteredItem = matchResult + +// fuzzyMatch performs a case-insensitive, left-to-right fuzzy match of query +// against candidate. It returns whether the query matched, the positions of +// matched characters in the candidate, and a score. +func fuzzyMatch(query, candidate string) (bool, []int, int) { + if query == "" { + return true, nil, 0 + } + + lowerCandidate := strings.ToLower(candidate) + lowerQuery := strings.ToLower(query) + + positions := make([]int, 0, utf8.RuneCountInString(query)) + score := 0 + ci := 0 // candidate rune index + prevMatchIdx := -1 + + candidateRunes := []rune(lowerCandidate) + queryRunes := []rune(lowerQuery) + + qi := 0 + for ci < len(candidateRunes) && qi < len(queryRunes) { + if candidateRunes[ci] == queryRunes[qi] { + positions = append(positions, ci) + + // Consecutive match bonus + if prevMatchIdx == ci-1 { + score += 4 + } + + // Word-boundary bonus: first char, or preceded by separator + if ci == 0 || isSeparator(candidateRunes[ci-1]) { + score += 3 + } + + // Exact case match bonus + origRunes := []rune(candidate) + qOrigRunes := []rune(query) + if origRunes[ci] == qOrigRunes[qi] { + score += 1 + } + + prevMatchIdx = ci + qi++ + } + ci++ + } + + if qi < len(queryRunes) { + return false, nil, 0 + } + + // Base score for matching + score += 1 + + return true, positions, score +} + +func isSeparator(r rune) bool { + return r == ' ' || r == '-' || r == '_' || r == '/' || r == '.' +} + +// matchItem tries to fuzzy-match query against item's Name and all Aliases. +// It returns a matchResult with highlight positions for every field that matched. +// The score is taken from the best-matching field. +func matchItem(query string, item Item, index int) (matchResult, bool) { + if query == "" { + return matchResult{ + item: item, + index: index, + score: 0, + }, true + } + + anyMatched := false + bestScore := 0 + bestSource := matchName + + // Try name + var namePositions []int + nameMatched, nPos, nScore := fuzzyMatch(query, item.Name) + if nameMatched { + anyMatched = true + namePositions = nPos + bestScore = nScore + 10 // bonus for name match + } + + // Try each alias — always, so we can highlight all that match + aliasMatches := make([]aliasMatch, len(item.Aliases)) + for i, alias := range item.Aliases { + matched, positions, score := fuzzyMatch(query, alias) + if matched { + aliasMatches[i] = aliasMatch{positions: positions} + if !anyMatched || score > bestScore { + bestScore = score + bestSource = matchAlias + } + anyMatched = true + } + } + + if !anyMatched { + return matchResult{}, false + } + + return matchResult{ + item: item, + index: index, + score: bestScore, + namePositions: namePositions, + aliasMatches: aliasMatches, + source: bestSource, + }, true +} + +// filterAndSort filters items by query and returns sorted results. +// With an empty query, items are sorted alphabetically by name (A first, i.e. +// lowest index = A). With a non-empty query, items are sorted by match score +// descending (best match at lowest index). +func filterAndSort(items []Item, query string) []filteredItem { + var results []filteredItem + for i, item := range items { + if result, ok := matchItem(query, item, i); ok { + results = append(results, result) + } + } + + if query == "" { + // Alphabetical by name (case-insensitive) + sort.SliceStable(results, func(i, j int) bool { + return strings.ToLower(results[i].item.Name) < strings.ToLower(results[j].item.Name) + }) + } else { + // Best match first + sort.SliceStable(results, func(i, j int) bool { + return results[i].score > results[j].score + }) + } + + return results +} diff --git a/internal/fzf/match_test.go b/internal/fzf/match_test.go new file mode 100644 index 0000000..68c25ec --- /dev/null +++ b/internal/fzf/match_test.go @@ -0,0 +1,216 @@ +package fzf + +import ( + "testing" +) + +func TestFuzzyMatch_ExactPrefix(t *testing.T) { + matched, positions, score := fuzzyMatch("foo", "foobar") + if !matched { + t.Fatal("expected match") + } + if len(positions) != 3 { + t.Fatalf("expected 3 positions, got %d", len(positions)) + } + for i, p := range positions { + if p != i { + t.Errorf("position %d: expected %d, got %d", i, i, p) + } + } + if score <= 0 { + t.Error("expected positive score") + } +} + +func TestFuzzyMatch_ScatteredChars(t *testing.T) { + matched, positions, _ := fuzzyMatch("fb", "foobar") + if !matched { + t.Fatal("expected match") + } + if len(positions) != 2 { + t.Fatalf("expected 2 positions, got %d", len(positions)) + } + if positions[0] != 0 || positions[1] != 3 { + t.Errorf("expected positions [0, 3], got %v", positions) + } +} + +func TestFuzzyMatch_NoMatch(t *testing.T) { + matched, _, _ := fuzzyMatch("xyz", "foobar") + if matched { + t.Error("expected no match") + } +} + +func TestFuzzyMatch_CaseInsensitive(t *testing.T) { + matched, positions, _ := fuzzyMatch("FOO", "foobar") + if !matched { + t.Fatal("expected match") + } + if len(positions) != 3 { + t.Fatalf("expected 3 positions, got %d", len(positions)) + } +} + +func TestFuzzyMatch_EmptyQuery(t *testing.T) { + matched, positions, score := fuzzyMatch("", "anything") + if !matched { + t.Fatal("expected match for empty query") + } + if len(positions) != 0 { + t.Error("expected no positions for empty query") + } + if score != 0 { + t.Error("expected zero score for empty query") + } +} + +func TestFuzzyMatch_EmptyCandidate(t *testing.T) { + matched, _, _ := fuzzyMatch("a", "") + if matched { + t.Error("expected no match against empty candidate") + } +} + +func TestFuzzyMatch_ConsecutiveBonus(t *testing.T) { + _, _, scoreConsec := fuzzyMatch("fo", "foobar") + _, _, scoreScatter := fuzzyMatch("fb", "foobar") + if scoreConsec <= scoreScatter { + t.Errorf("expected consecutive score (%d) > scattered score (%d)", scoreConsec, scoreScatter) + } +} + +func TestFuzzyMatch_WordBoundaryBonus(t *testing.T) { + _, _, scoreBoundary := fuzzyMatch("fb", "foo-bar") + _, _, scoreMiddle := fuzzyMatch("fb", "fxxbxx") + if scoreBoundary <= scoreMiddle { + t.Errorf("expected boundary score (%d) > middle score (%d)", scoreBoundary, scoreMiddle) + } +} + +func TestMatchItem_NameMatch(t *testing.T) { + item := Item{Key: "test", Name: "foobar", Aliases: []string{"baz"}} + result, ok := matchItem("foo", item, 0) + if !ok { + t.Fatal("expected match") + } + if result.source != matchName { + t.Error("expected name match source") + } + if len(result.namePositions) != 3 { + t.Errorf("expected 3 name positions, got %d", len(result.namePositions)) + } +} + +func TestMatchItem_AliasMatch(t *testing.T) { + item := Item{Key: "test", Name: "foobar", Aliases: []string{"baz", "qux"}} + result, ok := matchItem("baz", item, 0) + if !ok { + t.Fatal("expected match") + } + if result.source != matchAlias { + t.Error("expected alias match source") + } + if len(result.aliasMatches) != 2 { + t.Fatalf("expected 2 aliasMatches entries, got %d", len(result.aliasMatches)) + } + if len(result.aliasMatches[0].positions) != 3 { + t.Errorf("expected 3 positions for first alias, got %d", len(result.aliasMatches[0].positions)) + } + if result.aliasMatches[1].positions != nil { + t.Error("expected no match for second alias") + } +} + +func TestMatchItem_NamePreferredOverAlias(t *testing.T) { + item := Item{Key: "test", Name: "abc", Aliases: []string{"abc"}} + result, ok := matchItem("abc", item, 0) + if !ok { + t.Fatal("expected match") + } + if result.source != matchName { + t.Error("expected name match to be preferred over alias") + } +} + +func TestMatchItem_HighlightsBothNameAndAlias(t *testing.T) { + item := Item{Key: "test", Name: "abc", Aliases: []string{"axe", "abc"}} + result, ok := matchItem("a", item, 0) + if !ok { + t.Fatal("expected match") + } + // Name should have position 0 highlighted + if len(result.namePositions) != 1 || result.namePositions[0] != 0 { + t.Errorf("expected name position [0], got %v", result.namePositions) + } + // Both aliases contain 'a', both should have highlights + if len(result.aliasMatches) != 2 { + t.Fatalf("expected 2 aliasMatches, got %d", len(result.aliasMatches)) + } + if len(result.aliasMatches[0].positions) != 1 { + t.Errorf("expected 1 position for first alias, got %d", len(result.aliasMatches[0].positions)) + } + if len(result.aliasMatches[1].positions) != 1 { + t.Errorf("expected 1 position for second alias, got %d", len(result.aliasMatches[1].positions)) + } +} + +func TestMatchItem_NoMatch(t *testing.T) { + item := Item{Key: "test", Name: "foobar", Aliases: []string{"baz"}} + _, ok := matchItem("xyz", item, 0) + if ok { + t.Error("expected no match") + } +} + +func TestMatchItem_EmptyQuery(t *testing.T) { + item := Item{Key: "test", Name: "foobar", Aliases: []string{"baz"}} + result, ok := matchItem("", item, 0) + if !ok { + t.Fatal("expected match for empty query") + } + if result.score != 0 { + t.Error("expected zero score for empty query") + } +} + +func TestFilterAndSort_Basic(t *testing.T) { + items := []Item{ + {Key: "a", Name: "alpha"}, + {Key: "b", Name: "beta"}, + {Key: "c", Name: "gamma"}, + } + + results := filterAndSort(items, "a") + if len(results) != 3 { // alpha, beta, and gamma all contain 'a' + t.Fatalf("expected 3 results, got %d", len(results)) + } + // alpha should rank higher (word-boundary match at start) + if results[0].item.Key != "a" { + t.Errorf("expected 'a' first, got %q", results[0].item.Key) + } +} + +func TestFilterAndSort_EmptyQuery(t *testing.T) { + items := []Item{ + {Key: "a", Name: "alpha"}, + {Key: "b", Name: "beta"}, + } + + results := filterAndSort(items, "") + if len(results) != 2 { + t.Fatalf("expected all items for empty query, got %d", len(results)) + } +} + +func TestFilterAndSort_NoMatches(t *testing.T) { + items := []Item{ + {Key: "a", Name: "alpha"}, + {Key: "b", Name: "beta"}, + } + + results := filterAndSort(items, "xyz") + if len(results) != 0 { + t.Fatalf("expected 0 results, got %d", len(results)) + } +}