diff --git a/docs/images/test-diff-after-tab.png b/docs/images/test-diff-after-tab.png new file mode 100644 index 0000000..1a9c5d8 Binary files /dev/null and b/docs/images/test-diff-after-tab.png differ diff --git a/docs/images/test-diff-after-tab2.png b/docs/images/test-diff-after-tab2.png new file mode 100644 index 0000000..f05ea91 Binary files /dev/null and b/docs/images/test-diff-after-tab2.png differ diff --git a/docs/images/test-diff-before-tab.png b/docs/images/test-diff-before-tab.png new file mode 100644 index 0000000..e9fad98 Binary files /dev/null and b/docs/images/test-diff-before-tab.png differ diff --git a/docs/images/test-diff.gif b/docs/images/test-diff.gif new file mode 100644 index 0000000..0b83fb5 Binary files /dev/null and b/docs/images/test-diff.gif differ diff --git a/docs/tapes/test-diff.tape b/docs/tapes/test-diff.tape new file mode 100644 index 0000000..9e9ffad --- /dev/null +++ b/docs/tapes/test-diff.tape @@ -0,0 +1,37 @@ +Output docs/images/test-diff.gif + +Set Shell "bash" +Set FontSize 16 +Set Width 800 +Set Height 400 +Set Padding 10 + +Type "./claws" +Enter +Sleep 2s + +# Navigate to ec2 instances (has demo data) +Type ":ec2/instances" +Enter +Sleep 2s + +# Open command mode and type diff with space +Type ":diff " +Sleep 500ms + +Screenshot docs/images/test-diff-before-tab.png + +# Press Tab to complete +Tab +Sleep 500ms + +Screenshot docs/images/test-diff-after-tab.png + +# Press Tab again +Tab +Sleep 500ms + +Screenshot docs/images/test-diff-after-tab2.png + +Type "q" +Sleep 500ms diff --git a/internal/app/app.go b/internal/app/app.go index 19b7500..2ec80d9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -205,7 +205,6 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.width = msg.Width a.height = msg.Height a.help.SetWidth(msg.Width) - a.commandInput.SetWidth(msg.Width) // Update cached styles with new width a.styles = newAppStyles(msg.Width) // Mark warnings ready after first WindowSizeMsg (terminal initialized). diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 5a12895..a6e71c1 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -17,12 +17,18 @@ import ( "github.com/clawscli/claws/internal/ui" ) +const ( + commandInputWidth = 30 + commandInputWidthMax = 50 +) + // CommandInput handles command mode input // commandInputStyles holds cached lipgloss styles for performance type commandInputStyles struct { input lipgloss.Style suggestion lipgloss.Style highlight lipgloss.Style + alias lipgloss.Style } func newCommandInputStyles() commandInputStyles { @@ -30,6 +36,7 @@ func newCommandInputStyles() commandInputStyles { input: ui.InputFieldStyle(), suggestion: ui.DimStyle(), highlight: ui.HighlightStyle(), + alias: ui.NoStyle(), // Normal text, not dimmed } } @@ -41,12 +48,12 @@ type TagCompletionProvider interface { GetTagValues(key string) []string } -// DiffCompletionProvider provides resource names for diff command completion +// DiffCompletionProvider provides resource IDs for diff command completion type DiffCompletionProvider interface { - // GetResourceNames returns all resource names for completion - GetResourceNames() []string - // GetMarkedResourceName returns the marked resource name (empty if none) - GetMarkedResourceName() string + // GetResourceIDs returns all resource IDs for completion + GetResourceIDs() []string + // GetMarkedResourceID returns the marked resource ID (empty if none) + GetMarkedResourceID() string } type CommandInput struct { @@ -69,8 +76,8 @@ func NewCommandInput(ctx context.Context, reg *registry.Registry) *CommandInput ti := textinput.New() ti.Placeholder = "service/resource" ti.Prompt = ":" - ti.CharLimit = 50 - ti.SetWidth(30) + ti.CharLimit = 150 + ti.SetWidth(commandInputWidth) return &CommandInput{ ctx: ctx, @@ -111,7 +118,7 @@ func (c *CommandInput) Update(msg tea.Msg) (tea.Cmd, *NavigateMsg) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { - case "esc": + case "esc", "ctrl+c": c.Deactivate() return nil, nil @@ -121,25 +128,48 @@ func (c *CommandInput) Update(msg tea.Msg) (tea.Cmd, *NavigateMsg) { return cmd, nav case "tab": - // Cycle through suggestions - if len(c.suggestions) > 0 { - c.textInput.SetValue(c.suggestions[c.suggIdx]) - c.suggIdx = (c.suggIdx + 1) % len(c.suggestions) - } else { - // Get fresh suggestions + // Bash-style completion: common prefix first, then cycle + if len(c.suggestions) == 0 { c.updateSuggestions() - if len(c.suggestions) > 0 { - c.textInput.SetValue(c.suggestions[0]) - c.suggIdx = 1 % len(c.suggestions) + } + if len(c.suggestions) > 0 { + current := c.textInput.Value() + prefix := commonPrefix(c.suggestions) + + if len(prefix) > len(current) { + // Expand to common prefix + c.textInput.Reset() + c.textInput.SetValue(prefix) + c.suggIdx = 0 + } else { + // Common prefix = current input, cycle through suggestions + c.textInput.Reset() + c.textInput.SetValue(c.suggestions[c.suggIdx]) + c.suggIdx = (c.suggIdx + 1) % len(c.suggestions) } } return nil, nil case "shift+tab": - // Cycle backward through suggestions + // Bash-style completion: common prefix first, then cycle backward + if len(c.suggestions) == 0 { + c.updateSuggestions() + } if len(c.suggestions) > 0 { - c.suggIdx = (c.suggIdx - 1 + len(c.suggestions)) % len(c.suggestions) - c.textInput.SetValue(c.suggestions[c.suggIdx]) + current := c.textInput.Value() + prefix := commonPrefix(c.suggestions) + + if len(prefix) > len(current) { + // Expand to common prefix + c.textInput.Reset() + c.textInput.SetValue(prefix) + c.suggIdx = 0 + } else { + // Common prefix = current input, cycle backward + c.suggIdx = (c.suggIdx - 1 + len(c.suggestions)) % len(c.suggestions) + c.textInput.Reset() + c.textInput.SetValue(c.suggestions[c.suggIdx]) + } } return nil, nil } @@ -151,6 +181,14 @@ func (c *CommandInput) Update(msg tea.Msg) (tea.Cmd, *NavigateMsg) { // Update suggestions on input change c.updateSuggestions() + // Dynamic width: expand when input exceeds default width + inputLen := len(c.textInput.Value()) + if inputLen > commandInputWidth { + c.textInput.SetWidth(commandInputWidthMax) + } else { + c.textInput.SetWidth(commandInputWidth) + } + return cmd, nil } @@ -166,38 +204,141 @@ func (c *CommandInput) View() string { } s := c.styles - result := s.input.Render(c.textInput.View()) + inputView := s.input.Render(c.textInput.View()) + input := c.textInput.Value() + + // Calculate where Enter will navigate to (alias resolution or prefix match) + destination := c.resolveDestination(input) + + // Build view: destination (highlighted) + other suggestions (dim) + var destView, suggView string + + if destination != "" { + destView = s.alias.Render(" → " + s.highlight.Render(destination)) + } - // Show suggestions - if len(c.suggestions) > 0 && c.textInput.Value() != "" { + // Show other suggestions (white, no highlight) + if len(c.suggestions) > 0 && input != "" { maxShow := 5 - if len(c.suggestions) < maxShow { - maxShow = len(c.suggestions) - } + shown := 0 + var parts []string - suggText := " → " - for i := 0; i < maxShow; i++ { - if i > 0 { - suggText += " | " + for _, sugg := range c.suggestions { + if sugg == destination { + continue // Skip duplicate + } + if shown >= maxShow { + break } - if i == c.suggIdx%len(c.suggestions) { - suggText += s.highlight.Render(c.suggestions[i]) + parts = append(parts, sugg) + shown++ + } + + if len(parts) > 0 { + var suggText string + if destView != "" { + suggText = " | " + strings.Join(parts, " | ") } else { - suggText += c.suggestions[i] + suggText = " → " + strings.Join(parts, " | ") } + if len(c.suggestions) > maxShow+1 { + suggText += " ..." + } + suggView = s.alias.Render(suggText) // alias = NoStyle (white) + } + } + + return lipgloss.JoinHorizontal(lipgloss.Left, inputView, destView, suggView) +} + +// resolveDestination returns where Enter will navigate to for the given input. +// It uses the same logic as executeCommand: alias resolution, then prefix match. +func (c *CommandInput) resolveDestination(input string) string { + if input == "" { + return "" + } + + // Skip non-navigation commands + if strings.HasPrefix(input, "tag ") || strings.HasPrefix(input, "tags ") || + strings.HasPrefix(input, "diff ") || strings.HasPrefix(input, "sort ") || + strings.HasPrefix(input, "theme ") || strings.HasPrefix(input, "autosave ") || + strings.HasPrefix(input, "login ") { + return "" + } + + // Try alias resolution first + if service, resource, ok := c.registry.ResolveAlias(input); ok { + if resource != "" { + return service + "/" + resource + } + return service + } + + // If input contains "/", try ParseServiceResource for full path + if strings.Contains(input, "/") { + if service, resourceType, err := c.registry.ParseServiceResource(input); err == nil { + if resourceType != "" { + return service + "/" + resourceType + } + return service } - if len(c.suggestions) > maxShow { - suggText += " ..." + } + + // Fallback: prefix match on service/alias + if svc, res, ok := c.resolvePrefixMatch(input); ok { + // Only show resource if user explicitly typed "/" + if strings.Contains(input, "/") && res != "" { + return svc + "/" + res } - result += s.suggestion.Render(suggText) + return svc } - return result + return "" } -// SetWidth sets the input width -func (c *CommandInput) SetWidth(width int) { - c.textInput.SetWidth(width - 4) +// resolvePrefixMatch tries prefix match on services and aliases, returns resolved service/resource. +// Returns empty strings if no match found. +func (c *CommandInput) resolvePrefixMatch(input string) (service, resource string, ok bool) { + parts := strings.SplitN(input, "/", 2) + servicePart := parts[0] + resourcePart := "" + if len(parts) > 1 { + resourcePart = parts[1] + } + + // Try prefix match on service name + var matched string + for _, svc := range c.registry.ListServices() { + if strings.HasPrefix(svc, servicePart) { + matched = svc + break + } + } + + // Try prefix match on alias if no service matched + if matched == "" { + for _, alias := range c.registry.GetAliases() { + if strings.HasPrefix(alias, servicePart) { + matched = alias + break + } + } + } + + if matched == "" { + return "", "", false + } + + // Build full path and parse via ParseServiceResource (handles alias resolution) + fullPath := matched + if resourcePart != "" { + fullPath = matched + "/" + resourcePart + } + svc, res, err := c.registry.ParseServiceResource(fullPath) + if err != nil { + return "", "", false + } + return svc, res, true } // SetTagProvider sets the tag completion provider @@ -303,11 +444,11 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { parts := strings.Fields(suffix) if len(parts) == 1 { return func() tea.Msg { - return DiffMsg{LeftName: "", RightName: parts[0]} + return DiffMsg{LeftID: "", RightID: parts[0]} }, nil } else if len(parts) >= 2 { return func() tea.Msg { - return DiffMsg{LeftName: parts[0], RightName: parts[1]} + return DiffMsg{LeftID: parts[0], RightID: parts[1]} }, nil } } @@ -342,38 +483,14 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { } // Fallback: prefix matching for partial input - parts := strings.SplitN(input, "/", 2) - service = parts[0] - resourceType = "" - if len(parts) > 1 { - resourceType = parts[1] - } - - // Try prefix match on service - for _, svc := range c.registry.ListServices() { - if strings.HasPrefix(svc, service) { - service = svc - if resourceType == "" { - resourceType = c.registry.DefaultResource(svc) - } else { - // Prefix match on resource - for _, res := range c.registry.ListResources(svc) { - if strings.HasPrefix(res, resourceType) { - resourceType = res - break - } - } - } - break - } - } - - if _, ok := c.registry.Get(service, resourceType); ok { - browser := NewResourceBrowserWithType(c.ctx, c.registry, service, resourceType) + if svc, res, ok := c.resolvePrefixMatch(input); ok { + browser := NewResourceBrowserWithType(c.ctx, c.registry, svc, res) return nil, &NavigateMsg{View: browser} } - return nil, nil + return func() tea.Msg { + return ErrorMsg{Err: fmt.Errorf("unknown command: %s", input)} + }, nil } func (c *CommandInput) parseSortArgs(args string) tea.Cmd { @@ -501,13 +618,15 @@ func (c *CommandInput) GetSuggestions() []string { } for _, svc := range c.registry.ListServices() { - if strings.HasPrefix(svc, input) { + // Skip if input exactly matches service (already fully typed) + if svc != input && strings.HasPrefix(svc, input) { suggestions = append(suggestions, svc) } } for _, alias := range c.registry.GetAliases() { - if strings.HasPrefix(alias, input) { + // Skip if input exactly matches alias (already fully typed) + if alias != input && strings.HasPrefix(alias, input) { suggestions = append(suggestions, alias) } } @@ -549,33 +668,33 @@ func (c *CommandInput) getDiffSuggestions(args string) []string { return nil } - names := c.diffProvider.GetResourceNames() + ids := c.diffProvider.GetResourceIDs() parts := strings.SplitN(args, " ", 2) if len(parts) == 2 { - firstName := parts[0] + firstID := parts[0] secondPrefix := strings.ToLower(parts[1]) var filtered []string - for _, name := range names { - if name != firstName { - filtered = append(filtered, name) + for _, id := range ids { + if id != firstID { + filtered = append(filtered, id) } } matched := matchNamesWithFallback(filtered, secondPrefix) var suggestions []string - for _, name := range matched { - suggestions = append(suggestions, "diff "+firstName+" "+name) + for _, id := range matched { + suggestions = append(suggestions, "diff "+firstID+" "+id) } return suggestions } prefix := strings.ToLower(args) - matched := matchNamesWithFallback(names, prefix) + matched := matchNamesWithFallback(ids, prefix) var suggestions []string - for _, name := range matched { - suggestions = append(suggestions, "diff "+name) + for _, id := range matched { + suggestions = append(suggestions, "diff "+id) } return suggestions } @@ -621,3 +740,25 @@ func (c *CommandInput) getTagSuggestions(cmdPrefix, tagPart string) []string { return suggestions } + +// commonPrefix returns the longest common prefix of all suggestions. +// Returns empty string if suggestions is empty. +func commonPrefix(suggestions []string) string { + if len(suggestions) == 0 { + return "" + } + if len(suggestions) == 1 { + return suggestions[0] + } + + prefix := suggestions[0] + for _, s := range suggestions[1:] { + for len(prefix) > 0 && !strings.HasPrefix(s, prefix) { + prefix = prefix[:len(prefix)-1] + } + if prefix == "" { + return "" + } + } + return prefix +} diff --git a/internal/view/command_input_test.go b/internal/view/command_input_test.go index 49b23b5..7192c73 100644 --- a/internal/view/command_input_test.go +++ b/internal/view/command_input_test.go @@ -109,8 +109,8 @@ func TestCommandInput_GetSuggestions_Aliases(t *testing.T) { expected []string }{ {"cost", []string{"costexplorer", "cost-explorer"}}, - {"cf", []string{"cf", "cfn"}}, - {"cfn", []string{"cfn"}}, + {"cf", []string{"cfn"}}, // "cf" excluded (exact match) + {"cfn", []string{}}, // "cfn" excluded (exact match) } for _, tt := range tests { @@ -134,14 +134,6 @@ func TestCommandInput_GetSuggestions_Aliases(t *testing.T) { } } -func TestCommandInput_SetWidth(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - ci := NewCommandInput(ctx, reg) - ci.SetWidth(100) -} - func TestCommandInput_Update_Esc(t *testing.T) { ctx := context.Background() reg := registry.New() @@ -228,16 +220,16 @@ func TestCommandInput_QuitCommand(t *testing.T) { // mockDiffProvider for testing getDiffSuggestions type mockDiffProvider struct { - names []string - markedName string + ids []string + markedID string } -func (m *mockDiffProvider) GetResourceNames() []string { - return m.names +func (m *mockDiffProvider) GetResourceIDs() []string { + return m.ids } -func (m *mockDiffProvider) GetMarkedResourceName() string { - return m.markedName +func (m *mockDiffProvider) GetMarkedResourceID() string { + return m.markedID } func TestCommandInput_getDiffSuggestions(t *testing.T) { @@ -258,91 +250,91 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { }, { name: "empty args returns all sorted", - provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, + provider: &mockDiffProvider{ids: []string{"web-server", "db-server", "cache"}}, args: "", want: []string{"diff cache", "diff db-server", "diff web-server"}, }, { name: "prefix match", - provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, + provider: &mockDiffProvider{ids: []string{"web-server", "db-server", "cache"}}, args: "web", want: []string{"diff web-server"}, }, { name: "prefix match multiple sorted", - provider: &mockDiffProvider{names: []string{"web-server", "web-api", "db-server"}}, + provider: &mockDiffProvider{ids: []string{"web-server", "web-api", "db-server"}}, args: "web", want: []string{"diff web-api", "diff web-server"}, }, { name: "fuzzy fallback when no prefix sorted", - provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, + provider: &mockDiffProvider{ids: []string{"web-server", "db-server", "cache"}}, args: "server", want: []string{"diff db-server", "diff web-server"}, }, { name: "fuzzy match pattern", - provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, + provider: &mockDiffProvider{ids: []string{"web-server", "db-server", "cache"}}, args: "wsr", want: []string{"diff web-server"}, }, { name: "case insensitive prefix", - provider: &mockDiffProvider{names: []string{"Web-Server", "DB-Server", "Cache"}}, + provider: &mockDiffProvider{ids: []string{"Web-Server", "DB-Server", "Cache"}}, args: "WEB", want: []string{"diff Web-Server"}, }, { name: "case insensitive fuzzy sorted", - provider: &mockDiffProvider{names: []string{"Web-Server", "DB-Server", "Cache"}}, + provider: &mockDiffProvider{ids: []string{"Web-Server", "DB-Server", "Cache"}}, args: "SERVER", want: []string{"diff DB-Server", "diff Web-Server"}, }, { name: "no match returns empty", - provider: &mockDiffProvider{names: []string{"web-server", "db-server"}}, + provider: &mockDiffProvider{ids: []string{"web-server", "db-server"}}, args: "xyz", want: nil, }, { name: "second name completion excludes first sorted", - provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, + provider: &mockDiffProvider{ids: []string{"web-server", "db-server", "cache"}}, args: "web-server ", want: []string{"diff web-server cache", "diff web-server db-server"}, }, { name: "second name with prefix", - provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, + provider: &mockDiffProvider{ids: []string{"web-server", "db-server", "cache"}}, args: "web-server db", want: []string{"diff web-server db-server"}, }, { name: "second name fuzzy fallback", - provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, + provider: &mockDiffProvider{ids: []string{"web-server", "db-server", "cache"}}, args: "web-server sr", want: []string{"diff web-server db-server"}, }, { name: "second name no match", - provider: &mockDiffProvider{names: []string{"web-server", "db-server"}}, + provider: &mockDiffProvider{ids: []string{"web-server", "db-server"}}, args: "web-server xyz", want: nil, }, { name: "empty names list", - provider: &mockDiffProvider{names: []string{}}, + provider: &mockDiffProvider{ids: []string{}}, args: "", want: nil, }, { name: "single resource for first", - provider: &mockDiffProvider{names: []string{"only-one"}}, + provider: &mockDiffProvider{ids: []string{"only-one"}}, args: "", want: []string{"diff only-one"}, }, { name: "single resource for second - no suggestions", - provider: &mockDiffProvider{names: []string{"only-one"}}, + provider: &mockDiffProvider{ids: []string{"only-one"}}, args: "only-one ", want: nil, }, @@ -374,6 +366,103 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { } } +func TestCommandInput_DiffTabCompletion(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + ci := NewCommandInput(ctx, reg) + ci.SetDiffProvider(&mockDiffProvider{ids: []string{"i-123", "i-456", "i-789"}}) + ci.Activate() + + // Type "diff " + ci.textInput.SetValue("diff ") + ci.updateSuggestions() + + // Verify suggestions are generated + if len(ci.suggestions) != 3 { + t.Fatalf("Expected 3 suggestions, got %d: %v", len(ci.suggestions), ci.suggestions) + } + + // Verify suggestions have correct format + expected := []string{"diff i-123", "diff i-456", "diff i-789"} + for i, want := range expected { + if ci.suggestions[i] != want { + t.Errorf("suggestions[%d] = %q, want %q", i, ci.suggestions[i], want) + } + } + + // Press Tab - bash-style: first expand to common prefix "diff i-" + ci.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + got := ci.textInput.Value() + if got != "diff i-" { + t.Errorf("After 1st Tab, textInput.Value() = %q, want %q (common prefix)", got, "diff i-") + } + + // Press Tab again - now cycle to first suggestion + ci.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + got = ci.textInput.Value() + if got != "diff i-123" { + t.Errorf("After 2nd Tab, textInput.Value() = %q, want %q", got, "diff i-123") + } + + // Press Tab again - cycle to second suggestion + ci.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + got = ci.textInput.Value() + if got != "diff i-456" { + t.Errorf("After 3rd Tab, textInput.Value() = %q, want %q", got, "diff i-456") + } + + // Check View() contains "diff" + view := ci.View() + if !contains(view, "diff") { + t.Errorf("View() should contain 'diff', got: %q", view) + } +} + +func TestCommandInput_DiffTabCompletion_RealKeyInput(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + ci := NewCommandInput(ctx, reg) + ci.SetDiffProvider(&mockDiffProvider{ids: []string{"i-123", "i-456", "i-789"}}) + ci.Activate() + + // Type "diff " character by character (simulating real input) + for _, r := range "diff " { + ci.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + + t.Logf("After typing 'diff ': Value=%q, suggestions=%v", ci.textInput.Value(), ci.suggestions) + + // Press Tab - bash-style: first expand to common prefix "diff i-" + ci.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + + got := ci.textInput.Value() + if got != "diff i-" { + t.Errorf("After 1st Tab, textInput.Value() = %q, want %q (common prefix)", got, "diff i-") + } + + // Press Tab again - now cycle to first suggestion + ci.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + got = ci.textInput.Value() + if got != "diff i-123" { + t.Errorf("After 2nd Tab, textInput.Value() = %q, want %q", got, "diff i-123") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr)) +} + +func containsAt(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + func TestCommandInput_ClearHistoryCommand(t *testing.T) { ctx := context.Background() reg := registry.New() @@ -457,3 +546,147 @@ func TestCommandInput_ServicesCommand(t *testing.T) { }) } } + +func TestCommandInput_CtrlCExit(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + ci := NewCommandInput(ctx, reg) + ci.Activate() + + if !ci.IsActive() { + t.Fatal("Expected command input to be active") + } + + // Press Ctrl+C + ci.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + + if ci.IsActive() { + t.Error("Expected Ctrl+C to deactivate command input") + } +} + +func TestCommandInput_AliasResolutionInView(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + ci := NewCommandInput(ctx, reg) + ci.Activate() + + // "sq" is an alias for "service-quotas" + ci.textInput.SetValue("sq") + ci.updateSuggestions() + + view := ci.View() + + // View should contain the resolved alias + if !contains(view, "service-quotas") { + t.Errorf("View should contain resolved alias 'service-quotas', got: %q", view) + } +} + +func TestCommandInput_DynamicWidth(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + ci := NewCommandInput(ctx, reg) + ci.Activate() + + // Short input - default width + ci.textInput.SetValue("ec2") + ci.Update(tea.KeyPressMsg{Code: '2', Text: "2"}) + + // Long input - expanded width + longInput := "diff i-0123456789abcdef0 i-fedcba9876543210" + ci.textInput.SetValue(longInput) + ci.Update(tea.KeyPressMsg{Code: '0', Text: "0"}) + + // Just verify no panic with long input + view := ci.View() + if view == "" { + t.Error("Expected non-empty view for long input") + } +} + +func TestCommonPrefix(t *testing.T) { + tests := []struct { + name string + suggestions []string + want string + }{ + {"empty", []string{}, ""}, + {"single", []string{"ec2"}, "ec2"}, + {"exact match", []string{"ec2", "ec2"}, "ec2"}, + {"common prefix", []string{"saaa", "saab", "saba"}, "sa"}, + {"no common", []string{"abc", "xyz"}, ""}, + {"full prefix", []string{"ec2", "ec2/instances"}, "ec2"}, + {"different lengths", []string{"cloudformation", "cloudfront", "cloudwatch"}, "cloud"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := commonPrefix(tt.suggestions) + if got != tt.want { + t.Errorf("commonPrefix(%v) = %q, want %q", tt.suggestions, got, tt.want) + } + }) + } +} + +func TestCommandInput_BashStyleTabCompletion(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + ci := NewCommandInput(ctx, reg) + ci.Activate() + + // Type "cloud" - multiple matches with common prefix "cloud" + ci.textInput.SetValue("cloud") + ci.updateSuggestions() + + // Should have multiple suggestions starting with "cloud" + if len(ci.suggestions) < 2 { + t.Skipf("Need multiple cloud* services for this test, got %d", len(ci.suggestions)) + } + + // First Tab: should expand to common prefix (might be "cloud" itself if that's the max) + ci.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + afterFirstTab := ci.textInput.Value() + + // Common prefix should be >= original input + if len(afterFirstTab) < len("cloud") { + t.Errorf("After first Tab, value %q is shorter than input 'cloud'", afterFirstTab) + } + + // If common prefix == input, second Tab should cycle to first suggestion + if afterFirstTab == "cloud" { + ci.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + afterSecondTab := ci.textInput.Value() + if afterSecondTab == "cloud" { + t.Errorf("After second Tab, value should have cycled to a suggestion") + } + } +} + +func TestCommandInput_TabCompletionSingleMatch(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + ci := NewCommandInput(ctx, reg) + ci.Activate() + + // Type something that matches only one service + ci.textInput.SetValue("bedroc") + ci.updateSuggestions() + + if len(ci.suggestions) != 1 { + t.Skipf("Expected exactly 1 suggestion for 'bedroc', got %d: %v", len(ci.suggestions), ci.suggestions) + } + + // Tab should complete directly to the single match + ci.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + got := ci.textInput.Value() + if got != "bedrock" { + t.Errorf("After Tab with single match, got %q, want 'bedrock'", got) + } +} diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index 3081054..6106ba1 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -436,19 +436,19 @@ func (r *ResourceBrowser) GetTagValues(key string) []string { return values } -// GetResourceNames implements DiffCompletionProvider -func (r *ResourceBrowser) GetResourceNames() []string { - names := make([]string, 0, len(r.filtered)) +// GetResourceIDs implements DiffCompletionProvider +func (r *ResourceBrowser) GetResourceIDs() []string { + ids := make([]string, 0, len(r.filtered)) for _, res := range r.filtered { - names = append(names, res.GetName()) + ids = append(ids, res.GetID()) } - return names + return ids } -// GetMarkedResourceName implements DiffCompletionProvider -func (r *ResourceBrowser) GetMarkedResourceName() string { +// GetMarkedResourceID implements DiffCompletionProvider +func (r *ResourceBrowser) GetMarkedResourceID() string { if r.markedResource == nil { return "" } - return r.markedResource.GetName() + return r.markedResource.GetID() } diff --git a/internal/view/resource_browser_update.go b/internal/view/resource_browser_update.go index 2cd5a1c..3025225 100644 --- a/internal/view/resource_browser_update.go +++ b/internal/view/resource_browser_update.go @@ -118,8 +118,9 @@ func (r *ResourceBrowser) handleTagFilterMsg(msg TagFilterMsg) (tea.Model, tea.C func (r *ResourceBrowser) handleDiffMsg(msg DiffMsg) (tea.Model, tea.Cmd) { var leftRes, rightRes dao.Resource + // Match by ID (from GetResourceIDs) for _, res := range r.filtered { - if res.GetName() == msg.RightName { + if res.GetID() == msg.RightID { rightRes = res break } @@ -128,13 +129,13 @@ func (r *ResourceBrowser) handleDiffMsg(msg DiffMsg) (tea.Model, tea.Cmd) { return r, nil } - if msg.LeftName == "" { + if msg.LeftID == "" { if len(r.filtered) > 0 && r.tc.Cursor() < len(r.filtered) { leftRes = r.filtered[r.tc.Cursor()] } } else { for _, res := range r.filtered { - if res.GetName() == msg.LeftName { + if res.GetID() == msg.LeftID { leftRes = res break } diff --git a/internal/view/view.go b/internal/view/view.go index b6b910f..31b2902 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -87,10 +87,10 @@ type TagFilterMsg struct { } // DiffMsg tells the current view to show diff between resources -// If LeftName is empty, use current cursor row as left resource +// If LeftID is empty, use current cursor row as left resource type DiffMsg struct { - LeftName string // Name of left resource (empty = current row) - RightName string // Name of right resource + LeftID string // ID of left resource (empty = current row) + RightID string // ID of right resource } // ClearHistoryMsg tells the app to clear the navigation stack