diff --git a/.golangci.yml b/.golangci.yml index 389516913af5..0b7d3b63e6c5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -113,6 +113,8 @@ linters-settings: - anon - empty - error + - github.com/charmbracelet/bubbletea\.Model + - github.com/charmbracelet/bubbletea\.Msg - github.com/go-git/go-git/v5/plumbing/format/diff\.File - github.com/go-git/go-git/v5/plumbing/format/diff\.Patch - github.com/mitchellh/mapstructure\.DecodeHookFunc diff --git a/go.mod b/go.mod index 4bec22ff04ab..1603d7d59d4a 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.22 github.com/bmatcuk/doublestar/v4 v4.2.0 github.com/bradenhilton/mozillainstallhash v1.0.0 + github.com/charmbracelet/bubbles v0.14.0 + github.com/charmbracelet/bubbletea v0.22.1 github.com/charmbracelet/glamour v0.5.0 github.com/coreos/go-semver v0.3.0 github.com/fsnotify/fsnotify v1.5.4 @@ -56,6 +58,7 @@ require ( github.com/acomagu/bufpipe v1.0.3 // indirect github.com/alecthomas/chroma v0.10.0 // indirect github.com/alessio/shellescape v1.4.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.12.18 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.15 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.21 // indirect @@ -68,7 +71,9 @@ require ( github.com/aws/smithy-go v1.13.2 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bradenhilton/cityhash v1.0.0 // indirect + github.com/charmbracelet/lipgloss v0.5.0 // indirect github.com/cloudflare/circl v1.2.0 // indirect + github.com/containerd/console v1.0.3 // indirect github.com/danieljoos/wincred v1.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.7.0 // indirect @@ -91,11 +96,14 @@ require ( github.com/magiconair/properties v1.8.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect github.com/microcosm-cc/bluemonday v1.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.12.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect diff --git a/go.sum b/go.sum index 592d8bb65b93..0c7fbe4e2f99 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYU github.com/armon/go-metrics v0.3.10 h1:FR+drcQStOe+32sYyJYyZ7FIdgoGGBnwLl+flodp8Uo= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.16.14 h1:db6GvO4Z2UqHt5gvT0lr6J5x5P+oQ7bdRzczVaRekMU= github.com/aws/aws-sdk-go-v2 v1.16.14/go.mod h1:s/G+UV29dECbF5rf+RNj1xhlmvoNurGSr+McVSRj59w= github.com/aws/aws-sdk-go-v2/config v1.17.5 h1:+NS1BWvprx7nHcIk5o32LrZgifs/7Pm1V2nWjQgZ2H0= @@ -114,8 +116,17 @@ github.com/bwesterb/go-ristretto v1.2.1 h1:Xd9ZXmjKE2aY8Ub7+4bX7tXsIPsV1pIZaUlJU github.com/bwesterb/go-ristretto v1.2.1/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/charmbracelet/bubbles v0.14.0 h1:DJfCwnARfWjZLvMglhSQzo76UZ2gucuHPy9jLWX45Og= +github.com/charmbracelet/bubbles v0.14.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= +github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= +github.com/charmbracelet/bubbletea v0.22.1 h1:z66q0LWdJNOWEH9zadiAIXp2GN1AWrwNXU8obVY9X24= +github.com/charmbracelet/bubbletea v0.22.1/go.mod h1:8/7hVvbPN6ZZPkczLiB8YpLkLJ0n7DMho5Wvfd2X1C0= github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g= github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= @@ -131,6 +142,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534 h1:rtAn27wIbmOGUs7RIbVgPEjb31ehTVniDwPGXyMxm5U= @@ -332,6 +345,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= @@ -347,7 +362,10 @@ github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -366,11 +384,19 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +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/combinator v0.3.0 h1:SZDuRzzwmVPLkbOzbhGzBTwd5+Y6aFN4UusOW2azrNA= github.com/muesli/combinator v0.3.0/go.mod h1:ttPegJX0DPQaGDtJKMInIP6Vfp5pN8RX7QntFCcpy18= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= @@ -410,6 +436,8 @@ github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6us github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/crypt v0.6.0 h1:REOEXCs/NFY/1jOCEouMuT4zEniE5YoXbvpC5X/TLF8= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shirou/gopsutil/v3 v3.22.4 h1:srAQaiX6jX/cYL6q29aE0m8lOskT9CurZ9N61YR3yoI= @@ -665,6 +693,7 @@ golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220315194320-039c03cc5b86/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -674,6 +703,7 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 h1:wM1k/lXfpc5HdkJJyW9GELpd8ERGdnh8sMGL6Gzq3Ho= golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/pkg/chezmoibubbles/boolinputmodel.go b/pkg/chezmoibubbles/boolinputmodel.go new file mode 100644 index 000000000000..96a4a3eecf2a --- /dev/null +++ b/pkg/chezmoibubbles/boolinputmodel.go @@ -0,0 +1,79 @@ +package chezmoibubbles + +import ( + "strconv" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/twpayne/chezmoi/v2/pkg/chezmoi" +) + +type BoolInputModel struct { + textInput textinput.Model + defaultValue *bool + canceled bool +} + +func NewBoolInputModel(prompt string, defaultValue *bool) BoolInputModel { + textInput := textinput.New() + textInput.Prompt = prompt + "? " + textInput.Placeholder = "bool" + if defaultValue != nil { + textInput.Placeholder += ", default " + strconv.FormatBool(*defaultValue) + } + textInput.Validate = func(value string) error { + if value == "" && defaultValue != nil { + return nil + } + _, err := chezmoi.ParseBool(value) + return err + } + textInput.Focus() + return BoolInputModel{ + textInput: textInput, + defaultValue: defaultValue, + } +} + +func (m BoolInputModel) Canceled() bool { + return m.canceled +} + +func (m BoolInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m BoolInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + case tea.KeyEnter: + if m.defaultValue != nil { + m.textInput.SetValue(strconv.FormatBool(*m.defaultValue)) + return m, tea.Quit + } + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + if _, err := chezmoi.ParseBool(m.textInput.Value()); err == nil { + return m, tea.Quit + } + return m, cmd +} + +func (m BoolInputModel) Value() bool { + valueStr := m.textInput.Value() + if valueStr == "" && m.defaultValue != nil { + return *m.defaultValue + } + value, _ := chezmoi.ParseBool(valueStr) + return value +} + +func (m BoolInputModel) View() string { + return m.textInput.View() +} diff --git a/pkg/chezmoibubbles/boolinputmodel_test.go b/pkg/chezmoibubbles/boolinputmodel_test.go new file mode 100644 index 000000000000..cd8c656b4091 --- /dev/null +++ b/pkg/chezmoibubbles/boolinputmodel_test.go @@ -0,0 +1,70 @@ +package chezmoibubbles + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBoolInputModel(t *testing.T) { + for _, tc := range []struct { + name string + defaultValue *bool + input string + expectedCanceled bool + expectedValue bool + }{ + { + name: "empty_with_default", + defaultValue: boolPtr(true), + input: "\r", + expectedValue: true, + }, + { + name: "cancel_ctrlc", + input: "\x03", + expectedCanceled: true, + }, + { + name: "cancel_esc", + input: "\x1b", + expectedCanceled: true, + }, + { + name: "true", + input: "t", + expectedValue: true, + }, + { + name: "false", + input: "f", + expectedValue: false, + }, + { + name: "yes", + input: "y", + expectedValue: true, + }, + { + name: "no", + input: "n", + expectedValue: false, + }, + { + name: "one", + input: "1", + expectedValue: true, + }, + { + name: "zero", + input: "0", + expectedValue: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualModel := testRunModelWithInput(t, NewBoolInputModel("prompt", tc.defaultValue), tc.input) + assert.Equal(t, tc.expectedCanceled, actualModel.Canceled()) + assert.Equal(t, tc.expectedValue, actualModel.Value()) + }) + } +} diff --git a/pkg/chezmoibubbles/chezmoibubbles_test.go b/pkg/chezmoibubbles/chezmoibubbles_test.go new file mode 100644 index 000000000000..45c7abffb8cc --- /dev/null +++ b/pkg/chezmoibubbles/chezmoibubbles_test.go @@ -0,0 +1,53 @@ +package chezmoibubbles + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/require" +) + +var keyTypes = map[tea.KeyType]struct{}{ + tea.KeyCtrlC: {}, + tea.KeyEnter: {}, + tea.KeyEsc: {}, +} + +//nolint:ireturn,nolintlint +func makeKeyMsg(r rune) tea.Msg { + key := tea.Key{ + Type: tea.KeyRunes, + Runes: []rune{r}, + } + if _, ok := keyTypes[tea.KeyType(r)]; ok { + key = tea.Key{ + Type: tea.KeyType(r), + } + } + return tea.KeyMsg(key) +} + +//nolint:ireturn,nolintlint +func makeKeyMsgs(s string) []tea.Msg { + msgs := make([]tea.Msg, 0, len(s)) + for _, r := range s { + msgs = append(msgs, makeKeyMsg(r)) + } + return msgs +} + +//nolint:ireturn,nolintlint +func testRunModelWithInput[M tea.Model](t *testing.T, model M, input string) M { + t.Helper() + for _, msg := range makeKeyMsgs(input) { + m, _ := model.Update(msg) + var ok bool + model, ok = m.(M) + require.True(t, ok) + } + return model +} + +func boolPtr(b bool) *bool { return &b } +func int64Ptr(i int64) *int64 { return &i } +func stringPtr(s string) *string { return &s } diff --git a/pkg/chezmoibubbles/choiceinputmodel.go b/pkg/chezmoibubbles/choiceinputmodel.go new file mode 100644 index 000000000000..4e5cd5b078c0 --- /dev/null +++ b/pkg/chezmoibubbles/choiceinputmodel.go @@ -0,0 +1,94 @@ +package chezmoibubbles + +import ( + "errors" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/twpayne/chezmoi/v2/pkg/chezmoi" +) + +type ChoiceInputModel struct { + textInput textinput.Model + uniqueAbbreviations map[string]string + defaultValue *string + canceled bool +} + +func NewChoiceInputModel(prompt string, choices []string, defaultValue *string) ChoiceInputModel { + textInput := textinput.New() + textInput.Prompt = prompt + "? " + textInput.Placeholder = strings.Join(choices, "/") + if defaultValue != nil { + textInput.Placeholder += ", default " + *defaultValue + } + allAbbreviations := make(map[string]struct{}) + for _, choice := range choices { + for i := range choice { + allAbbreviations[choice[:i+1]] = struct{}{} + } + } + textInput.Validate = func(s string) error { + if s == "" && defaultValue != nil { + return nil + } + if _, ok := allAbbreviations[s]; ok { + return nil + } + return errors.New("unknown or ambiguous choice") + } + textInput.Focus() + return ChoiceInputModel{ + textInput: textInput, + uniqueAbbreviations: chezmoi.UniqueAbbreviations(choices), + defaultValue: defaultValue, + } +} + +func (m ChoiceInputModel) Canceled() bool { + return m.canceled +} + +func (m ChoiceInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m ChoiceInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + case tea.KeyEnter: + value := m.textInput.Value() + if value == "" && m.defaultValue != nil { + m.textInput.SetValue(*m.defaultValue) + return m, tea.Quit + } else if value, ok := m.uniqueAbbreviations[value]; ok { + m.textInput.SetValue(value) + return m, tea.Quit + } + } + } + + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + if _, ok := m.uniqueAbbreviations[m.textInput.Value()]; ok { + return m, tea.Quit + } + return m, cmd +} + +func (m ChoiceInputModel) Value() string { + value := m.textInput.Value() + if value == "" && m.defaultValue != nil { + return *m.defaultValue + } + return m.uniqueAbbreviations[value] +} + +func (m ChoiceInputModel) View() string { + return m.textInput.View() +} diff --git a/pkg/chezmoibubbles/choiceinputmodel_test.go b/pkg/chezmoibubbles/choiceinputmodel_test.go new file mode 100644 index 000000000000..b6d8554d1c7e --- /dev/null +++ b/pkg/chezmoibubbles/choiceinputmodel_test.go @@ -0,0 +1,78 @@ +package chezmoibubbles + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestChoiceInputModel(t *testing.T) { + choicesYesNoAll := []string{"yes", "no", "all"} + for _, tc := range []struct { + name string + choices []string + defaultValue *string + input string + expectedCanceled bool + expectedValue string + }{ + { + name: "empty_with_default", + choices: choicesYesNoAll, + defaultValue: stringPtr("all"), + input: "\r", + expectedValue: "all", + }, + { + name: "cancel_ctrlc", + input: "\x03", + expectedCanceled: true, + }, + { + name: "cancel_esc", + input: "\x1b", + expectedCanceled: true, + }, + { + name: "y", + choices: choicesYesNoAll, + input: "y", + expectedValue: "yes", + }, + { + name: "n", + choices: choicesYesNoAll, + input: "n", + expectedValue: "no", + }, + { + name: "a", + choices: choicesYesNoAll, + input: "a", + expectedValue: "all", + }, + { + name: "ambiguous_a", + choices: []string{"aaa", "abb", "bbb"}, + input: "a", + }, + { + name: "unambiguous_b", + choices: []string{"aaa", "abb", "bbb"}, + input: "b", + expectedValue: "bbb", + }, + { + name: "ambiguous_resolved", + choices: []string{"aaa", "abb", "bbb"}, + input: "aa", + expectedValue: "aaa", + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualModel := testRunModelWithInput(t, NewChoiceInputModel("prompt", tc.choices, tc.defaultValue), tc.input) + assert.Equal(t, tc.expectedCanceled, actualModel.Canceled()) + assert.Equal(t, tc.expectedValue, actualModel.Value()) + }) + } +} diff --git a/pkg/chezmoibubbles/intinputmodel.go b/pkg/chezmoibubbles/intinputmodel.go new file mode 100644 index 000000000000..20bf462be861 --- /dev/null +++ b/pkg/chezmoibubbles/intinputmodel.go @@ -0,0 +1,77 @@ +package chezmoibubbles + +import ( + "strconv" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type IntInputModel struct { + textInput textinput.Model + defaultValue *int64 + canceled bool +} + +func NewIntInputModel(prompt string, defaultValue *int64) IntInputModel { + textInput := textinput.New() + textInput.Prompt = prompt + "? " + textInput.Placeholder = "int" + if defaultValue != nil { + textInput.Placeholder += ", default " + strconv.FormatInt(*defaultValue, 10) + } + textInput.Validate = func(value string) error { + if value == "" && defaultValue != nil { + return nil + } + if value == "-" { + return nil + } + _, err := strconv.ParseInt(value, 10, 64) + return err + } + textInput.Focus() + return IntInputModel{ + textInput: textInput, + defaultValue: defaultValue, + } +} + +func (m IntInputModel) Canceled() bool { + return m.canceled +} + +func (m IntInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m IntInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + case tea.KeyEnter: + if m.textInput.Value() == "" && m.defaultValue != nil { + m.textInput.SetValue(strconv.FormatInt(*m.defaultValue, 10)) + } + return m, tea.Quit + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m IntInputModel) Value() int64 { + valueStr := m.textInput.Value() + if valueStr == "" && m.defaultValue != nil { + return *m.defaultValue + } + value, _ := strconv.ParseInt(valueStr, 10, 64) + return value +} + +func (m IntInputModel) View() string { + return m.textInput.View() +} diff --git a/pkg/chezmoibubbles/intinputmodel_test.go b/pkg/chezmoibubbles/intinputmodel_test.go new file mode 100644 index 000000000000..72cec120c8ba --- /dev/null +++ b/pkg/chezmoibubbles/intinputmodel_test.go @@ -0,0 +1,60 @@ +package chezmoibubbles + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIntInputModel(t *testing.T) { + for _, tc := range []struct { + name string + defaultValue *int64 + input string + expectedCanceled bool + expectedValue int64 + }{ + { + name: "empty_with_default", + defaultValue: int64Ptr(1), + input: "\r", + expectedValue: 1, + }, + { + name: "cancel_ctrlc", + input: "\x03", + expectedCanceled: true, + }, + { + name: "cancel_esc", + input: "\x1b", + expectedCanceled: true, + }, + { + name: "one_enter", + input: "1\r", + expectedValue: 1, + }, + { + name: "minus_one_enter", + input: "-1\r", + expectedValue: -1, + }, + { + name: "minus_enter", + input: "-\r", + expectedValue: 0, + }, + { + name: "one_invalid_enter", + input: "1a\r", + expectedValue: 1, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualModel := testRunModelWithInput(t, NewIntInputModel("prompt", tc.defaultValue), tc.input) + assert.Equal(t, tc.expectedCanceled, actualModel.Canceled()) + assert.Equal(t, tc.expectedValue, actualModel.Value()) + }) + } +} diff --git a/pkg/chezmoibubbles/passwordinputmodel.go b/pkg/chezmoibubbles/passwordinputmodel.go new file mode 100644 index 000000000000..af219a1bd201 --- /dev/null +++ b/pkg/chezmoibubbles/passwordinputmodel.go @@ -0,0 +1,53 @@ +package chezmoibubbles + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type PasswordInputModel struct { + textInput textinput.Model + canceled bool +} + +func NewPasswordInputModel(prompt string) PasswordInputModel { + textInput := textinput.New() + textInput.Prompt = prompt + textInput.Placeholder = "password" + textInput.EchoMode = textinput.EchoNone + textInput.Focus() + return PasswordInputModel{ + textInput: textInput, + } +} + +func (m PasswordInputModel) Canceled() bool { + return m.canceled +} + +func (m PasswordInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m PasswordInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + case tea.KeyEnter: + return m, tea.Quit + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m PasswordInputModel) Value() string { + return m.textInput.Value() +} + +func (m PasswordInputModel) View() string { + return m.textInput.View() +} diff --git a/pkg/chezmoibubbles/passwordinputmodel_test.go b/pkg/chezmoibubbles/passwordinputmodel_test.go new file mode 100644 index 000000000000..61b6daf32c0a --- /dev/null +++ b/pkg/chezmoibubbles/passwordinputmodel_test.go @@ -0,0 +1,48 @@ +package chezmoibubbles + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPasswordInputModel(t *testing.T) { + for _, tc := range []struct { + name string + input string + expectedCanceled bool + expectedValue string + }{ + { + name: "empty", + input: "\r", + }, + { + name: "cancel_ctrlc", + input: "\x03", + expectedCanceled: true, + }, + { + name: "cancel_esc", + input: "\x1b", + expectedCanceled: true, + }, + { + name: "password_enter", + input: "password\r", + expectedValue: "password", + }, + { + name: "password_ctrlc", + input: "password\x03", + expectedCanceled: true, + expectedValue: "password", + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualModel := testRunModelWithInput(t, NewPasswordInputModel("prompt"), tc.input) + assert.Equal(t, tc.expectedCanceled, actualModel.Canceled()) + assert.Equal(t, tc.expectedValue, actualModel.Value()) + }) + } +} diff --git a/pkg/chezmoibubbles/stringinputmodel.go b/pkg/chezmoibubbles/stringinputmodel.go new file mode 100644 index 000000000000..3c56d77955f4 --- /dev/null +++ b/pkg/chezmoibubbles/stringinputmodel.go @@ -0,0 +1,61 @@ +package chezmoibubbles + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type StringInputModel struct { + textInput textinput.Model + defaultValue *string + canceled bool +} + +func NewStringInputModel(prompt string, defaultValue *string) StringInputModel { + textInput := textinput.New() + textInput.Prompt = prompt + "? " + textInput.Placeholder = "string" + if defaultValue != nil { + textInput.Placeholder += ", default " + *defaultValue + } + textInput.Focus() + return StringInputModel{ + textInput: textInput, + defaultValue: defaultValue, + } +} + +func (m StringInputModel) Canceled() bool { + return m.canceled +} + +func (m StringInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m StringInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + case tea.KeyEnter: + return m, tea.Quit + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m StringInputModel) Value() string { + value := m.textInput.Value() + if value == "" && m.defaultValue != nil { + return *m.defaultValue + } + return value +} + +func (m StringInputModel) View() string { + return m.textInput.View() +} diff --git a/pkg/chezmoibubbles/stringinputmodel_test.go b/pkg/chezmoibubbles/stringinputmodel_test.go new file mode 100644 index 000000000000..7d84ba212ce1 --- /dev/null +++ b/pkg/chezmoibubbles/stringinputmodel_test.go @@ -0,0 +1,55 @@ +package chezmoibubbles + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStringInputModel(t *testing.T) { + for _, tc := range []struct { + name string + defaultValue *string + input string + expectedCanceled bool + expectedValue string + }{ + { + name: "empty", + input: "\r", + }, + { + name: "empty_with_default", + defaultValue: stringPtr("default"), + input: "\r", + expectedValue: "default", + }, + { + name: "cancel_ctrlc", + input: "\x03", + expectedCanceled: true, + }, + { + name: "cancel_esc", + input: "\x1b", + expectedCanceled: true, + }, + { + name: "value_enter", + input: "value\r", + expectedValue: "value", + }, + { + name: "value_ctrlc", + input: "value\x03", + expectedCanceled: true, + expectedValue: "value", + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualModel := testRunModelWithInput(t, NewStringInputModel("prompt", tc.defaultValue), tc.input) + assert.Equal(t, tc.expectedCanceled, actualModel.Canceled()) + assert.Equal(t, tc.expectedValue, actualModel.Value()) + }) + } +} diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index 5c9d57e12cfa..c4a0de5ccca2 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -1852,21 +1852,6 @@ func (c *Config) persistentStateFile() (chezmoi.AbsPath, error) { return defaultConfigFileAbsPath.Dir().Join(persistentStateFileRelPath), nil } -// promptChoice prompts the user for one of choices until a valid choice is made. -func (c *Config) promptChoice(prompt string, choices []string) (string, error) { - promptWithChoices := fmt.Sprintf("%s [%s]? ", prompt, strings.Join(choices, ",")) - abbreviations := chezmoi.UniqueAbbreviations(choices) - for { - line, err := c.readLine(promptWithChoices) - if err != nil { - return "", err - } - if value, ok := abbreviations[strings.TrimSpace(line)]; ok { - return value, nil - } - } -} - // readConfig reads the config file, if it exists. func (c *Config) readConfig() error { viper.SetConfigFile(c.configFileAbsPath.String()) @@ -1886,22 +1871,6 @@ func (c *Config) readConfig() error { return nil } -// readLine reads a line from stdin, trimming leading and trailing whitespace. -func (c *Config) readLine(prompt string) (string, error) { - _, err := c.stdout.Write([]byte(prompt)) - if err != nil { - return "", err - } - if c.bufioReader == nil { - c.bufioReader = bufio.NewReader(c.stdin) - } - line, err := c.bufioReader.ReadString('\n') - if err != nil { - return "", err - } - return strings.TrimSpace(line), nil -} - // run runs name with args in dir. func (c *Config) run(dir chezmoi.AbsPath, name string, args []string) error { cmd := exec.Command(name, args...) diff --git a/pkg/cmd/config_unix.go b/pkg/cmd/config_unix.go index fac6138d7d56..f28cbd8044cb 100644 --- a/pkg/cmd/config_unix.go +++ b/pkg/cmd/config_unix.go @@ -3,44 +3,6 @@ package cmd -import ( - "errors" - "os" - - "go.uber.org/multierr" - "golang.org/x/term" -) - -// readPassword reads a password. -func (c *Config) readPassword(prompt string) (password string, err error) { - if c.noTTY { - password, err = c.readLine(prompt) - return - } - - if c.PINEntry.Command != "" { - return c.readPINEntry(prompt) - } - - var tty *os.File - if tty, err = os.OpenFile("/dev/tty", os.O_RDWR, 0); err != nil { - return - } - defer multierr.AppendInvoke(&err, multierr.Close(tty)) - if _, err = tty.Write([]byte(prompt)); err != nil { - return - } - var passwordBytes []byte - if passwordBytes, err = term.ReadPassword(int(tty.Fd())); err != nil && !errors.Is(err, term.ErrPasteIndicator) { - return - } - if _, err = tty.Write([]byte{'\n'}); err != nil { - return - } - password = string(passwordBytes) - return -} - func (c *Config) windowsVersion() (map[string]any, error) { return nil, nil } diff --git a/pkg/cmd/config_windows.go b/pkg/cmd/config_windows.go index 2066373abe56..7cbf28af3aaf 100644 --- a/pkg/cmd/config_windows.go +++ b/pkg/cmd/config_windows.go @@ -4,49 +4,9 @@ import ( "fmt" "strings" - "go.uber.org/multierr" - "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" - "golang.org/x/term" ) -// readPassword reads a password. -func (c *Config) readPassword(prompt string) (password string, err error) { - if c.noTTY { - password, err = c.readLine(prompt) - return - } - - if c.PINEntry.Command != "" { - return c.readPINEntry(prompt) - } - - var name *uint16 - name, err = windows.UTF16PtrFromString("CONIN$") - if err != nil { - err = fmt.Errorf("windows.UTF16PtrFromString: %w", err) - return - } - var handle windows.Handle - if handle, err = windows.CreateFile(name, windows.GENERIC_READ|windows.GENERIC_WRITE, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, 0, 0); err != nil { - err = fmt.Errorf("windows.CreateFile: %w", err) - return - } - defer func() { - err = multierr.Append(err, windows.CloseHandle(handle)) - }() - //nolint:forbidigo - fmt.Print(prompt) - var passwordBytes []byte - if passwordBytes, err = term.ReadPassword(int(handle)); err != nil { - return - } - //nolint:forbidigo - fmt.Println("") - password = string(passwordBytes) - return -} - func (c *Config) windowsVersion() (map[string]any, error) { registryKey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE) if err != nil { diff --git a/pkg/cmd/initcmd.go b/pkg/cmd/initcmd.go index f63a27dbe538..000f01e5edd8 100644 --- a/pkg/cmd/initcmd.go +++ b/pkg/cmd/initcmd.go @@ -278,7 +278,7 @@ func (c *Config) builtinGitClone(username, url string, workingTreeRawPath chezmo return err } var basicAuth http.BasicAuth - if basicAuth.Username, err = c.readLine(fmt.Sprintf("Username [default %q]? ", username)); err != nil { + if basicAuth.Username, err = c.readString("Username? ", &username); err != nil { return err } if basicAuth.Username == "" { diff --git a/pkg/cmd/inittemplatefuncs.go b/pkg/cmd/inittemplatefuncs.go index 2f43d15106b2..e5314bf97e8a 100644 --- a/pkg/cmd/inittemplatefuncs.go +++ b/pkg/cmd/inittemplatefuncs.go @@ -3,8 +3,6 @@ package cmd import ( "fmt" "os" - "strconv" - "strings" "golang.org/x/term" @@ -29,27 +27,11 @@ func (c *Config) promptBoolInitTemplateFunc(prompt string, args ...bool) bool { return value } - switch len(args) { - case 0: - value, err := chezmoi.ParseBool(c.promptString(prompt)) - if err != nil { - panic(err) - } - return value - case 1: - prompt += " (default " + strconv.FormatBool(args[0]) + ")" - valueStr := c.promptString(prompt) - if valueStr == "" { - return args[0] - } - value, err := chezmoi.ParseBool(valueStr) - if err != nil { - panic(err) - } - return value - default: - panic("unreachable") + value, err := c.promptBool(prompt, args...) + if err != nil { + panic(err) } + return value } func (c *Config) promptBoolOnceInitTemplateFunc(m map[string]any, key, prompt string, args ...bool) bool { @@ -79,27 +61,11 @@ func (c *Config) promptIntInitTemplateFunc(prompt string, args ...int64) int64 { return int64(value) } - switch len(args) { - case 0: - value, err := strconv.ParseInt(c.promptString(prompt), 10, 64) - if err != nil { - panic(err) - } - return value - case 1: - promptStr := prompt + " (default " + strconv.FormatInt(args[0], 10) + ")" - valueStr := c.promptString(promptStr) - if valueStr == "" { - return args[0] - } - value, err := strconv.ParseInt(valueStr, 10, 64) - if err != nil { - panic(err) - } - return value - default: - panic("unreachable") + value, err := c.promptInt(prompt, args...) + if err != nil { + panic(err) } + return value } func (c *Config) promptIntOnceInitTemplateFunc(m map[string]any, key, prompt string, args ...int64) int64 { @@ -129,7 +95,11 @@ func (c *Config) promptStringInitTemplateFunc(prompt string, args ...string) str return value } - return c.promptString(prompt, args...) + value, err := c.promptString(prompt, args...) + if err != nil { + panic(err) + } + return value } func (c *Config) promptStringOnceInitTemplateFunc(m map[string]any, key, prompt string, args ...string) string { @@ -149,31 +119,6 @@ func (c *Config) promptStringOnceInitTemplateFunc(m map[string]any, key, prompt return c.promptStringInitTemplateFunc(prompt, args...) } -func (c *Config) promptString(prompt string, args ...string) string { - switch len(args) { - case 0: - value, err := c.readLine(prompt + "? ") - if err != nil { - panic(err) - } - return strings.TrimSpace(value) - case 1: - defaultStr := strings.TrimSpace(args[0]) - promptStr := prompt + " (default " + strconv.Quote(defaultStr) + ")? " - switch value, err := c.readLine(promptStr); { - case err != nil: - panic(err) - case value == "": - return defaultStr - default: - return strings.TrimSpace(value) - } - default: - err := fmt.Errorf("want 0 or 1 arguments, got %d", len(args)) - panic(err) - } -} - func (c *Config) stdinIsATTYInitTemplateFunc() bool { file, ok := c.stdin.(*os.File) if !ok { diff --git a/pkg/cmd/internaltestcmd.go b/pkg/cmd/internaltestcmd.go index f536c3a4a4e1..ac11cb292f5e 100644 --- a/pkg/cmd/internaltestcmd.go +++ b/pkg/cmd/internaltestcmd.go @@ -1,7 +1,12 @@ package cmd import ( + "strconv" + "strings" + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/v2/pkg/chezmoi" ) func (c *Config) newInternalTestCmd() *cobra.Command { @@ -11,8 +16,41 @@ func (c *Config) newInternalTestCmd() *cobra.Command { Hidden: true, } + internalTestPromptBoolCmd := &cobra.Command{ + Use: "prompt-bool", + Args: cobra.MinimumNArgs(1), + Short: "Run promptBool", + RunE: c.runInternalTestPromptBoolCmd, + } + internalTestCmd.AddCommand(internalTestPromptBoolCmd) + + internalTestPromptChoiceCmd := &cobra.Command{ + Use: "prompt-choice", + Args: cobra.MinimumNArgs(2), + Short: "Run promptChoice", + RunE: c.runInternalTestPromptChoiceCmd, + } + internalTestCmd.AddCommand(internalTestPromptChoiceCmd) + + internalTestPromptIntCmd := &cobra.Command{ + Use: "prompt-int", + Args: cobra.MinimumNArgs(1), + Short: "Run promptInt", + RunE: c.runInternalTestPromptIntCmd, + } + internalTestCmd.AddCommand(internalTestPromptIntCmd) + + internalTestPromptStringCmd := &cobra.Command{ + Use: "prompt-string", + Args: cobra.MinimumNArgs(1), + Short: "Run promptString", + RunE: c.runInternalTestPromptStringCmd, + } + internalTestCmd.AddCommand(internalTestPromptStringCmd) + internalTestReadPasswordCmd := &cobra.Command{ Use: "read-password", + Args: cobra.NoArgs, Short: "Read a password", RunE: c.runInternalTestReadPasswordCmd, } @@ -21,6 +59,54 @@ func (c *Config) newInternalTestCmd() *cobra.Command { return internalTestCmd } +func (c *Config) runInternalTestPromptBoolCmd(cmd *cobra.Command, args []string) error { + boolArgs := make([]bool, 0, len(args)-1) + for _, arg := range args[1:] { + boolArg, err := chezmoi.ParseBool(arg) + if err != nil { + return err + } + boolArgs = append(boolArgs, boolArg) + } + value, err := c.promptBool(args[0], boolArgs...) + if err != nil { + return err + } + return c.writeOutputString(strconv.FormatBool(value) + "\n") +} + +func (c *Config) runInternalTestPromptChoiceCmd(cmd *cobra.Command, args []string) error { + value, err := c.promptChoice(args[0], strings.Split(args[1], ","), args[2:]...) + if err != nil { + return err + } + return c.writeOutputString(value + "\n") +} + +func (c *Config) runInternalTestPromptIntCmd(cmd *cobra.Command, args []string) error { + int64Args := make([]int64, 0, len(args)-1) + for _, arg := range args[1:] { + int64Arg, err := strconv.ParseInt(arg, 10, 64) + if err != nil { + return err + } + int64Args = append(int64Args, int64Arg) + } + value, err := c.promptInt(args[0], int64Args...) + if err != nil { + return err + } + return c.writeOutputString(strconv.FormatInt(value, 10) + "\n") +} + +func (c *Config) runInternalTestPromptStringCmd(cmd *cobra.Command, args []string) error { + value, err := c.promptString(args[0], args[1:]...) + if err != nil { + return err + } + return c.writeOutputString(value + "\n") +} + func (c *Config) runInternalTestReadPasswordCmd(cmd *cobra.Command, args []string) error { password, err := c.readPassword("Password? ") if err != nil { diff --git a/pkg/cmd/prompt.go b/pkg/cmd/prompt.go new file mode 100644 index 000000000000..a6450ecb9cea --- /dev/null +++ b/pkg/cmd/prompt.go @@ -0,0 +1,250 @@ +package cmd + +import ( + "bufio" + "fmt" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/exp/slices" + + "github.com/twpayne/chezmoi/v2/pkg/chezmoi" + "github.com/twpayne/chezmoi/v2/pkg/chezmoibubbles" +) + +// readBool reads a bool. +func (c *Config) readBool(prompt string, defaultValue *bool) (bool, error) { + switch { + case c.noTTY: + fullPrompt := prompt + if defaultValue != nil { + fullPrompt += " (default " + strconv.FormatBool(*defaultValue) + ")" + } + fullPrompt += "? " + for { + valueStr, err := c.readLineRaw(fullPrompt) + if err != nil { + return false, err + } + if valueStr == "" && defaultValue != nil { + return *defaultValue, nil + } + if value, err := chezmoi.ParseBool(valueStr); err == nil { + return value, nil + } + } + default: + initModel := chezmoibubbles.NewBoolInputModel(prompt, defaultValue) + finalModel, err := runCancelableModel(initModel) + if err != nil { + return false, err + } + return finalModel.Value(), nil + } +} + +// readChoice reads a choice. +func (c *Config) readChoice(prompt string, choices []string, defaultValue *string) (string, error) { + switch { + case c.noTTY: + fullPrompt := prompt + " (" + strings.Join(choices, "/") + if defaultValue != nil { + fullPrompt += ", default " + *defaultValue + } + fullPrompt += ")? " + abbreviations := chezmoi.UniqueAbbreviations(choices) + for { + value, err := c.readLineRaw(fullPrompt) + if err != nil { + return "", err + } + if value == "" && defaultValue != nil { + return *defaultValue, nil + } + if value, ok := abbreviations[value]; ok { + return value, nil + } + } + default: + initModel := chezmoibubbles.NewChoiceInputModel(prompt, choices, defaultValue) + finalModel, err := runCancelableModel(initModel) + if err != nil { + return "", err + } + return finalModel.Value(), nil + } +} + +// readInt reads an int. +func (c *Config) readInt(prompt string, defaultValue *int64) (int64, error) { + switch { + case c.noTTY: + fullPrompt := prompt + if defaultValue != nil { + fullPrompt += " (default " + strconv.FormatInt(*defaultValue, 10) + ")" + } + fullPrompt += "? " + for { + valueStr, err := c.readLineRaw(fullPrompt) + if err != nil { + return 0, err + } + if valueStr == "" && defaultValue != nil { + return *defaultValue, nil + } + if value, err := strconv.ParseInt(valueStr, 10, 64); err == nil { + return value, nil + } + } + default: + initModel := chezmoibubbles.NewIntInputModel(prompt, defaultValue) + finalModel, err := runCancelableModel(initModel) + if err != nil { + return 0, err + } + return finalModel.Value(), nil + } +} + +// readLineRaw reads a line, trimming leading and trailing whitespace. +func (c *Config) readLineRaw(prompt string) (string, error) { + _, err := c.stdout.Write([]byte(prompt)) + if err != nil { + return "", err + } + if c.bufioReader == nil { + c.bufioReader = bufio.NewReader(c.stdin) + } + line, err := c.bufioReader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(line), nil +} + +// readPassword reads a password. +func (c *Config) readPassword(prompt string) (string, error) { + switch { + case c.noTTY: + return c.readLineRaw(prompt) + case c.PINEntry.Command != "": + return c.readPINEntry(prompt) + default: + initModel := chezmoibubbles.NewPasswordInputModel(prompt) + finalModel, err := runCancelableModel(initModel) + if err != nil { + return "", err + } + return finalModel.Value(), nil + } +} + +// readString reads a string. +func (c *Config) readString(prompt string, defaultValue *string) (string, error) { + switch { + case c.noTTY: + fullPrompt := prompt + if defaultValue != nil { + fullPrompt += " (default " + strconv.Quote(*defaultValue) + ")" + } + fullPrompt += "? " + value, err := c.readLineRaw(fullPrompt) + if err != nil { + return "", err + } + if value == "" && defaultValue != nil { + return *defaultValue, nil + } + return value, nil + default: + initModel := chezmoibubbles.NewStringInputModel(prompt, defaultValue) + finalModel, err := runCancelableModel(initModel) + if err != nil { + return "", err + } + return strings.TrimSpace(finalModel.Value()), nil + } +} + +func (c *Config) promptBool(prompt string, args ...bool) (bool, error) { + var defaultValue *bool + switch len(args) { + case 0: + // Do nothing. + case 1: + defaultValue = &args[0] + default: + return false, fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + } + return c.readBool(prompt, defaultValue) +} + +// promptChoice prompts the user for one of choices until a valid choice is made. +func (c *Config) promptChoice(prompt string, choices []string, args ...string) (string, error) { + var defaultValue *string + switch len(args) { + case 0: + // Do nothing. + case 1: + if !slices.Contains(choices, args[0]) { + return "", fmt.Errorf("%s: invalid default value", args[0]) + } + defaultValue = &args[0] + default: + return "", fmt.Errorf("want 2 or 3 arguments, got %d", len(args)+2) + } + return c.readChoice(prompt, choices, defaultValue) +} + +func (c *Config) promptInt(prompt string, args ...int64) (int64, error) { + var defaultValue *int64 + switch len(args) { + case 0: + // Do nothing. + case 1: + defaultValue = &args[0] + default: + return 0, fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + } + return c.readInt(prompt, defaultValue) +} + +func (c *Config) promptString(prompt string, args ...string) (string, error) { + var defaultValue *string + switch len(args) { + case 0: + // Do nothing. + case 1: + arg := strings.TrimSpace(args[0]) + defaultValue = &arg + default: + return "", fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + } + return c.readString(prompt, defaultValue) +} + +type cancelableModel interface { + tea.Model + Canceled() bool +} + +//nolint:ireturn,nolintlint +func runCancelableModel[M cancelableModel](initModel M) (M, error) { + switch finalModel, err := runModel(initModel); { + case err != nil: + return finalModel, err + case finalModel.Canceled(): + return finalModel, chezmoi.ExitCodeError(0) + default: + return finalModel, nil + } +} + +//nolint:ireturn,nolintlint +func runModel[M tea.Model](initModel M) (M, error) { + program := tea.NewProgram(initModel) + finalModel, err := program.StartReturningModel() + //nolint:forcetypeassert + return finalModel.(M), err +}