From 59e457f193baed639d1f0b2fd4a805b10d032e25 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 04:27:03 +0000 Subject: [PATCH 01/15] feat: add alias completion in command mode (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include aliases in GetSuggestions() so :cost+Tab suggests cost-explorer. Self-referential aliases (sfn→sfn) excluded from suggestions. --- internal/registry/registry.go | 16 ++++++++++++ internal/registry/registry_test.go | 32 +++++++++++++++++++++++ internal/view/command_input.go | 6 +++++ internal/view/command_input_test.go | 40 +++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 50c18bf7..fbeeb811 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -297,6 +297,22 @@ func (r *Registry) GetAliasesForService(service string) []string { return aliases } +// GetAliases returns all aliases (excluding self-referential ones like "sfn" -> "sfn") +func (r *Registry) GetAliases() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + var aliases []string + for alias, target := range r.aliases { + // Exclude self-referential aliases (alias == target) + if alias != target { + aliases = append(aliases, alias) + } + } + slices.Sort(aliases) + return aliases +} + // RegisterCustom registers a custom (hand-written) implementation // Custom implementations take priority over generated ones func (r *Registry) RegisterCustom(service, resource string, entry Entry) { diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index c5c135da..6779c503 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -259,6 +259,38 @@ func TestRegistry_GetAliasesForService_WithResourceAlias(t *testing.T) { } } +func TestRegistry_GetAliases(t *testing.T) { + reg := New() + aliases := reg.GetAliases() + + if len(aliases) == 0 { + t.Fatal("GetAliases() should return aliases") + } + + aliasMap := make(map[string]bool) + for _, a := range aliases { + aliasMap[a] = true + } + + for _, expected := range []string{"cfn", "cf", "sg", "cost-explorer"} { + if !aliasMap[expected] { + t.Errorf("GetAliases() should include %q", expected) + } + } +} + +func TestRegistry_GetAliases_ExcludesSelfReferential(t *testing.T) { + reg := New() + aliases := reg.GetAliases() + + for _, alias := range aliases { + resolved, _, found := reg.ResolveAlias(alias) + if found && alias == resolved { + t.Errorf("GetAliases() should exclude self-referential alias %q", alias) + } + } +} + func TestRegistry_GetDAO_NotRegistered(t *testing.T) { reg := New() diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 06db304f..7c50ff63 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -462,6 +462,12 @@ func (c *CommandInput) GetSuggestions() []string { suggestions = append(suggestions, svc) } } + + for _, alias := range c.registry.GetAliases() { + if strings.HasPrefix(alias, input) { + suggestions = append(suggestions, alias) + } + } } return suggestions diff --git a/internal/view/command_input_test.go b/internal/view/command_input_test.go index 197f952b..3c606b0c 100644 --- a/internal/view/command_input_test.go +++ b/internal/view/command_input_test.go @@ -94,6 +94,46 @@ func TestCommandInput_GetSuggestions(t *testing.T) { } } +func TestCommandInput_GetSuggestions_Aliases(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + reg.RegisterCustom("costexplorer", "costs", registry.Entry{}) + reg.RegisterCustom("cloudformation", "stacks", registry.Entry{}) + + ci := NewCommandInput(ctx, reg) + ci.Activate() + + tests := []struct { + input string + expected []string + }{ + {"cost", []string{"costexplorer", "cost-explorer"}}, + {"cf", []string{"cf", "cfn"}}, + {"cfn", []string{"cfn"}}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + ci.textInput.SetValue(tt.input) + suggestions := ci.GetSuggestions() + + for _, exp := range tt.expected { + found := false + for _, s := range suggestions { + if s == exp { + found = true + break + } + } + if !found { + t.Errorf("Expected %q in suggestions for %q, got %v", exp, tt.input, suggestions) + } + } + }) + } +} + func TestCommandInput_SetWidth(t *testing.T) { ctx := context.Background() reg := registry.New() From fdcaecf8148b22cb50f9421c82b8afcb352dcd30 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 07:11:04 +0000 Subject: [PATCH 02/15] refactor: use prefix+fuzzy for diff completion --- internal/view/command_input.go | 57 +++++++++++++++++++++-------- internal/view/command_input_test.go | 36 ++++++++++++++++-- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 7c50ff63..9bf001f9 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -473,39 +473,66 @@ func (c *CommandInput) GetSuggestions() []string { return suggestions } -// getDiffSuggestions returns resource name suggestions for diff command -// Supports: :diff and :diff func (c *CommandInput) getDiffSuggestions(args string) []string { if c.diffProvider == nil { return nil } - var suggestions []string names := c.diffProvider.GetResourceNames() - - // Check if we're completing the second name (has space after first name) parts := strings.SplitN(args, " ", 2) + if len(parts) == 2 { - // Completing second name: "diff name1 " firstName := parts[0] secondPrefix := strings.ToLower(parts[1]) + + var filtered []string for _, name := range names { - if name != firstName && (secondPrefix == "" || strings.Contains(strings.ToLower(name), secondPrefix)) { - suggestions = append(suggestions, "diff "+firstName+" "+name) + if name != firstName { + filtered = append(filtered, name) } } - } else { - // Completing first name: "diff " - prefix := strings.ToLower(args) - for _, name := range names { - if prefix == "" || strings.Contains(strings.ToLower(name), prefix) { - suggestions = append(suggestions, "diff "+name) - } + + matched := matchNamesWithFallback(filtered, secondPrefix) + var suggestions []string + for _, name := range matched { + suggestions = append(suggestions, "diff "+firstName+" "+name) } + return suggestions + } + + prefix := strings.ToLower(args) + matched := matchNamesWithFallback(names, prefix) + var suggestions []string + for _, name := range matched { + suggestions = append(suggestions, "diff "+name) } return suggestions } +func matchNamesWithFallback(names []string, pattern string) []string { + if pattern == "" { + return names + } + + var prefixMatches []string + for _, name := range names { + if strings.HasPrefix(strings.ToLower(name), pattern) { + prefixMatches = append(prefixMatches, name) + } + } + if len(prefixMatches) > 0 { + return prefixMatches + } + + var fuzzyMatches []string + for _, name := range names { + if fuzzyMatch(name, pattern) { + fuzzyMatches = append(fuzzyMatches, name) + } + } + return fuzzyMatches +} + // getTagSuggestions returns tag key/value suggestions with command prefix func (c *CommandInput) getTagSuggestions(cmdPrefix, tagPart string) []string { if c.tagProvider == nil { diff --git a/internal/view/command_input_test.go b/internal/view/command_input_test.go index 3c606b0c..dc193deb 100644 --- a/internal/view/command_input_test.go +++ b/internal/view/command_input_test.go @@ -267,13 +267,37 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { want: []string{"diff web-server", "diff db-server", "diff cache"}, }, { - name: "first name prefix filter", + name: "prefix match", + provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, + args: "web", + want: []string{"diff web-server"}, + }, + { + name: "prefix match multiple", + provider: &mockDiffProvider{names: []string{"web-server", "web-api", "db-server"}}, + args: "web", + want: []string{"diff web-server", "diff web-api"}, + }, + { + name: "fuzzy fallback when no prefix", provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, args: "server", want: []string{"diff web-server", "diff db-server"}, }, { - name: "case insensitive match", + name: "fuzzy match pattern", + provider: &mockDiffProvider{names: []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"}}, + args: "WEB", + want: []string{"diff Web-Server"}, + }, + { + name: "case insensitive fuzzy", provider: &mockDiffProvider{names: []string{"Web-Server", "DB-Server", "Cache"}}, args: "SERVER", want: []string{"diff Web-Server", "diff DB-Server"}, @@ -296,6 +320,12 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { 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"}}, + args: "web-server sr", + want: []string{"diff web-server db-server"}, + }, { name: "second name no match", provider: &mockDiffProvider{names: []string{"web-server", "db-server"}}, @@ -318,7 +348,7 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { name: "single resource for second - no suggestions", provider: &mockDiffProvider{names: []string{"only-one"}}, args: "only-one ", - want: nil, // can't diff with self + want: nil, }, } From baab12a64c2e892a355c84449451fb606774c5a4 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 07:18:54 +0000 Subject: [PATCH 03/15] refactor: use CutPrefix and cache GetAliases --- internal/registry/registry.go | 28 +++++++++++++++++----------- internal/view/command_input.go | 33 ++++++++++++--------------------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/internal/registry/registry.go b/internal/registry/registry.go index fbeeb811..6cc570e0 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -53,6 +53,10 @@ type Registry struct { aliases map[string]string // alias -> service name or service/resource displayNames map[string]string // service -> display name for UI categories []ServiceCategory // ordered list of service categories + + // Cached computed values (aliases are immutable after init) + aliasListOnce sync.Once + aliasListCache []string } // New creates a new Registry @@ -299,18 +303,20 @@ func (r *Registry) GetAliasesForService(service string) []string { // GetAliases returns all aliases (excluding self-referential ones like "sfn" -> "sfn") func (r *Registry) GetAliases() []string { - r.mu.RLock() - defer r.mu.RUnlock() - - var aliases []string - for alias, target := range r.aliases { - // Exclude self-referential aliases (alias == target) - if alias != target { - aliases = append(aliases, alias) + r.aliasListOnce.Do(func() { + r.mu.RLock() + defer r.mu.RUnlock() + + var aliases []string + for alias, target := range r.aliases { + if alias != target { + aliases = append(aliases, alias) + } } - } - slices.Sort(aliases) - return aliases + slices.Sort(aliases) + r.aliasListCache = aliases + }) + return r.aliasListCache } // RegisterCustom registers a custom (hand-written) implementation diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 9bf001f9..16263d4e 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -235,8 +235,8 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { if input == "login" || strings.HasPrefix(input, "login ") { profileName := "claws-login" - if strings.HasPrefix(input, "login ") { - if name := strings.TrimSpace(strings.TrimPrefix(input, "login ")); name != "" { + if suffix, ok := strings.CutPrefix(input, "login "); ok { + if name := strings.TrimSpace(suffix); name != "" { if !config.IsValidProfileName(name) { return func() tea.Msg { return ErrorMsg{Err: fmt.Errorf("invalid profile name: %s", name)} @@ -261,30 +261,21 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { } // Handle tag command: :tag - filter current view by tag - if input == "tag" || strings.HasPrefix(input, "tag ") { - tagFilter := "" - if strings.HasPrefix(input, "tag ") { - tagFilter = strings.TrimPrefix(input, "tag ") - } + if tagFilter, ok := strings.CutPrefix(input, "tag "); ok || input == "tag" { return func() tea.Msg { return TagFilterMsg{Filter: tagFilter} }, nil } // Handle tags command: :tags, :tags - cross-service tag search via Tagging API - if input == "tags" || strings.HasPrefix(input, "tags ") { - tagFilter := "" - if strings.HasPrefix(input, "tags ") { - tagFilter = strings.TrimPrefix(input, "tags ") - } + if tagFilter, ok := strings.CutPrefix(input, "tags "); ok || input == "tags" { browser := NewTagSearchView(c.ctx, c.registry, tagFilter) return nil, &NavigateMsg{View: browser} } // Handle diff command: :diff or :diff - if strings.HasPrefix(input, "diff ") { - args := strings.TrimSpace(strings.TrimPrefix(input, "diff ")) - parts := strings.Fields(args) + if suffix, ok := strings.CutPrefix(input, "diff "); ok { + parts := strings.Fields(suffix) if len(parts) == 1 { // :diff - compare current row with named resource return func() tea.Msg { @@ -393,18 +384,18 @@ func (c *CommandInput) GetSuggestions() []string { var suggestions []string // Handle :tag command completion - if strings.HasPrefix(input, "tag ") { - return c.getTagSuggestions("tag ", strings.TrimPrefix(input, "tag ")) + if suffix, ok := strings.CutPrefix(input, "tag "); ok { + return c.getTagSuggestions("tag ", suffix) } // Handle :tags command completion (same as :tag) - if strings.HasPrefix(input, "tags ") { - return c.getTagSuggestions("tags ", strings.TrimPrefix(input, "tags ")) + if suffix, ok := strings.CutPrefix(input, "tags "); ok { + return c.getTagSuggestions("tags ", suffix) } // Handle :diff command completion - if strings.HasPrefix(input, "diff ") { - return c.getDiffSuggestions(strings.TrimPrefix(input, "diff ")) + if suffix, ok := strings.CutPrefix(input, "diff "); ok { + return c.getDiffSuggestions(suffix) } if strings.Contains(input, "/") { From 5621def067e4290c6e991a2a10d9df6bf9e8dc32 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 07:29:23 +0000 Subject: [PATCH 04/15] refactor: extract match functions to shared module - Move fuzzyMatch and matchNamesWithFallback to match.go - Make :tag/:tags clear behavior explicit - Sort suggestions alphabetically (services + aliases) --- internal/view/command_input.go | 44 ++++------ internal/view/match.go | 41 +++++++++ internal/view/match_test.go | 103 +++++++++++++++++++++++ internal/view/resource_browser_filter.go | 12 --- internal/view/service_browser_test.go | 29 ------- 5 files changed, 160 insertions(+), 69 deletions(-) create mode 100644 internal/view/match.go create mode 100644 internal/view/match_test.go diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 16263d4e..91ae3a91 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -3,6 +3,7 @@ package view import ( "context" "fmt" + "slices" "strings" "charm.land/bubbles/v2/textinput" @@ -260,15 +261,24 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { }), nil } - // Handle tag command: :tag - filter current view by tag - if tagFilter, ok := strings.CutPrefix(input, "tag "); ok || input == "tag" { + // Handle tag command: :tag (clear) or :tag (filter by tag) + if input == "tag" { + return func() tea.Msg { + return TagFilterMsg{Filter: ""} + }, nil + } + if tagFilter, ok := strings.CutPrefix(input, "tag "); ok { return func() tea.Msg { return TagFilterMsg{Filter: tagFilter} }, nil } - // Handle tags command: :tags, :tags - cross-service tag search via Tagging API - if tagFilter, ok := strings.CutPrefix(input, "tags "); ok || input == "tags" { + // Handle tags command: :tags (all) or :tags (cross-service tag search) + if input == "tags" { + browser := NewTagSearchView(c.ctx, c.registry, "") + return nil, &NavigateMsg{View: browser} + } + if tagFilter, ok := strings.CutPrefix(input, "tags "); ok { browser := NewTagSearchView(c.ctx, c.registry, tagFilter) return nil, &NavigateMsg{View: browser} } @@ -459,6 +469,8 @@ func (c *CommandInput) GetSuggestions() []string { suggestions = append(suggestions, alias) } } + + slices.Sort(suggestions) } return suggestions @@ -500,30 +512,6 @@ func (c *CommandInput) getDiffSuggestions(args string) []string { return suggestions } -func matchNamesWithFallback(names []string, pattern string) []string { - if pattern == "" { - return names - } - - var prefixMatches []string - for _, name := range names { - if strings.HasPrefix(strings.ToLower(name), pattern) { - prefixMatches = append(prefixMatches, name) - } - } - if len(prefixMatches) > 0 { - return prefixMatches - } - - var fuzzyMatches []string - for _, name := range names { - if fuzzyMatch(name, pattern) { - fuzzyMatches = append(fuzzyMatches, name) - } - } - return fuzzyMatches -} - // getTagSuggestions returns tag key/value suggestions with command prefix func (c *CommandInput) getTagSuggestions(cmdPrefix, tagPart string) []string { if c.tagProvider == nil { diff --git a/internal/view/match.go b/internal/view/match.go new file mode 100644 index 00000000..a8ac3778 --- /dev/null +++ b/internal/view/match.go @@ -0,0 +1,41 @@ +package view + +import "strings" + +// fuzzyMatch checks if pattern characters appear in order in str (case insensitive) +func fuzzyMatch(str, pattern string) bool { + str = strings.ToLower(str) + pi := 0 + for i := 0; i < len(str) && pi < len(pattern); i++ { + if str[i] == pattern[pi] { + pi++ + } + } + return pi == len(pattern) +} + +// matchNamesWithFallback returns names matching the pattern. +// It first tries prefix matching, then falls back to fuzzy matching if no prefix matches. +func matchNamesWithFallback(names []string, pattern string) []string { + if pattern == "" { + return names + } + + var prefixMatches []string + for _, name := range names { + if strings.HasPrefix(strings.ToLower(name), pattern) { + prefixMatches = append(prefixMatches, name) + } + } + if len(prefixMatches) > 0 { + return prefixMatches + } + + var fuzzyMatches []string + for _, name := range names { + if fuzzyMatch(name, pattern) { + fuzzyMatches = append(fuzzyMatches, name) + } + } + return fuzzyMatches +} diff --git a/internal/view/match_test.go b/internal/view/match_test.go new file mode 100644 index 00000000..65d4a2e2 --- /dev/null +++ b/internal/view/match_test.go @@ -0,0 +1,103 @@ +package view + +import ( + "slices" + "testing" +) + +func TestFuzzyMatch(t *testing.T) { + tests := []struct { + str string + pattern string + want bool + }{ + {"AgentCoreStackdev", "agecrstdev", true}, + {"AgentCoreStackdev", "agent", true}, + {"AgentCoreStackdev", "acd", true}, + {"AgentCoreStackdev", "xyz", false}, + {"AgentCoreStackdev", "deva", false}, + {"i-1234567890abcdef0", "i1234", true}, + {"i-1234567890abcdef0", "abcdef", true}, + {"production", "prod", true}, + {"production", "pdn", true}, + {"", "a", false}, + {"abc", "", true}, + } + + for _, tt := range tests { + t.Run(tt.str+"_"+tt.pattern, func(t *testing.T) { + got := fuzzyMatch(tt.str, tt.pattern) + if got != tt.want { + t.Errorf("fuzzyMatch(%q, %q) = %v, want %v", tt.str, tt.pattern, got, tt.want) + } + }) + } +} + +func TestMatchNamesWithFallback(t *testing.T) { + tests := []struct { + name string + names []string + pattern string + want []string + }{ + { + name: "empty pattern returns all", + names: []string{"web-server", "db-server", "cache"}, + pattern: "", + want: []string{"web-server", "db-server", "cache"}, + }, + { + name: "prefix match single", + names: []string{"web-server", "db-server", "cache"}, + pattern: "web", + want: []string{"web-server"}, + }, + { + name: "prefix match multiple", + names: []string{"web-server", "web-api", "db-server"}, + pattern: "web", + want: []string{"web-server", "web-api"}, + }, + { + name: "fuzzy fallback when no prefix", + names: []string{"web-server", "db-server", "cache"}, + pattern: "server", + want: []string{"web-server", "db-server"}, + }, + { + name: "fuzzy match pattern", + names: []string{"web-server", "db-server", "cache"}, + pattern: "wsr", + want: []string{"web-server"}, + }, + { + name: "case insensitive prefix", + names: []string{"Web-Server", "DB-Server", "Cache"}, + pattern: "web", + want: []string{"Web-Server"}, + }, + { + name: "no match returns empty", + names: []string{"web-server", "db-server"}, + pattern: "xyz", + want: nil, + }, + { + name: "empty names", + names: []string{}, + pattern: "web", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchNamesWithFallback(tt.names, tt.pattern) + if !slices.Equal(got, tt.want) { + t.Errorf("matchNamesWithFallback(%v, %q) = %v, want %v", + tt.names, tt.pattern, got, tt.want) + } + }) + } +} diff --git a/internal/view/resource_browser_filter.go b/internal/view/resource_browser_filter.go index 4c143e40..23ad866d 100644 --- a/internal/view/resource_browser_filter.go +++ b/internal/view/resource_browser_filter.go @@ -193,15 +193,3 @@ func getFieldValue(data any, fieldName string) string { return fmt.Sprintf("%v", field.Interface()) } } - -// fuzzyMatch checks if pattern characters appear in order in str (case insensitive) -func fuzzyMatch(str, pattern string) bool { - str = strings.ToLower(str) - pi := 0 - for i := 0; i < len(str) && pi < len(pattern); i++ { - if str[i] == pattern[pi] { - pi++ - } - } - return pi == len(pattern) -} diff --git a/internal/view/service_browser_test.go b/internal/view/service_browser_test.go index 35b06d10..15d989fe 100644 --- a/internal/view/service_browser_test.go +++ b/internal/view/service_browser_test.go @@ -140,35 +140,6 @@ func TestServiceBrowserCategoryNavigation(t *testing.T) { } } -func TestFuzzyMatch(t *testing.T) { - tests := []struct { - str string - pattern string - want bool - }{ - {"AgentCoreStackdev", "agecrstdev", true}, - {"AgentCoreStackdev", "agent", true}, - {"AgentCoreStackdev", "acd", true}, - {"AgentCoreStackdev", "xyz", false}, - {"AgentCoreStackdev", "deva", false}, // order matters - {"i-1234567890abcdef0", "i1234", true}, - {"i-1234567890abcdef0", "abcdef", true}, - {"production", "prod", true}, - {"production", "pdn", true}, - {"", "a", false}, - {"abc", "", true}, // empty pattern matches everything - } - - for _, tt := range tests { - t.Run(tt.str+"_"+tt.pattern, func(t *testing.T) { - got := fuzzyMatch(tt.str, tt.pattern) - if got != tt.want { - t.Errorf("fuzzyMatch(%q, %q) = %v, want %v", tt.str, tt.pattern, got, tt.want) - } - }) - } -} - func TestServiceBrowserMouseHover(t *testing.T) { ctx := context.Background() reg := registry.New() From 710cf754c40c489f9e76b9126995ef5875c68852 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 07:39:33 +0000 Subject: [PATCH 05/15] refactor: cache GetAliasesForService and sort match results --- internal/registry/registry.go | 32 ++++++++++++++++++----------- internal/view/command_input_test.go | 20 +++++++++--------- internal/view/match.go | 11 ++++++++-- internal/view/match_test.go | 8 ++++---- 4 files changed, 43 insertions(+), 28 deletions(-) diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 6cc570e0..74ad33b4 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "slices" + "strings" "sync" "github.com/clawscli/claws/internal/dao" @@ -55,8 +56,10 @@ type Registry struct { categories []ServiceCategory // ordered list of service categories // Cached computed values (aliases are immutable after init) - aliasListOnce sync.Once - aliasListCache []string + aliasListOnce sync.Once + aliasListCache []string + serviceAliasesOnce sync.Once + serviceAliasesCache map[string][]string } // New creates a new Registry @@ -287,18 +290,23 @@ func (r *Registry) ResolveAlias(input string) (string, string, bool) { // GetAliasesForService returns all aliases for a given service func (r *Registry) GetAliasesForService(service string) []string { - r.mu.RLock() - defer r.mu.RUnlock() + r.serviceAliasesOnce.Do(func() { + r.mu.RLock() + defer r.mu.RUnlock() - var aliases []string - for alias, target := range r.aliases { - // Check if target matches service (handle "service" or "service/resource") - if target == service || (len(target) > len(service) && target[:len(service)] == service && target[len(service)] == '/') { - aliases = append(aliases, alias) + r.serviceAliasesCache = make(map[string][]string) + for alias, target := range r.aliases { + svc := target + if idx := strings.Index(target, "/"); idx != -1 { + svc = target[:idx] + } + r.serviceAliasesCache[svc] = append(r.serviceAliasesCache[svc], alias) } - } - slices.Sort(aliases) - return aliases + for svc := range r.serviceAliasesCache { + slices.Sort(r.serviceAliasesCache[svc]) + } + }) + return r.serviceAliasesCache[service] } // GetAliases returns all aliases (excluding self-referential ones like "sfn" -> "sfn") diff --git a/internal/view/command_input_test.go b/internal/view/command_input_test.go index dc193deb..a5643b17 100644 --- a/internal/view/command_input_test.go +++ b/internal/view/command_input_test.go @@ -261,10 +261,10 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { want: nil, }, { - name: "empty args returns all", + name: "empty args returns all sorted", provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, args: "", - want: []string{"diff web-server", "diff db-server", "diff cache"}, + want: []string{"diff cache", "diff db-server", "diff web-server"}, }, { name: "prefix match", @@ -273,16 +273,16 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { want: []string{"diff web-server"}, }, { - name: "prefix match multiple", + name: "prefix match multiple sorted", provider: &mockDiffProvider{names: []string{"web-server", "web-api", "db-server"}}, args: "web", - want: []string{"diff web-server", "diff web-api"}, + want: []string{"diff web-api", "diff web-server"}, }, { - name: "fuzzy fallback when no prefix", + name: "fuzzy fallback when no prefix sorted", provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, args: "server", - want: []string{"diff web-server", "diff db-server"}, + want: []string{"diff db-server", "diff web-server"}, }, { name: "fuzzy match pattern", @@ -297,10 +297,10 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { want: []string{"diff Web-Server"}, }, { - name: "case insensitive fuzzy", + name: "case insensitive fuzzy sorted", provider: &mockDiffProvider{names: []string{"Web-Server", "DB-Server", "Cache"}}, args: "SERVER", - want: []string{"diff Web-Server", "diff DB-Server"}, + want: []string{"diff DB-Server", "diff Web-Server"}, }, { name: "no match returns empty", @@ -309,10 +309,10 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { want: nil, }, { - name: "second name completion excludes first", + name: "second name completion excludes first sorted", provider: &mockDiffProvider{names: []string{"web-server", "db-server", "cache"}}, args: "web-server ", - want: []string{"diff web-server db-server", "diff web-server cache"}, + want: []string{"diff web-server cache", "diff web-server db-server"}, }, { name: "second name with prefix", diff --git a/internal/view/match.go b/internal/view/match.go index a8ac3778..764f61c7 100644 --- a/internal/view/match.go +++ b/internal/view/match.go @@ -1,6 +1,9 @@ package view -import "strings" +import ( + "slices" + "strings" +) // fuzzyMatch checks if pattern characters appear in order in str (case insensitive) func fuzzyMatch(str, pattern string) bool { @@ -18,7 +21,9 @@ func fuzzyMatch(str, pattern string) bool { // It first tries prefix matching, then falls back to fuzzy matching if no prefix matches. func matchNamesWithFallback(names []string, pattern string) []string { if pattern == "" { - return names + result := slices.Clone(names) + slices.Sort(result) + return result } var prefixMatches []string @@ -28,6 +33,7 @@ func matchNamesWithFallback(names []string, pattern string) []string { } } if len(prefixMatches) > 0 { + slices.Sort(prefixMatches) return prefixMatches } @@ -37,5 +43,6 @@ func matchNamesWithFallback(names []string, pattern string) []string { fuzzyMatches = append(fuzzyMatches, name) } } + slices.Sort(fuzzyMatches) return fuzzyMatches } diff --git a/internal/view/match_test.go b/internal/view/match_test.go index 65d4a2e2..959f17cd 100644 --- a/internal/view/match_test.go +++ b/internal/view/match_test.go @@ -42,10 +42,10 @@ func TestMatchNamesWithFallback(t *testing.T) { want []string }{ { - name: "empty pattern returns all", + name: "empty pattern returns all sorted", names: []string{"web-server", "db-server", "cache"}, pattern: "", - want: []string{"web-server", "db-server", "cache"}, + want: []string{"cache", "db-server", "web-server"}, }, { name: "prefix match single", @@ -57,13 +57,13 @@ func TestMatchNamesWithFallback(t *testing.T) { name: "prefix match multiple", names: []string{"web-server", "web-api", "db-server"}, pattern: "web", - want: []string{"web-server", "web-api"}, + want: []string{"web-api", "web-server"}, }, { name: "fuzzy fallback when no prefix", names: []string{"web-server", "db-server", "cache"}, pattern: "server", - want: []string{"web-server", "db-server"}, + want: []string{"db-server", "web-server"}, }, { name: "fuzzy match pattern", From f97837b5bc85e260338dcf1161035c84e2635982 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 07:49:24 +0000 Subject: [PATCH 06/15] refactor: unify command parser pattern for sort and login --- internal/view/command_input.go | 94 ++++++++++++++++------------------ 1 file changed, 45 insertions(+), 49 deletions(-) diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 91ae3a91..010423b0 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -229,36 +229,31 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { return nil, &NavigateMsg{View: browser, ClearStack: true} } - // Handle sort command: :sort, :sort , :sort desc - if input == "sort" || strings.HasPrefix(input, "sort ") { - return c.parseSortCommand(input), nil - } - - if input == "login" || strings.HasPrefix(input, "login ") { - profileName := "claws-login" - if suffix, ok := strings.CutPrefix(input, "login "); ok { - if name := strings.TrimSpace(suffix); name != "" { - if !config.IsValidProfileName(name) { - return func() tea.Msg { - return ErrorMsg{Err: fmt.Errorf("invalid profile name: %s", name)} - }, nil - } - profileName = name - } + // Handle sort command: :sort (clear) or :sort (sort by column) + if input == "sort" { + return func() tea.Msg { + return SortMsg{Column: "", Ascending: true} + }, nil + } + if suffix, ok := strings.CutPrefix(input, "sort "); ok { + return c.parseSortArgs(suffix), nil + } + + // Handle login command: :login (default) or :login + if input == "login" { + return c.executeLogin("claws-login"), nil + } + if suffix, ok := strings.CutPrefix(input, "login "); ok { + profileName := strings.TrimSpace(suffix) + if profileName == "" { + return c.executeLogin("claws-login"), nil } - exec := &action.SimpleExec{ - Command: fmt.Sprintf("aws login --remote --profile %s", profileName), - ActionName: action.ActionNameLogin, - SkipAWSEnv: true, + if !config.IsValidProfileName(profileName) { + return func() tea.Msg { + return ErrorMsg{Err: fmt.Errorf("invalid profile name: %s", profileName)} + }, nil } - return tea.Exec(exec, func(err error) tea.Msg { - if err != nil { - return ErrorMsg{Err: err} - } - sel := config.NamedProfile(profileName) - config.Global().SetSelections([]config.ProfileSelection{sel}) - return navmsg.ProfilesChangedMsg{Selections: []config.ProfileSelection{sel}} - }), nil + return c.executeLogin(profileName), nil } // Handle tag command: :tag (clear) or :tag (filter by tag) @@ -357,35 +352,36 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { return nil, nil } -// parseSortCommand parses the sort command and returns a SortMsg command -// Syntax: :sort, :sort , :sort desc -func (c *CommandInput) parseSortCommand(input string) tea.Cmd { - // :sort - clear sorting - if input == "sort" { - return func() tea.Msg { - return SortMsg{Column: "", Ascending: true} - } - } - - // Parse arguments - args := strings.TrimPrefix(input, "sort ") +func (c *CommandInput) parseSortArgs(args string) tea.Cmd { ascending := true column := args - // Check for "desc" prefix - if strings.HasPrefix(args, "desc ") { + if col, ok := strings.CutPrefix(args, "desc "); ok { ascending = false - column = strings.TrimPrefix(args, "desc ") - } else if strings.HasPrefix(args, "asc ") { - ascending = true - column = strings.TrimPrefix(args, "asc ") + column = col + } else if col, ok := strings.CutPrefix(args, "asc "); ok { + column = col } - column = strings.TrimSpace(column) - return func() tea.Msg { - return SortMsg{Column: column, Ascending: ascending} + return SortMsg{Column: strings.TrimSpace(column), Ascending: ascending} + } +} + +func (c *CommandInput) executeLogin(profileName string) tea.Cmd { + exec := &action.SimpleExec{ + Command: fmt.Sprintf("aws login --remote --profile %s", profileName), + ActionName: action.ActionNameLogin, + SkipAWSEnv: true, } + return tea.Exec(exec, func(err error) tea.Msg { + if err != nil { + return ErrorMsg{Err: err} + } + sel := config.NamedProfile(profileName) + config.Global().SetSelections([]config.ProfileSelection{sel}) + return navmsg.ProfilesChangedMsg{Selections: []config.ProfileSelection{sel}} + }) } // GetSuggestions returns command suggestions based on current input From 1ac7363ec2962ad702212f8089eb7ffa64d9233a Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 08:00:10 +0000 Subject: [PATCH 07/15] test: add cache concurrency tests and improve comments --- internal/registry/registry.go | 10 ++++---- internal/registry/registry_test.go | 37 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 74ad33b4..d015be32 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -55,11 +55,11 @@ type Registry struct { displayNames map[string]string // service -> display name for UI categories []ServiceCategory // ordered list of service categories - // Cached computed values (aliases are immutable after init) - aliasListOnce sync.Once - aliasListCache []string - serviceAliasesOnce sync.Once - serviceAliasesCache map[string][]string + // Cached computed values (aliases are immutable after init, safe to cache) + aliasListOnce sync.Once // guards aliasListCache initialization + aliasListCache []string // cached result of GetAliases() + serviceAliasesOnce sync.Once // guards serviceAliasesCache initialization + serviceAliasesCache map[string][]string // cached result of GetAliasesForService() by service } // New creates a new Registry diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 6779c503..a28aca31 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -2,6 +2,7 @@ package registry import ( "context" + "sync" "testing" "github.com/clawscli/claws/internal/dao" @@ -291,6 +292,42 @@ func TestRegistry_GetAliases_ExcludesSelfReferential(t *testing.T) { } } +func TestRegistry_GetAliases_ConcurrentAccess(t *testing.T) { + reg := New() + var wg sync.WaitGroup + const goroutines = 100 + + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + aliases := reg.GetAliases() + if len(aliases) == 0 { + t.Error("GetAliases() should return aliases") + } + }() + } + wg.Wait() +} + +func TestRegistry_GetAliasesForService_ConcurrentAccess(t *testing.T) { + reg := New() + var wg sync.WaitGroup + const goroutines = 100 + + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + aliases := reg.GetAliasesForService("cloudformation") + if len(aliases) != 2 { + t.Errorf("GetAliasesForService() returned %d aliases, want 2", len(aliases)) + } + }() + } + wg.Wait() +} + func TestRegistry_GetDAO_NotRegistered(t *testing.T) { reg := New() From fef737d0ebf080f792757531c78cb4b5d9506908 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 10:00:09 +0000 Subject: [PATCH 08/15] refactor: use CutPrefix in ecs tasks render --- custom/ecs/tasks/render.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom/ecs/tasks/render.go b/custom/ecs/tasks/render.go index 1c7fa2b1..1bc11a4a 100644 --- a/custom/ecs/tasks/render.go +++ b/custom/ecs/tasks/render.go @@ -318,8 +318,7 @@ func (r *TaskRenderer) Navigations(resource dao.Resource) []render.Navigation { } // If task is part of a service, add service navigation - if group := task.Group(); strings.HasPrefix(group, "service:") { - serviceName := strings.TrimPrefix(group, "service:") + if serviceName, ok := strings.CutPrefix(task.Group(), "service:"); ok { navs = append(navs, render.Navigation{ Key: "s", Label: "Service", From c67c204ccaaf5c1086107a33d7d893231e84a7ab Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 10:10:00 +0000 Subject: [PATCH 09/15] refactor: use CutSuffix in parseNumericValue --- internal/view/resource_browser_sort.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/view/resource_browser_sort.go b/internal/view/resource_browser_sort.go index ce02cb15..242aa524 100644 --- a/internal/view/resource_browser_sort.go +++ b/internal/view/resource_browser_sort.go @@ -90,8 +90,8 @@ func parseNumeric(s string) (float64, error) { } for suffix, mult := range suffixes { - if strings.HasSuffix(s, suffix) { - s = strings.TrimSuffix(s, suffix) + if before, ok := strings.CutSuffix(s, suffix); ok { + s = before multiplier = mult break } From d3f5618a43eb910c6a77651afe6989301e535237 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 10:16:03 +0000 Subject: [PATCH 10/15] fix: quote profile name in error message for clarity --- internal/view/command_input.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 010423b0..1c29a76b 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -250,7 +250,7 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { } if !config.IsValidProfileName(profileName) { return func() tea.Msg { - return ErrorMsg{Err: fmt.Errorf("invalid profile name: %s", profileName)} + return ErrorMsg{Err: fmt.Errorf("invalid profile name: %q", profileName)} }, nil } return c.executeLogin(profileName), nil From a592a36ef355724446dd8b7903fdeb97805019d9 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 10:40:35 +0000 Subject: [PATCH 11/15] fix: lowercase pattern in fuzzyMatch for case insensitivity --- .claude | 1 + internal/view/match.go | 1 + internal/view/match_test.go | 4 ++++ 3 files changed, 6 insertions(+) create mode 120000 .claude diff --git a/.claude b/.claude new file mode 120000 index 00000000..91401423 --- /dev/null +++ b/.claude @@ -0,0 +1 @@ +/home/ec2-user/work/claws/.claude \ No newline at end of file diff --git a/internal/view/match.go b/internal/view/match.go index 764f61c7..f3d588a3 100644 --- a/internal/view/match.go +++ b/internal/view/match.go @@ -8,6 +8,7 @@ import ( // fuzzyMatch checks if pattern characters appear in order in str (case insensitive) func fuzzyMatch(str, pattern string) bool { str = strings.ToLower(str) + pattern = strings.ToLower(pattern) pi := 0 for i := 0; i < len(str) && pi < len(pattern); i++ { if str[i] == pattern[pi] { diff --git a/internal/view/match_test.go b/internal/view/match_test.go index 959f17cd..e642b231 100644 --- a/internal/view/match_test.go +++ b/internal/view/match_test.go @@ -22,6 +22,10 @@ func TestFuzzyMatch(t *testing.T) { {"production", "pdn", true}, {"", "a", false}, {"abc", "", true}, + // uppercase pattern - case insensitive + {"production", "PROD", true}, + {"AgentCoreStackdev", "ACD", true}, + {"web-server", "WEB", true}, } for _, tt := range tests { From 7f8ea9b7e5f1edc09f8c94e3f05f047b094e71b8 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 10:41:33 +0000 Subject: [PATCH 12/15] chore: remove .claude symlink --- .claude | 1 - 1 file changed, 1 deletion(-) delete mode 120000 .claude diff --git a/.claude b/.claude deleted file mode 120000 index 91401423..00000000 --- a/.claude +++ /dev/null @@ -1 +0,0 @@ -/home/ec2-user/work/claws/.claude \ No newline at end of file From ad009d6f6d5fa2aa78946fb34d9c5d26b70d3c15 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 10:41:44 +0000 Subject: [PATCH 13/15] chore: add .claude to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f40fea8d..83f638d1 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ claws.log # Node.js node_modules/ +.claude From b5fae77e430d4730670a34e981aa31af95e04807 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 10:45:13 +0000 Subject: [PATCH 14/15] fix: lowercase pattern in matchNamesWithFallback prefix check --- internal/view/match.go | 2 ++ internal/view/match_test.go | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/view/match.go b/internal/view/match.go index f3d588a3..b1769fc7 100644 --- a/internal/view/match.go +++ b/internal/view/match.go @@ -27,6 +27,8 @@ func matchNamesWithFallback(names []string, pattern string) []string { return result } + pattern = strings.ToLower(pattern) + var prefixMatches []string for _, name := range names { if strings.HasPrefix(strings.ToLower(name), pattern) { diff --git a/internal/view/match_test.go b/internal/view/match_test.go index e642b231..abab4beb 100644 --- a/internal/view/match_test.go +++ b/internal/view/match_test.go @@ -76,11 +76,17 @@ func TestMatchNamesWithFallback(t *testing.T) { want: []string{"web-server"}, }, { - name: "case insensitive prefix", + name: "case insensitive prefix lowercase pattern", names: []string{"Web-Server", "DB-Server", "Cache"}, pattern: "web", want: []string{"Web-Server"}, }, + { + name: "case insensitive prefix uppercase pattern", + names: []string{"web-server", "web-api", "db-server"}, + pattern: "WEB", + want: []string{"web-api", "web-server"}, + }, { name: "no match returns empty", names: []string{"web-server", "db-server"}, From 55c863aabc5a7bf69948afa0575393244f5772aa Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Fri, 2 Jan 2026 10:49:49 +0000 Subject: [PATCH 15/15] fix: return defensive copy from alias cache methods --- internal/registry/registry.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/registry/registry.go b/internal/registry/registry.go index d015be32..289fe3ad 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -288,7 +288,7 @@ func (r *Registry) ResolveAlias(input string) (string, string, bool) { return input, "", false } -// GetAliasesForService returns all aliases for a given service +// GetAliasesForService returns all aliases for a given service. func (r *Registry) GetAliasesForService(service string) []string { r.serviceAliasesOnce.Do(func() { r.mu.RLock() @@ -306,10 +306,10 @@ func (r *Registry) GetAliasesForService(service string) []string { slices.Sort(r.serviceAliasesCache[svc]) } }) - return r.serviceAliasesCache[service] + return slices.Clone(r.serviceAliasesCache[service]) } -// GetAliases returns all aliases (excluding self-referential ones like "sfn" -> "sfn") +// GetAliases returns all aliases (excluding self-referential ones like "sfn" -> "sfn"). func (r *Registry) GetAliases() []string { r.aliasListOnce.Do(func() { r.mu.RLock() @@ -324,7 +324,7 @@ func (r *Registry) GetAliases() []string { slices.Sort(aliases) r.aliasListCache = aliases }) - return r.aliasListCache + return slices.Clone(r.aliasListCache) } // RegisterCustom registers a custom (hand-written) implementation