mirror of
https://github.com/chenasraf/tx.git
synced 2026-05-17 17:28:08 +00:00
feat: new UI
This commit is contained in:
23
go.mod
23
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
|
||||
)
|
||||
|
||||
89
go.sum
89
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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
174
internal/fzf/match.go
Normal file
174
internal/fzf/match.go
Normal file
@@ -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
|
||||
}
|
||||
216
internal/fzf/match_test.go
Normal file
216
internal/fzf/match_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user