Merge pull request #12859 from cli/kw/experimental-huh-prompter
Add experimental huh-only prompter gated by GH_EXPERIMENTAL_PROMPTER
This commit is contained in:
commit
b62671151b
9 changed files with 1468 additions and 56 deletions
26
go.mod
26
go.mod
|
|
@ -3,6 +3,10 @@ module github.com/cli/cli/v2
|
|||
go 1.26.1
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.0.0
|
||||
charm.land/bubbletea/v2 v2.0.2
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.2
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/MakeNowJust/heredoc v1.0.0
|
||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2
|
||||
|
|
@ -11,7 +15,6 @@ require (
|
|||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/cli/go-gh/v2 v2.13.0
|
||||
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24
|
||||
|
|
@ -72,16 +75,20 @@ require (
|
|||
github.com/blang/semver v3.5.1+incompatible // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.2 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20250630141444-821143405392 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
github.com/cli/shurcooL-graphql v0.0.4 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
|
|
@ -92,7 +99,6 @@ require (
|
|||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
|
|
@ -134,14 +140,12 @@ require (
|
|||
github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.17 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
|
|
|
|||
67
go.sum
67
go.sum
|
|
@ -1,3 +1,11 @@
|
|||
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
|
||||
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
|
||||
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
|
||||
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
|
||||
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||
cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=
|
||||
cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI=
|
||||
cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
|
||||
|
|
@ -86,8 +94,8 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
|||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||
|
|
@ -102,38 +110,38 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
|||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
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.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
||||
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
|
||||
github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392 h1:VHLoEcL+kH60a4F8qMsPfOIfWjFE3ciaW4gge2YR3sA=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20250630141444-821143405392 h1:6ipGA1NEA0AZG2UEf81RQGJvEPvYLn/M18mZcdt4J8g=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20250630141444-821143405392/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
|
||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||
|
|
@ -148,6 +156,10 @@ github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
|
|||
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
|
||||
github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY=
|
||||
github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw=
|
||||
|
|
@ -185,8 +197,6 @@ github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqI
|
|||
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
|
|
@ -380,12 +390,10 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
|
|||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
|
||||
github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
|
|
@ -403,8 +411,6 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
|
|||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
|
|
@ -596,7 +602,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
|||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/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-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/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=
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ func TestAccessiblePrompter(t *testing.T) {
|
|||
|
||||
go func() {
|
||||
// Wait for prompt to appear without the invalid default value
|
||||
_, err := console.ExpectString("Select a number \r\n")
|
||||
_, err := console.ExpectString("Select a number")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select option 2
|
||||
|
|
@ -128,7 +128,7 @@ func TestAccessiblePrompter(t *testing.T) {
|
|||
|
||||
go func() {
|
||||
// Wait for prompt to appear
|
||||
_, err := console.ExpectString("Input a number between 0 and 3:")
|
||||
_, err := console.ExpectString("Enter a number between 0 and 3:")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select options 1 and 2
|
||||
|
|
@ -207,7 +207,7 @@ func TestAccessiblePrompter(t *testing.T) {
|
|||
|
||||
go func() {
|
||||
// Wait for prompt to appear without the invalid default values
|
||||
_, err := console.ExpectString("Select a number \r\n")
|
||||
_, err := console.ExpectString("Select a number")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Not selecting anything will fail because there are no defaults.
|
||||
|
|
@ -259,7 +259,7 @@ func TestAccessiblePrompter(t *testing.T) {
|
|||
|
||||
go func() {
|
||||
// Wait for prompt to appear
|
||||
_, err := console.ExpectString("Select an option \r\n")
|
||||
_, err := console.ExpectString("Select an option")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select the search option, which will always be the first option
|
||||
|
|
@ -279,7 +279,7 @@ func TestAccessiblePrompter(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
|
||||
// Wait for the multiselect prompt to re-appear after search
|
||||
_, err = console.ExpectString("Select an option \r\n")
|
||||
_, err = console.ExpectString("Select an option")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select the first search result
|
||||
|
|
@ -325,7 +325,7 @@ func TestAccessiblePrompter(t *testing.T) {
|
|||
|
||||
go func() {
|
||||
// Wait for prompt to appear
|
||||
_, err := console.ExpectString("Select an option (default: Initial Result Label 1) \r\n")
|
||||
_, err := console.ExpectString("Select an option (default: Initial Result Label 1)")
|
||||
require.NoError(t, err)
|
||||
|
||||
// This confirms default selections
|
||||
|
|
@ -379,7 +379,7 @@ func TestAccessiblePrompter(t *testing.T) {
|
|||
|
||||
go func() {
|
||||
// Wait for prompt to appear
|
||||
_, err := console.ExpectString("Select an option \r\n")
|
||||
_, err := console.ExpectString("Select an option")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select one of our initial search results
|
||||
|
|
@ -524,7 +524,7 @@ func TestAccessiblePrompter(t *testing.T) {
|
|||
// expected string matches any part of the stream, we have to use an
|
||||
// anchored regexp (i.e., with ^ and $) to make sure the password/token
|
||||
// is not printed at all.
|
||||
_, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$"))
|
||||
_, err = console.Expect(expect.RegexpPattern(`^(\x1b\[[\d;]*m)* \r\n\r\n$`))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
|
|
@ -615,7 +615,7 @@ func TestAccessiblePrompter(t *testing.T) {
|
|||
// expected string matches any part of the stream, we have to use an
|
||||
// anchored regexp (i.e., with ^ and $) to make sure the password/token
|
||||
// is not printed at all.
|
||||
_, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$"))
|
||||
_, err = console.Expect(expect.RegexpPattern(`^(\x1b\[[\d;]*m)* \r\n\r\n$`))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
|
|
@ -660,7 +660,7 @@ func TestAccessiblePrompter(t *testing.T) {
|
|||
// expected string matches any part of the stream, we have to use an
|
||||
// anchored regexp (i.e., with ^ and $) to make sure the password/token
|
||||
// is not printed at all.
|
||||
_, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$"))
|
||||
_, err = console.Expect(expect.RegexpPattern(`^(\x1b\[[\d;]*m)* \r\n\r\n$`))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
|
|
|
|||
290
internal/prompter/huh_prompter.go
Normal file
290
internal/prompter/huh_prompter.go
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
package prompter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/cli/v2/pkg/surveyext"
|
||||
ghPrompter "github.com/cli/go-gh/v2/pkg/prompter"
|
||||
)
|
||||
|
||||
type huhPrompter struct {
|
||||
stdin ghPrompter.FileReader
|
||||
stdout ghPrompter.FileWriter
|
||||
stderr ghPrompter.FileWriter
|
||||
editorCmd string
|
||||
}
|
||||
|
||||
func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form {
|
||||
return huh.NewForm(groups...).
|
||||
WithTheme(huh.ThemeFunc(huh.ThemeBase16)).
|
||||
WithInput(p.stdin).
|
||||
WithOutput(p.stdout)
|
||||
}
|
||||
|
||||
func (p *huhPrompter) runForm(form *huh.Form) error {
|
||||
err := form.Run()
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
// TODO(huh-prompter-improvements)
|
||||
// It's unfortunate that we take a dependency on survey/terminal here, but our clean cancellation logic
|
||||
// in cmd.go expects it. Better would be to have a prompter.Cancelled sentinel error, but then we need to
|
||||
// go and change non-experimental code to do so, and I don't think we should take that on right now.
|
||||
return terminal.InterruptErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *huhPrompter) buildSelectForm(prompt, defaultValue string, options []string) (*huh.Form, *int) {
|
||||
var result int
|
||||
|
||||
if !slices.Contains(options, defaultValue) {
|
||||
defaultValue = ""
|
||||
}
|
||||
|
||||
formOptions := make([]huh.Option[int], len(options))
|
||||
for i, o := range options {
|
||||
if defaultValue == o {
|
||||
result = i
|
||||
}
|
||||
formOptions[i] = huh.NewOption(o, i)
|
||||
}
|
||||
|
||||
form := p.newForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title(prompt).
|
||||
Value(&result).
|
||||
Options(formOptions...),
|
||||
),
|
||||
)
|
||||
return form, &result
|
||||
}
|
||||
|
||||
func (p *huhPrompter) Select(prompt, defaultValue string, options []string) (int, error) {
|
||||
form, result := p.buildSelectForm(prompt, defaultValue, options)
|
||||
err := p.runForm(form)
|
||||
return *result, err
|
||||
}
|
||||
|
||||
func (p *huhPrompter) buildMultiSelectForm(prompt string, defaults []string, options []string) (*huh.Form, *[]int) {
|
||||
var result []int
|
||||
|
||||
defaults = slices.DeleteFunc(defaults, func(s string) bool {
|
||||
return !slices.Contains(options, s)
|
||||
})
|
||||
|
||||
formOptions := make([]huh.Option[int], len(options))
|
||||
for i, o := range options {
|
||||
if slices.Contains(defaults, o) {
|
||||
result = append(result, i)
|
||||
}
|
||||
formOptions[i] = huh.NewOption(o, i)
|
||||
}
|
||||
|
||||
form := p.newForm(
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[int]().
|
||||
Title(prompt).
|
||||
Value(&result).
|
||||
Limit(len(options)).
|
||||
Options(formOptions...),
|
||||
),
|
||||
)
|
||||
return form, &result
|
||||
}
|
||||
|
||||
func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) {
|
||||
form, result := p.buildMultiSelectForm(prompt, defaults, options)
|
||||
err := p.runForm(form)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
|
||||
func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) (*huh.Form, *multiSelectSearchField) {
|
||||
field := newMultiSelectSearchField(prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
|
||||
form := p.newForm(huh.NewGroup(field))
|
||||
return form, field
|
||||
}
|
||||
|
||||
func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
|
||||
form, field := p.buildMultiSelectWithSearchForm(prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
|
||||
err := p.runForm(form)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return field.selectedKeys(), nil
|
||||
}
|
||||
|
||||
func (p *huhPrompter) buildInputForm(prompt, defaultValue string) (*huh.Form, *string) {
|
||||
result := defaultValue
|
||||
form := p.newForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title(prompt).
|
||||
Value(&result),
|
||||
),
|
||||
)
|
||||
return form, &result
|
||||
}
|
||||
|
||||
func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) {
|
||||
form, result := p.buildInputForm(prompt, defaultValue)
|
||||
err := p.runForm(form)
|
||||
return *result, err
|
||||
}
|
||||
|
||||
func (p *huhPrompter) buildPasswordForm(prompt string) (*huh.Form, *string) {
|
||||
var result string
|
||||
form := p.newForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Title(prompt).
|
||||
Value(&result),
|
||||
),
|
||||
)
|
||||
return form, &result
|
||||
}
|
||||
|
||||
func (p *huhPrompter) Password(prompt string) (string, error) {
|
||||
form, result := p.buildPasswordForm(prompt)
|
||||
err := p.runForm(form)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
|
||||
func (p *huhPrompter) buildConfirmForm(prompt string, defaultValue bool) (*huh.Form, *bool) {
|
||||
result := defaultValue
|
||||
form := p.newForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(prompt).
|
||||
Value(&result),
|
||||
),
|
||||
)
|
||||
return form, &result
|
||||
}
|
||||
|
||||
func (p *huhPrompter) Confirm(prompt string, defaultValue bool) (bool, error) {
|
||||
form, result := p.buildConfirmForm(prompt, defaultValue)
|
||||
err := p.runForm(form)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
|
||||
func (p *huhPrompter) buildAuthTokenForm() (*huh.Form, *string) {
|
||||
var result string
|
||||
form := p.newForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Title("Paste your authentication token:").
|
||||
Validate(func(input string) error {
|
||||
if input == "" {
|
||||
return fmt.Errorf("token is required")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Value(&result),
|
||||
),
|
||||
)
|
||||
return form, &result
|
||||
}
|
||||
|
||||
func (p *huhPrompter) AuthToken() (string, error) {
|
||||
form, result := p.buildAuthTokenForm()
|
||||
err := p.runForm(form)
|
||||
return *result, err
|
||||
}
|
||||
|
||||
func (p *huhPrompter) buildConfirmDeletionForm(requiredValue string) *huh.Form {
|
||||
return p.newForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title(fmt.Sprintf("Type %q to confirm deletion", requiredValue)).
|
||||
Validate(func(input string) error {
|
||||
if input != requiredValue {
|
||||
return fmt.Errorf("You entered: %q", input)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *huhPrompter) ConfirmDeletion(requiredValue string) error {
|
||||
return p.runForm(p.buildConfirmDeletionForm(requiredValue))
|
||||
}
|
||||
|
||||
func (p *huhPrompter) buildInputHostnameForm() (*huh.Form, *string) {
|
||||
var result string
|
||||
form := p.newForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Hostname:").
|
||||
Validate(ghinstance.HostnameValidator).
|
||||
Value(&result),
|
||||
),
|
||||
)
|
||||
return form, &result
|
||||
}
|
||||
|
||||
func (p *huhPrompter) InputHostname() (string, error) {
|
||||
form, result := p.buildInputHostnameForm()
|
||||
err := p.runForm(form)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
|
||||
func (p *huhPrompter) buildMarkdownEditorForm(prompt string, blankAllowed bool) (*huh.Form, *string) {
|
||||
var result string
|
||||
skipOption := "skip"
|
||||
launchOption := "launch"
|
||||
options := []huh.Option[string]{
|
||||
huh.NewOption(fmt.Sprintf("Launch %s", surveyext.EditorName(p.editorCmd)), launchOption),
|
||||
}
|
||||
if blankAllowed {
|
||||
options = append(options, huh.NewOption("Skip", skipOption))
|
||||
}
|
||||
|
||||
form := p.newForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(prompt).
|
||||
Options(options...).
|
||||
Value(&result),
|
||||
),
|
||||
)
|
||||
return form, &result
|
||||
}
|
||||
|
||||
func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) {
|
||||
form, result := p.buildMarkdownEditorForm(prompt, blankAllowed)
|
||||
err := p.runForm(form)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if *result == "skip" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return text, nil
|
||||
}
|
||||
643
internal/prompter/huh_prompter_test.go
Normal file
643
internal/prompter/huh_prompter_test.go
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
package prompter
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- Interaction helpers ---
|
||||
// A set of helpers for simulating user input in huh form tests.
|
||||
// Each helper (tab(), toggle(), typeKeys(), etc.) produces raw terminal
|
||||
// bytes that are piped into form.Run() via io.Pipe, driving the real
|
||||
// bubbletea event loop.
|
||||
|
||||
type interactionStep struct {
|
||||
bytes []byte
|
||||
delay time.Duration // pause before sending (lets the event loop settle)
|
||||
}
|
||||
|
||||
type interaction struct {
|
||||
steps []interactionStep
|
||||
}
|
||||
|
||||
func newInteraction(steps ...interactionStep) interaction {
|
||||
return interaction{steps: steps}
|
||||
}
|
||||
|
||||
func (ix interaction) run(t *testing.T, w *io.PipeWriter) {
|
||||
t.Helper()
|
||||
for _, s := range ix.steps {
|
||||
time.Sleep(s.delay)
|
||||
_, err := w.Write(s.bytes)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step helpers — each returns a single interactionStep.
|
||||
//
|
||||
// These send raw terminal escape sequences that bubbletea's input parser
|
||||
// understands. Common ANSI escape codes:
|
||||
//
|
||||
// \t = Tab
|
||||
// \x1b[Z = Shift+Tab (reverse tab)
|
||||
// \r = Enter (carriage return)
|
||||
// \x1b[A = Arrow Up
|
||||
// \x1b[B = Arrow Down
|
||||
// \x1b[C = Arrow Right
|
||||
// \x1b[D = Arrow Left
|
||||
// \x01 = Ctrl+A (line start)
|
||||
// \x0b = Ctrl+K (kill to end of line)
|
||||
|
||||
func tab() interactionStep {
|
||||
return interactionStep{bytes: []byte("\t")}
|
||||
}
|
||||
|
||||
func shiftTab() interactionStep {
|
||||
return interactionStep{bytes: []byte("\x1b[Z")}
|
||||
}
|
||||
|
||||
func enter() interactionStep {
|
||||
return interactionStep{bytes: []byte("\r")}
|
||||
}
|
||||
|
||||
func toggle() interactionStep {
|
||||
return interactionStep{bytes: []byte("x")}
|
||||
}
|
||||
|
||||
func down() interactionStep {
|
||||
return interactionStep{bytes: []byte("\x1b[B")}
|
||||
}
|
||||
|
||||
func left() interactionStep {
|
||||
return interactionStep{bytes: []byte("\x1b[D")}
|
||||
}
|
||||
|
||||
func right() interactionStep {
|
||||
return interactionStep{bytes: []byte("\x1b[C")}
|
||||
}
|
||||
|
||||
func typeKeys(s string) interactionStep {
|
||||
return interactionStep{bytes: []byte(s)}
|
||||
}
|
||||
|
||||
func pressY() interactionStep {
|
||||
return interactionStep{bytes: []byte("y")}
|
||||
}
|
||||
|
||||
func pressN() interactionStep {
|
||||
return interactionStep{bytes: []byte("n")}
|
||||
}
|
||||
|
||||
func clearLine() interactionStep {
|
||||
return interactionStep{bytes: []byte{0x01, 0x0b}}
|
||||
}
|
||||
|
||||
// waitForOptions adds extra delay to let OptionsFunc load before continuing.
|
||||
func waitForOptions() interactionStep {
|
||||
return interactionStep{bytes: nil, delay: 50 * time.Millisecond}
|
||||
}
|
||||
|
||||
// --- Test harness ---
|
||||
|
||||
func newTestHuhPrompter() *huhPrompter {
|
||||
return &huhPrompter{}
|
||||
}
|
||||
|
||||
// runForm runs a huh form with the given interaction, returning any error.
|
||||
// The form runs in a goroutine using bubbletea's real event loop via io.Pipe.
|
||||
func runForm(t *testing.T, f *huh.Form, ix interaction) {
|
||||
t.Helper()
|
||||
r, w := io.Pipe()
|
||||
f.WithInput(r).WithOutput(io.Discard).WithWidth(80)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- f.Run() }()
|
||||
|
||||
ix.run(t, w)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("form.Run() did not complete in time")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestHuhPrompterInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultValue string
|
||||
ix interaction
|
||||
wantResult string
|
||||
}{
|
||||
{
|
||||
name: "basic input",
|
||||
ix: newInteraction(typeKeys("hello"), enter()),
|
||||
wantResult: "hello",
|
||||
},
|
||||
{
|
||||
name: "default value returned when no input",
|
||||
defaultValue: "default",
|
||||
ix: newInteraction(enter()),
|
||||
wantResult: "default",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildInputForm("Name:", tt.defaultValue)
|
||||
runForm(t, f, tt.ix)
|
||||
require.Equal(t, tt.wantResult, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHuhPrompterSelect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options []string
|
||||
defaultValue string
|
||||
ix interaction
|
||||
wantIndex int
|
||||
}{
|
||||
{
|
||||
name: "selects first option by default",
|
||||
options: []string{"a", "b", "c"},
|
||||
ix: newInteraction(enter()),
|
||||
wantIndex: 0,
|
||||
},
|
||||
{
|
||||
name: "respects default value",
|
||||
options: []string{"a", "b", "c"},
|
||||
defaultValue: "b",
|
||||
ix: newInteraction(enter()),
|
||||
wantIndex: 1,
|
||||
},
|
||||
{
|
||||
name: "invalid default selects first",
|
||||
options: []string{"a", "b", "c"},
|
||||
defaultValue: "z",
|
||||
ix: newInteraction(enter()),
|
||||
wantIndex: 0,
|
||||
},
|
||||
{
|
||||
name: "navigate down one",
|
||||
options: []string{"a", "b", "c"},
|
||||
ix: newInteraction(down(), enter()),
|
||||
wantIndex: 1,
|
||||
},
|
||||
{
|
||||
name: "navigate down two",
|
||||
options: []string{"a", "b", "c"},
|
||||
ix: newInteraction(down(), down(), enter()),
|
||||
wantIndex: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildSelectForm("Pick:", tt.defaultValue, tt.options)
|
||||
runForm(t, f, tt.ix)
|
||||
require.Equal(t, tt.wantIndex, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHuhPrompterMultiSelect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options []string
|
||||
defaults []string
|
||||
ix interaction
|
||||
wantResult []int
|
||||
}{
|
||||
{
|
||||
name: "no defaults and no toggles returns empty",
|
||||
options: []string{"a", "b", "c"},
|
||||
ix: newInteraction(enter()),
|
||||
wantResult: []int{},
|
||||
},
|
||||
{
|
||||
name: "defaults are pre-selected",
|
||||
options: []string{"a", "b", "c"},
|
||||
defaults: []string{"a", "c"},
|
||||
ix: newInteraction(enter()),
|
||||
wantResult: []int{0, 2},
|
||||
},
|
||||
{
|
||||
name: "toggle first option",
|
||||
options: []string{"a", "b", "c"},
|
||||
ix: newInteraction(toggle(), enter()),
|
||||
wantResult: []int{0},
|
||||
},
|
||||
{
|
||||
name: "toggle multiple options",
|
||||
options: []string{"a", "b", "c"},
|
||||
ix: newInteraction(
|
||||
toggle(), // toggle a
|
||||
down(), // move to b
|
||||
down(), // move to c
|
||||
toggle(), // toggle c
|
||||
enter(),
|
||||
),
|
||||
wantResult: []int{0, 2},
|
||||
},
|
||||
{
|
||||
name: "invalid defaults are excluded",
|
||||
options: []string{"a", "b"},
|
||||
defaults: []string{"z"},
|
||||
ix: newInteraction(enter()),
|
||||
wantResult: []int{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildMultiSelectForm("Pick:", tt.defaults, tt.options)
|
||||
runForm(t, f, tt.ix)
|
||||
require.Equal(t, tt.wantResult, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHuhPrompterConfirm(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultValue bool
|
||||
ix interaction
|
||||
wantResult bool
|
||||
}{
|
||||
{
|
||||
name: "default false submitted as-is",
|
||||
ix: newInteraction(enter()),
|
||||
wantResult: false,
|
||||
},
|
||||
{
|
||||
name: "default true submitted as-is",
|
||||
defaultValue: true,
|
||||
ix: newInteraction(enter()),
|
||||
wantResult: true,
|
||||
},
|
||||
{
|
||||
name: "toggle from false to true with left arrow",
|
||||
ix: newInteraction(left(), enter()),
|
||||
wantResult: true,
|
||||
},
|
||||
{
|
||||
name: "toggle from true to false with right arrow",
|
||||
defaultValue: true,
|
||||
ix: newInteraction(right(), enter()),
|
||||
wantResult: false,
|
||||
},
|
||||
{
|
||||
name: "accept with y key",
|
||||
ix: newInteraction(pressY(), enter()),
|
||||
wantResult: true,
|
||||
},
|
||||
{
|
||||
name: "reject with n key",
|
||||
defaultValue: true,
|
||||
ix: newInteraction(pressN(), enter()),
|
||||
wantResult: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildConfirmForm("Sure?", tt.defaultValue)
|
||||
runForm(t, f, tt.ix)
|
||||
require.Equal(t, tt.wantResult, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHuhPrompterPassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ix interaction
|
||||
wantResult string
|
||||
}{
|
||||
{
|
||||
name: "basic password",
|
||||
ix: newInteraction(typeKeys("s3cret"), enter()),
|
||||
wantResult: "s3cret",
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
ix: newInteraction(enter()),
|
||||
wantResult: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildPasswordForm("Password:")
|
||||
runForm(t, f, tt.ix)
|
||||
require.Equal(t, tt.wantResult, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHuhPrompterMarkdownEditor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
blankAllowed bool
|
||||
ix interaction
|
||||
wantResult string
|
||||
}{
|
||||
{
|
||||
name: "selects launch by default",
|
||||
blankAllowed: true,
|
||||
ix: newInteraction(enter()),
|
||||
wantResult: "launch",
|
||||
},
|
||||
{
|
||||
name: "navigate to skip",
|
||||
blankAllowed: true,
|
||||
ix: newInteraction(down(), enter()),
|
||||
wantResult: "skip",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildMarkdownEditorForm("Body:", tt.blankAllowed)
|
||||
runForm(t, f, tt.ix)
|
||||
require.Equal(t, tt.wantResult, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHuhPrompterMultiSelectWithSearch(t *testing.T) {
|
||||
staticSearchFunc := func(query string) MultiSelectSearchResult {
|
||||
if query == "" {
|
||||
return MultiSelectSearchResult{
|
||||
Keys: []string{"result-a", "result-b"},
|
||||
Labels: []string{"Result A", "Result B"},
|
||||
}
|
||||
}
|
||||
return MultiSelectSearchResult{
|
||||
Keys: []string{"search-1", "search-2"},
|
||||
Labels: []string{"Search 1", "Search 2"},
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
defaults []string
|
||||
persistent []string
|
||||
ix interaction
|
||||
wantResult []string
|
||||
}{
|
||||
{
|
||||
name: "defaults are pre-selected and returned on immediate submit",
|
||||
defaults: []string{"result-a"},
|
||||
ix: newInteraction(tab(), enter()),
|
||||
wantResult: []string{"result-a"},
|
||||
},
|
||||
{
|
||||
name: "toggle an option from search results",
|
||||
ix: newInteraction(tab(), waitForOptions(), toggle(), enter()),
|
||||
wantResult: []string{"result-a"},
|
||||
},
|
||||
{
|
||||
name: "toggle multiple options",
|
||||
ix: newInteraction(
|
||||
tab(), waitForOptions(),
|
||||
toggle(), // toggle result-a
|
||||
down(), // move to result-b
|
||||
toggle(), // toggle result-b
|
||||
enter(),
|
||||
),
|
||||
wantResult: []string{"result-a", "result-b"},
|
||||
},
|
||||
{
|
||||
name: "no selection returns empty",
|
||||
ix: newInteraction(tab(), enter()),
|
||||
wantResult: []string{},
|
||||
},
|
||||
{
|
||||
name: "persistent options are shown and selectable",
|
||||
persistent: []string{"persistent-1"},
|
||||
ix: newInteraction(
|
||||
tab(), waitForOptions(),
|
||||
down(), // skip result-a
|
||||
down(), // skip result-b
|
||||
toggle(), // toggle persistent-1
|
||||
enter(),
|
||||
),
|
||||
wantResult: []string{"persistent-1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildMultiSelectWithSearchForm(
|
||||
"Select", "Search", tt.defaults, tt.persistent, staticSearchFunc,
|
||||
)
|
||||
runForm(t, f, tt.ix)
|
||||
assert.Equal(t, tt.wantResult, result.selectedKeys())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) {
|
||||
staticSearchFunc := func(query string) MultiSelectSearchResult {
|
||||
if query == "" {
|
||||
return MultiSelectSearchResult{
|
||||
Keys: []string{"result-a", "result-b"},
|
||||
Labels: []string{"Result A", "Result B"},
|
||||
}
|
||||
}
|
||||
return MultiSelectSearchResult{
|
||||
Keys: []string{"search-1", "search-2"},
|
||||
Labels: []string{"Search 1", "Search 2"},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("selections persist after changing search query", func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildMultiSelectWithSearchForm(
|
||||
"Select", "Search", nil, nil, staticSearchFunc,
|
||||
)
|
||||
runForm(t, f, newInteraction(
|
||||
tab(), waitForOptions(),
|
||||
toggle(), // toggle result-a
|
||||
shiftTab(), // back to search input
|
||||
typeKeys("foo"), // change query
|
||||
tab(), waitForOptions(),
|
||||
enter(), // submit — result-a should persist
|
||||
))
|
||||
assert.Equal(t, []string{"result-a"}, result.selectedKeys())
|
||||
})
|
||||
t.Run("empty search results shows no-results placeholder", func(t *testing.T) {
|
||||
emptySearchFunc := func(query string) MultiSelectSearchResult {
|
||||
return MultiSelectSearchResult{}
|
||||
}
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildMultiSelectWithSearchForm(
|
||||
"Select", "Search", nil, nil, emptySearchFunc,
|
||||
)
|
||||
// With no results, the "No results" message is shown.
|
||||
// Toggle does nothing, submitting returns empty.
|
||||
runForm(t, f, newInteraction(tab(), waitForOptions(), toggle(), enter()))
|
||||
assert.Equal(t, []string{}, result.selectedKeys())
|
||||
})
|
||||
}
|
||||
|
||||
func TestHuhPrompterAuthToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ix interaction
|
||||
wantResult string
|
||||
}{
|
||||
{
|
||||
name: "accepts token input",
|
||||
ix: newInteraction(typeKeys("ghp_abc123"), enter()),
|
||||
wantResult: "ghp_abc123",
|
||||
},
|
||||
{
|
||||
name: "rejects blank then accepts valid input",
|
||||
ix: newInteraction(enter(), typeKeys("ghp_valid"), enter()),
|
||||
wantResult: "ghp_valid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildAuthTokenForm()
|
||||
runForm(t, f, tt.ix)
|
||||
require.Equal(t, tt.wantResult, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHuhPrompterConfirmDeletion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requiredValue string
|
||||
ix interaction
|
||||
}{
|
||||
{
|
||||
name: "accepts matching input",
|
||||
requiredValue: "my-repo",
|
||||
ix: newInteraction(typeKeys("my-repo"), enter()),
|
||||
},
|
||||
{
|
||||
name: "rejects wrong input then accepts correct input",
|
||||
requiredValue: "my-repo",
|
||||
ix: newInteraction(typeKeys("wrong"), enter(), clearLine(), typeKeys("my-repo"), enter()),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f := p.buildConfirmDeletionForm(tt.requiredValue)
|
||||
runForm(t, f, tt.ix)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHuhPrompterInputHostname(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ix interaction
|
||||
wantResult string
|
||||
}{
|
||||
{
|
||||
name: "accepts valid hostname",
|
||||
ix: newInteraction(typeKeys("github.example.com"), enter()),
|
||||
wantResult: "github.example.com",
|
||||
},
|
||||
{
|
||||
name: "rejects blank then accepts valid hostname",
|
||||
ix: newInteraction(enter(), typeKeys("github.example.com"), enter()),
|
||||
wantResult: "github.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildInputHostnameForm()
|
||||
runForm(t, f, tt.ix)
|
||||
require.Equal(t, tt.wantResult, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHuhPrompterMultiSelectWithSearchBackspace(t *testing.T) {
|
||||
// Simulate real API latency and non-overlapping results.
|
||||
staticSearchFunc := func(query string) MultiSelectSearchResult {
|
||||
time.Sleep(100 * time.Millisecond) // simulate API latency
|
||||
if query == "" {
|
||||
return MultiSelectSearchResult{
|
||||
Keys: []string{"alice", "bob"},
|
||||
Labels: []string{"Alice", "Bob"},
|
||||
}
|
||||
}
|
||||
return MultiSelectSearchResult{
|
||||
Keys: []string{"frank", "fiona"},
|
||||
Labels: []string{"Frank", "Fiona"},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("selections persist after backspacing search query", func(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
f, result := p.buildMultiSelectWithSearchForm(
|
||||
"Select", "Search", nil, nil, staticSearchFunc,
|
||||
)
|
||||
longWait := interactionStep{delay: 300 * time.Millisecond}
|
||||
runForm(t, f, newInteraction(
|
||||
tab(), longWait,
|
||||
toggle(), // toggle alice
|
||||
shiftTab(), // back to search input
|
||||
typeKeys("f"), // type "f"
|
||||
longWait, // wait for API + OptionsFunc
|
||||
typeKeys("\x7f"), // backspace to ""
|
||||
longWait, // wait for cache/API
|
||||
tab(), longWait,
|
||||
enter(),
|
||||
))
|
||||
assert.Equal(t, []string{"alice"}, result.selectedKeys())
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunFormTranslatesErrUserAborted(t *testing.T) {
|
||||
p := newTestHuhPrompter()
|
||||
form, _ := p.buildSelectForm("Pick one:", "", []string{"a", "b", "c"})
|
||||
|
||||
r, w := io.Pipe()
|
||||
form.WithInput(r).WithOutput(io.Discard).WithWidth(80)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- p.runForm(form) }()
|
||||
|
||||
// Send Ctrl+C to trigger huh.ErrUserAborted
|
||||
_, err := w.Write([]byte{0x03})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
assert.ErrorIs(t, err, terminal.InterruptErr, "expected huh.ErrUserAborted to be translated to terminal.InterruptErr")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("runForm did not complete in time")
|
||||
}
|
||||
}
|
||||
445
internal/prompter/multi_select_with_search.go
Normal file
445
internal/prompter/multi_select_with_search.go
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
package prompter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// multiSelectSearchField is a custom huh Field that combines a text input
|
||||
// for searching with a multi-select list. Unlike huh's built-in OptionsFunc,
|
||||
// search results are loaded synchronously when the user presses Enter in
|
||||
// the search input, avoiding goroutine races with selection state.
|
||||
type multiSelectSearchField struct {
|
||||
// configuration
|
||||
title string
|
||||
searchTitle string
|
||||
searchFunc func(string) MultiSelectSearchResult
|
||||
|
||||
// state
|
||||
mode msMode // which sub-component has focus
|
||||
search textinput.Model
|
||||
cursor int
|
||||
loading bool
|
||||
spinner spinner.Model
|
||||
|
||||
// options and selections
|
||||
options []msOption
|
||||
selected map[string]bool // key → selected (source of truth)
|
||||
optionLabels map[string]string // key → display label
|
||||
lastQuery string
|
||||
defaultValues []string
|
||||
persistent []string
|
||||
|
||||
// field metadata
|
||||
key string
|
||||
err error
|
||||
focused bool
|
||||
width int
|
||||
height int
|
||||
theme huh.Theme
|
||||
hasDarkBg bool
|
||||
position huh.FieldPosition
|
||||
}
|
||||
|
||||
type msMode int
|
||||
|
||||
const (
|
||||
msModeSearch msMode = iota
|
||||
msModeSelect
|
||||
)
|
||||
|
||||
type msOption struct {
|
||||
label string
|
||||
value string
|
||||
}
|
||||
|
||||
// msSearchResultMsg carries search results back from the background goroutine.
|
||||
type msSearchResultMsg struct {
|
||||
query string
|
||||
result MultiSelectSearchResult
|
||||
}
|
||||
|
||||
func newMultiSelectSearchField(
|
||||
title, searchTitle string,
|
||||
defaults, persistent []string,
|
||||
searchFunc func(string) MultiSelectSearchResult,
|
||||
) *multiSelectSearchField {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = "> "
|
||||
ti.Placeholder = "Type to search"
|
||||
ti.Focus()
|
||||
|
||||
selected := make(map[string]bool)
|
||||
for _, k := range defaults {
|
||||
selected[k] = true
|
||||
}
|
||||
|
||||
m := &multiSelectSearchField{
|
||||
title: title,
|
||||
searchTitle: searchTitle,
|
||||
searchFunc: searchFunc,
|
||||
mode: msModeSearch,
|
||||
search: ti,
|
||||
selected: selected,
|
||||
optionLabels: make(map[string]string),
|
||||
defaultValues: defaults,
|
||||
persistent: persistent,
|
||||
height: 10,
|
||||
spinner: spinner.New(spinner.WithSpinner(spinner.Line)),
|
||||
}
|
||||
|
||||
// Load initial results synchronously (form hasn't started yet).
|
||||
m.applySearchResult("", m.searchFunc(""))
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// startSearch launches an async search and returns a tea.Cmd that will
|
||||
// deliver the result via msSearchResultMsg.
|
||||
func (m *multiSelectSearchField) startSearch(query string) tea.Cmd {
|
||||
m.loading = true
|
||||
searchFunc := m.searchFunc
|
||||
return tea.Batch(
|
||||
func() tea.Msg {
|
||||
return msSearchResultMsg{query: query, result: searchFunc(query)}
|
||||
},
|
||||
m.spinner.Tick,
|
||||
)
|
||||
}
|
||||
|
||||
// applySearchResult processes a completed search and rebuilds the option list.
|
||||
func (m *multiSelectSearchField) applySearchResult(query string, result MultiSelectSearchResult) {
|
||||
m.loading = false
|
||||
m.lastQuery = query
|
||||
if result.Err != nil {
|
||||
m.err = result.Err
|
||||
return
|
||||
}
|
||||
if len(result.Keys) != len(result.Labels) {
|
||||
m.err = fmt.Errorf("search returned mismatched keys and labels: %d keys, %d labels", len(result.Keys), len(result.Labels))
|
||||
return
|
||||
}
|
||||
|
||||
for i, k := range result.Keys {
|
||||
m.optionLabels[k] = result.Labels[i]
|
||||
}
|
||||
|
||||
// Build option list: selected items first, then results, then persistent.
|
||||
var options []msOption
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// 1. Currently selected items.
|
||||
for _, k := range m.selectedKeys() {
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
options = append(options, msOption{label: m.label(k), value: k})
|
||||
}
|
||||
|
||||
// 2. Search results.
|
||||
for i, k := range result.Keys {
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
l := result.Labels[i]
|
||||
if l == "" {
|
||||
l = k
|
||||
}
|
||||
options = append(options, msOption{label: l, value: k})
|
||||
}
|
||||
|
||||
// 3. Persistent options.
|
||||
for _, k := range m.persistent {
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
options = append(options, msOption{label: m.label(k), value: k})
|
||||
}
|
||||
|
||||
m.options = options
|
||||
m.cursor = 0
|
||||
m.err = nil
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) selectedKeys() []string {
|
||||
keys := make([]string, 0)
|
||||
// Maintain order: defaults first, then any added during this session.
|
||||
seen := make(map[string]bool)
|
||||
for _, k := range m.defaultValues {
|
||||
if m.selected[k] && !seen[k] {
|
||||
keys = append(keys, k)
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
for _, o := range m.options {
|
||||
if m.selected[o.value] && !seen[o.value] {
|
||||
keys = append(keys, o.value)
|
||||
seen[o.value] = true
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) label(key string) string {
|
||||
if l, ok := m.optionLabels[key]; ok && l != "" {
|
||||
return l
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// --- huh.Field interface ---
|
||||
|
||||
func (m *multiSelectSearchField) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) Update(msg tea.Msg) (huh.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.BackgroundColorMsg:
|
||||
m.hasDarkBg = msg.IsDark()
|
||||
|
||||
case msSearchResultMsg:
|
||||
m.applySearchResult(msg.query, msg.result)
|
||||
m.mode = msModeSelect
|
||||
m.search.Blur()
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if !m.loading {
|
||||
break
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if m.loading {
|
||||
return m, nil // ignore keys while loading
|
||||
}
|
||||
switch m.mode {
|
||||
case msModeSearch:
|
||||
return m.updateSearch(msg)
|
||||
case msModeSelect:
|
||||
return m.updateSelect(msg)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) updateSearch(msg tea.KeyPressMsg) (huh.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter", "tab"))):
|
||||
query := m.search.Value()
|
||||
if query == m.lastQuery {
|
||||
// Query unchanged — just switch to select mode.
|
||||
m.mode = msModeSelect
|
||||
m.search.Blur()
|
||||
return m, nil
|
||||
}
|
||||
// New query — clear input and search in background with spinner.
|
||||
m.search.SetValue("")
|
||||
return m, m.startSearch(query)
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
|
||||
return m, huh.PrevField
|
||||
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.search, cmd = m.search.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) updateSelect(msg tea.KeyPressMsg) (huh.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
|
||||
// Back to search mode.
|
||||
m.mode = msModeSearch
|
||||
m.search.Focus()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
return m, huh.NextField
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
|
||||
if m.cursor < len(m.options)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("space", "x"))):
|
||||
if len(m.options) > 0 {
|
||||
k := m.options[m.cursor].value
|
||||
m.selected[k] = !m.selected[k]
|
||||
if !m.selected[k] {
|
||||
delete(m.selected, k)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) View() string {
|
||||
styles := m.activeStyles()
|
||||
var sb strings.Builder
|
||||
|
||||
// Title.
|
||||
if m.title != "" {
|
||||
sb.WriteString(styles.Title.Render(m.title))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Search input.
|
||||
if m.searchTitle != "" {
|
||||
sb.WriteString(styles.Description.Render(m.searchTitle))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(m.search.View())
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Options list.
|
||||
if m.loading {
|
||||
m.spinner.Style = styles.MultiSelectSelector.UnsetString()
|
||||
sb.WriteString(m.spinner.View() + " Loading...")
|
||||
sb.WriteString("\n")
|
||||
} else if len(m.options) == 0 {
|
||||
sb.WriteString(styles.UnselectedOption.Render(" No results"))
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
for i, o := range m.options {
|
||||
cursor := m.mode == msModeSelect && i == m.cursor
|
||||
isSelected := m.selected[o.value]
|
||||
sb.WriteString(m.renderOption(o, cursor, isSelected))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return styles.Base.Width(m.width).Height(m.height).Render(sb.String())
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) renderOption(o msOption, cursor, selected bool) string {
|
||||
styles := m.activeStyles()
|
||||
|
||||
var parts []string
|
||||
if cursor {
|
||||
parts = append(parts, styles.MultiSelectSelector.String())
|
||||
} else {
|
||||
parts = append(parts, strings.Repeat(" ", lipgloss.Width(styles.MultiSelectSelector.String())))
|
||||
}
|
||||
if selected {
|
||||
parts = append(parts, styles.SelectedPrefix.String())
|
||||
parts = append(parts, styles.SelectedOption.Render(o.label))
|
||||
} else {
|
||||
parts = append(parts, styles.UnselectedPrefix.String())
|
||||
parts = append(parts, styles.UnselectedOption.Render(o.label))
|
||||
}
|
||||
return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) activeStyles() *huh.FieldStyles {
|
||||
theme := m.theme
|
||||
if theme == nil {
|
||||
theme = huh.ThemeFunc(huh.ThemeCharm)
|
||||
}
|
||||
if m.focused {
|
||||
return &theme.Theme(m.hasDarkBg).Focused
|
||||
}
|
||||
return &theme.Theme(m.hasDarkBg).Blurred
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) Focus() tea.Cmd {
|
||||
m.focused = true
|
||||
if m.mode == msModeSearch {
|
||||
return m.search.Focus()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) Blur() tea.Cmd {
|
||||
m.focused = false
|
||||
m.search.Blur()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) Error() error { return m.err }
|
||||
func (*multiSelectSearchField) Skip() bool { return false }
|
||||
func (*multiSelectSearchField) Zoom() bool { return false }
|
||||
func (m *multiSelectSearchField) GetKey() string { return m.key }
|
||||
func (m *multiSelectSearchField) GetValue() any { return m.selectedKeys() }
|
||||
func (m *multiSelectSearchField) Run() error { return huh.Run(m) }
|
||||
func (m *multiSelectSearchField) RunAccessible(w io.Writer, r io.Reader) error {
|
||||
_, _ = fmt.Fprintln(w, "MultiSelectWithSearch accessible mode not implemented")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) KeyBinds() []key.Binding {
|
||||
if m.mode == msModeSearch {
|
||||
return []key.Binding{
|
||||
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "search")),
|
||||
key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")),
|
||||
}
|
||||
}
|
||||
return []key.Binding{
|
||||
key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle")),
|
||||
key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")),
|
||||
key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")),
|
||||
key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "search")),
|
||||
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) WithTheme(theme huh.Theme) huh.Field {
|
||||
if m.theme != nil {
|
||||
return m
|
||||
}
|
||||
m.theme = theme
|
||||
|
||||
styles := theme.Theme(m.hasDarkBg)
|
||||
st := m.search.Styles()
|
||||
st.Cursor.Color = styles.Focused.TextInput.Cursor.GetForeground()
|
||||
st.Focused.Prompt = styles.Focused.TextInput.Prompt
|
||||
st.Focused.Text = styles.Focused.TextInput.Text
|
||||
st.Focused.Placeholder = styles.Focused.TextInput.Placeholder
|
||||
m.search.SetStyles(st)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) WithKeyMap(k *huh.KeyMap) huh.Field {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) WithWidth(width int) huh.Field {
|
||||
m.width = width
|
||||
m.search.SetWidth(width)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) WithHeight(height int) huh.Field {
|
||||
m.height = height
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *multiSelectSearchField) WithPosition(p huh.FieldPosition) huh.Field {
|
||||
m.position = p
|
||||
return m
|
||||
}
|
||||
|
|
@ -5,8 +5,8 @@ import (
|
|||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/surveyext"
|
||||
|
|
@ -53,6 +53,15 @@ type Prompter interface {
|
|||
}
|
||||
|
||||
func New(editorCmd string, io *iostreams.IOStreams) Prompter {
|
||||
if io.ExperimentalPrompterEnabled() {
|
||||
return &huhPrompter{
|
||||
stdin: io.In,
|
||||
stdout: io.Out,
|
||||
stderr: io.ErrOut,
|
||||
editorCmd: editorCmd,
|
||||
}
|
||||
}
|
||||
|
||||
if io.AccessiblePrompterEnabled() {
|
||||
return &accessiblePrompter{
|
||||
stdin: io.In,
|
||||
|
|
@ -80,7 +89,7 @@ type accessiblePrompter struct {
|
|||
|
||||
func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form {
|
||||
return huh.NewForm(groups...).
|
||||
WithTheme(huh.ThemeBase16()).
|
||||
WithTheme(huh.ThemeFunc(huh.ThemeBase16)).
|
||||
WithAccessible(true).
|
||||
WithInput(p.stdin).
|
||||
WithOutput(p.stdout)
|
||||
|
|
|
|||
|
|
@ -316,6 +316,13 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams {
|
|||
io.SetAccessiblePrompterEnabled(true)
|
||||
}
|
||||
|
||||
experimentalPrompterValue, experimentalPrompterIsSet := os.LookupEnv("GH_EXPERIMENTAL_PROMPTER")
|
||||
if experimentalPrompterIsSet {
|
||||
if !slices.Contains(falseyValues, experimentalPrompterValue) {
|
||||
io.SetExperimentalPrompterEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED")
|
||||
if ghSpinnerDisabledIsSet {
|
||||
if !slices.Contains(falseyValues, ghSpinnerDisabledValue) {
|
||||
|
|
|
|||
|
|
@ -79,8 +79,9 @@ type IOStreams struct {
|
|||
pagerCommand string
|
||||
pagerProcess *os.Process
|
||||
|
||||
neverPrompt bool
|
||||
accessiblePrompterEnabled bool
|
||||
neverPrompt bool
|
||||
accessiblePrompterEnabled bool
|
||||
experimentalPrompterEnabled bool
|
||||
|
||||
TempFileOverride *os.File
|
||||
}
|
||||
|
|
@ -466,6 +467,14 @@ func (s *IOStreams) AccessiblePrompterEnabled() bool {
|
|||
return s.accessiblePrompterEnabled
|
||||
}
|
||||
|
||||
func (s *IOStreams) SetExperimentalPrompterEnabled(enabled bool) {
|
||||
s.experimentalPrompterEnabled = enabled
|
||||
}
|
||||
|
||||
func (s *IOStreams) ExperimentalPrompterEnabled() bool {
|
||||
return s.experimentalPrompterEnabled
|
||||
}
|
||||
|
||||
func System() *IOStreams {
|
||||
terminal := ghTerm.FromEnv()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue