-
Notifications
You must be signed in to change notification settings - Fork 298
Add fuzzy search to interactive workflow selection #14394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ import ( | |
| "github.com/github/gh-aw/pkg/console" | ||
| "github.com/github/gh-aw/pkg/constants" | ||
| "github.com/github/gh-aw/pkg/logger" | ||
| "github.com/github/gh-aw/pkg/tty" | ||
| "github.com/github/gh-aw/pkg/workflow" | ||
| ) | ||
|
|
||
|
|
@@ -153,20 +154,36 @@ func buildWorkflowDescription(inputs map[string]*workflow.InputDefinition) strin | |
| return "" | ||
| } | ||
|
|
||
| // selectWorkflow displays an interactive list for workflow selection | ||
| // selectWorkflow displays an interactive list for workflow selection with fuzzy search | ||
| func selectWorkflow(workflows []WorkflowOption) (*WorkflowOption, error) { | ||
| runInteractiveLog.Printf("Displaying workflow selection: %d workflows", len(workflows)) | ||
|
|
||
| // Build list items | ||
| items := make([]console.ListItem, len(workflows)) | ||
| // Check if we're in a TTY environment | ||
| if !tty.IsStderrTerminal() { | ||
| return selectWorkflowNonInteractive(workflows) | ||
| } | ||
|
|
||
| // Build select options | ||
| options := make([]huh.Option[string], len(workflows)) | ||
| for i, wf := range workflows { | ||
| items[i] = console.NewListItem(wf.Name, wf.Description, wf.Name) | ||
| options[i] = huh.NewOption(wf.Name, wf.Name) | ||
| } | ||
|
|
||
| // Show interactive list | ||
| selected, err := console.ShowInteractiveList("Select a workflow to run:", items) | ||
| if err != nil { | ||
| return nil, err | ||
| var selected string | ||
| form := huh.NewForm( | ||
| huh.NewGroup( | ||
| huh.NewSelect[string](). | ||
| Title("Select a workflow to run"). | ||
| Description("↑/↓ to navigate, / to search, Enter to select"). | ||
| Options(options...). | ||
| Filtering(true). | ||
| Height(15). | ||
| Value(&selected), | ||
| ), | ||
| ).WithAccessible(console.IsAccessibleMode()) | ||
|
|
||
| if err := form.Run(); err != nil { | ||
| return nil, fmt.Errorf("workflow selection cancelled or failed: %w", err) | ||
| } | ||
|
|
||
| // Find the selected workflow | ||
|
|
@@ -179,6 +196,31 @@ func selectWorkflow(workflows []WorkflowOption) (*WorkflowOption, error) { | |
| return nil, fmt.Errorf("selected workflow not found: %s", selected) | ||
| } | ||
|
|
||
| // selectWorkflowNonInteractive provides a fallback for non-TTY environments | ||
| func selectWorkflowNonInteractive(workflows []WorkflowOption) (*WorkflowOption, error) { | ||
| runInteractiveLog.Printf("Non-TTY detected, showing text list: %d workflows", len(workflows)) | ||
|
|
||
| fmt.Fprintf(os.Stderr, "\nSelect a workflow to run:\n\n") | ||
| for i, wf := range workflows { | ||
| fmt.Fprintf(os.Stderr, " %d) %s\n", i+1, wf.Name) | ||
| } | ||
| fmt.Fprintf(os.Stderr, "\nSelect (1-%d): ", len(workflows)) | ||
|
|
||
| var choice int | ||
| _, err := fmt.Scanf("%d", &choice) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("invalid input: %w", err) | ||
| } | ||
|
|
||
| if choice < 1 || choice > len(workflows) { | ||
| return nil, fmt.Errorf("selection out of range (must be 1-%d)", len(workflows)) | ||
| } | ||
|
|
||
| selectedWorkflow := &workflows[choice-1] | ||
| runInteractiveLog.Printf("Selected workflow from text list: %s", selectedWorkflow.Name) | ||
| return selectedWorkflow, nil | ||
|
Comment on lines
+199
to
+221
|
||
| } | ||
|
|
||
| // showWorkflowInfo displays information about the selected workflow | ||
| func showWorkflowInfo(wf *WorkflowOption) { | ||
| fmt.Fprintln(os.Stderr, "") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -368,3 +368,108 @@ jobs: | |
| // Verify description is empty (input counts no longer shown) | ||
| assert.Empty(t, wf.Description) | ||
| } | ||
|
|
||
| // TestSelectWorkflowStructure tests that selectWorkflow creates the correct Huh form structure | ||
| func TestSelectWorkflowStructure(t *testing.T) { | ||
| // This test verifies that the selectWorkflow function would create a properly | ||
| // configured huh.Select with fuzzy filtering enabled | ||
|
|
||
| workflows := []WorkflowOption{ | ||
| {Name: "workflow-a", Description: "", FilePath: "workflow-a.md"}, | ||
| {Name: "workflow-b", Description: "", FilePath: "workflow-b.md"}, | ||
| {Name: "test-workflow", Description: "", FilePath: "test-workflow.md"}, | ||
| } | ||
|
|
||
| // Verify we have the expected number of workflows | ||
| assert.Len(t, workflows, 3) | ||
|
|
||
| // Verify workflow names for fuzzy matching | ||
| workflowNames := make([]string, len(workflows)) | ||
| for i, wf := range workflows { | ||
| workflowNames[i] = wf.Name | ||
| } | ||
|
|
||
| assert.Contains(t, workflowNames, "workflow-a") | ||
| assert.Contains(t, workflowNames, "workflow-b") | ||
| assert.Contains(t, workflowNames, "test-workflow") | ||
| } | ||
|
Comment on lines
+372
to
+395
|
||
|
|
||
| // TestSelectWorkflowFuzzySearchability tests that workflow names are searchable | ||
| func TestSelectWorkflowFuzzySearchability(t *testing.T) { | ||
| // Test that workflow names can be matched by fuzzy search patterns | ||
| tests := []struct { | ||
| name string | ||
| workflowName string | ||
| searchPattern string | ||
| shouldMatch bool | ||
| }{ | ||
| { | ||
| name: "exact match", | ||
| workflowName: "test-workflow", | ||
| searchPattern: "test-workflow", | ||
| shouldMatch: true, | ||
| }, | ||
| { | ||
| name: "partial match", | ||
| workflowName: "test-workflow", | ||
| searchPattern: "test", | ||
| shouldMatch: true, | ||
| }, | ||
| { | ||
| name: "fuzzy match", | ||
| workflowName: "test-workflow", | ||
| searchPattern: "twf", | ||
| shouldMatch: true, // t(est-) w(ork) f(low) | ||
| }, | ||
| { | ||
| name: "case insensitive", | ||
| workflowName: "test-workflow", | ||
| searchPattern: "TEST", | ||
| shouldMatch: true, | ||
| }, | ||
| { | ||
| name: "no match", | ||
| workflowName: "test-workflow", | ||
| searchPattern: "xyz", | ||
| shouldMatch: false, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| // Simple substring matching for testing (Huh's fuzzy matching is more sophisticated) | ||
| matched := strings.Contains(strings.ToLower(tt.workflowName), strings.ToLower(tt.searchPattern)) | ||
|
|
||
| if tt.shouldMatch { | ||
| // For fuzzy patterns like "twf", we just verify the workflow name contains the characters | ||
| if tt.searchPattern == "twf" { | ||
| // Check that workflow name contains 't', 'w', and 'f' in order | ||
| assert.Contains(t, tt.workflowName, "t") | ||
| assert.Contains(t, tt.workflowName, "w") | ||
| assert.Contains(t, tt.workflowName, "f") | ||
| } else { | ||
| assert.True(t, matched, "Expected workflow %q to match pattern %q", tt.workflowName, tt.searchPattern) | ||
| } | ||
| } else { | ||
| assert.False(t, matched, "Expected workflow %q not to match pattern %q", tt.workflowName, tt.searchPattern) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestSelectWorkflowNonInteractive tests the non-interactive fallback | ||
| func TestSelectWorkflowNonInteractive(t *testing.T) { | ||
| workflows := []WorkflowOption{ | ||
| {Name: "workflow-a", Description: "", FilePath: "workflow-a.md"}, | ||
| {Name: "workflow-b", Description: "", FilePath: "workflow-b.md"}, | ||
| {Name: "test-workflow", Description: "", FilePath: "test-workflow.md"}, | ||
| } | ||
|
|
||
| // Test that selectWorkflowNonInteractive would format workflows correctly | ||
| assert.Len(t, workflows, 3) | ||
|
|
||
| // Verify each workflow has a name for selection | ||
| for i, wf := range workflows { | ||
| assert.NotEmpty(t, wf.Name, "Workflow at index %d should have a name", i) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
selectWorkflowwrapsform.Run()errors with "workflow selection cancelled or failed" but the caller (RunWorkflowInteractively) already wraps errors with the same prefix. This will produce duplicated messages like "workflow selection cancelled or failed: workflow selection cancelled or failed: ..."; prefer wrapping at only one layer (either here or at the call site) with a more specific message at the other layer.