diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 30a158dc9a6..ebda8eda5f6 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -208,7 +208,7 @@ jobs: run: git tag "$TAG_NAME" - name: Authenticate to Azure for code signing if: inputs.environment == 'production' - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 with: client-id: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }} tenant-id: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }} @@ -226,7 +226,7 @@ jobs: run: script/release --local "$TAG_NAME" --platform windows - name: Set up MSBuild id: setupmsbuild - uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce + uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 - name: Build MSI shell: bash env: @@ -414,7 +414,7 @@ jobs: git diff --name-status @{upstream}.. fi - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c + uses: mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769 if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') with: formula-name: gh diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index ff7f0039336..eccf933dd77 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c + uses: mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769 if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') with: formula-name: gh diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index b8eff83890c..98642afafeb 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path" + "path/filepath" "strconv" "strings" "testing" @@ -15,6 +16,7 @@ import ( "github.com/cli/cli/v2/internal/ghcmd" "github.com/cli/go-internal/testscript" + "github.com/MakeNowJust/heredoc" ) func ghMain() int { @@ -224,6 +226,19 @@ func sharedSetup(tsEnv testScriptEnv) func(ts *testscript.Env) error { ts.Setenv("RANDOM_STRING", randomString(10)) + // The sandbox overrides HOME, so git cannot find the user's global + // config. Write a minimal identity so commits inside the sandbox + // don't fail with "Author identity unknown". + gitCfg := filepath.Join(ts.Cd, ".gitconfig") + gitCfgContent := heredoc.Doc(` + [user] + name = GitHub CLI Acceptance Test Runner + email = cli-acceptance-test-runner@github.com + `) + if err := os.WriteFile(gitCfg, []byte(gitCfgContent), 0o644); err != nil { + return fmt.Errorf("writing sandbox .gitconfig: %w", err) + } + ts.Values[keyT] = ts.T() return nil } diff --git a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar index 542579b0aa6..c3717ab2314 100644 --- a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar +++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar @@ -47,4 +47,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Check the PR is indeed created exec gh pr view ${USER}:feature/branch --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar index e263b0351c8..6359672e158 100644 --- a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar +++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar @@ -45,4 +45,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Check the PR is indeed created exec gh pr view ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar index 0974f922590..b51e13d13e1 100644 --- a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar +++ b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar @@ -47,4 +47,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar b/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar index 395fce86a03..b8b1515f530 100644 --- a/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar +++ b/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar @@ -43,4 +43,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar index e0d0c099cd7..cbfc7dcb120 100644 --- a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar @@ -46,4 +46,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar index 51708405d8f..24fb2781736 100644 --- a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar +++ b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar @@ -50,4 +50,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar index ff92f1e2d49..48e8fa6cccd 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar @@ -46,4 +46,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar index 63d3ae2b41e..3781ec925b8 100644 --- a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar @@ -31,4 +31,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${REPO_ID}","name":"${REPO}"},"isCrossRepository":false} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${REPO_ID}","name":"${REPO}","nameWithOwner":"${ORG}/${REPO}"},"isCrossRepository":false} diff --git a/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar index a59171d5899..7c45b1d3756 100644 --- a/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar +++ b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar @@ -44,4 +44,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}","nameWithOwner":"${USER}/${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/workflow/run-download-traversal.txtar b/acceptance/testdata/workflow/run-download-traversal.txtar deleted file mode 100644 index a8a64475216..00000000000 --- a/acceptance/testdata/workflow/run-download-traversal.txtar +++ /dev/null @@ -1,71 +0,0 @@ -# Set up env -env REPO=${SCRIPT_NAME}-${RANDOM_STRING} - -# Use gh as a credential helper -exec gh auth setup-git - -# Create a repository with a file so it has a default branch -exec gh repo create ${ORG}/${REPO} --add-readme --private - -# Defer repo cleanup -defer gh repo delete --yes ${ORG}/${REPO} - -# Clone the repo -exec gh repo clone ${ORG}/${REPO} - -# commit the workflow file -cd ${REPO} -mkdir .github/workflows -mv ../workflow.yml .github/workflows/workflow.yml -exec git add .github/workflows/workflow.yml -exec git commit -m 'Create workflow file' -exec git push -u origin main - -# Sleep because it takes a second for the workflow to register -sleep 1 - -# Check the workflow is indeed created -exec gh workflow list -stdout 'Test Workflow Name' - -# Run the workflow -exec gh workflow run 'Test Workflow Name' - -# It takes some time for a workflow run to register -sleep 10 - -# Get the run ID we want to watch -exec gh run list --json databaseId --jq '.[0].databaseId' -stdout2env RUN_ID - -# Wait for workflow to complete -exec gh run watch ${RUN_ID} --exit-status - -# Download the artifact and see there is an error -! exec gh run download ${RUN_ID} -stderr 'would result in path traversal' - --- workflow.yml -- -# This is a basic workflow to help you get started with Actions - -name: Test Workflow Name - -# Controls when the workflow will run -on: - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel -jobs: - # This workflow contains a single job called "build" - build: - # The type of runner that the job will run on - runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - - run: echo hello > world.txt - - uses: actions/upload-artifact@v4 - with: - name: .. - path: world.txt diff --git a/api/query_builder.go b/api/query_builder.go index c3e1e9ba3a9..9c97e67e997 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -387,7 +387,7 @@ func IssueGraphQL(fields []string) string { case "headRepositoryOwner": q = append(q, `headRepositoryOwner{id,login,...on User{name}}`) case "headRepository": - q = append(q, `headRepository{id,name}`) + q = append(q, `headRepository{id,name,nameWithOwner}`) case "assignees": q = append(q, `assignees(first:100){nodes{id,login,name,databaseId},totalCount}`) case "assignedActors": diff --git a/go.mod b/go.mod index 4a34d400f02..99413bdefa1 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 5fcce09e833..c9cfb3dfc02 100644 --- a/go.sum +++ b/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= diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 2c26a16a0aa..372b0e6705f 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -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) }) diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go new file mode 100644 index 00000000000..c6bec9fb35c --- /dev/null +++ b/internal/prompter/huh_prompter.go @@ -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 +} diff --git a/internal/prompter/huh_prompter_test.go b/internal/prompter/huh_prompter_test.go new file mode 100644 index 00000000000..30ec22551b1 --- /dev/null +++ b/internal/prompter/huh_prompter_test.go @@ -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") + } +} diff --git a/internal/prompter/multi_select_with_search.go b/internal/prompter/multi_select_with_search.go new file mode 100644 index 00000000000..5eeb34d4507 --- /dev/null +++ b/internal/prompter/multi_select_with_search.go @@ -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 +} diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 2bf49eb5877..dcf0e03f121 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -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) diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 48ec0c8fe0c..cdbed20af40 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -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) { diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 22f966ac810..b3bada201fc 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -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()