From abb30e511e2b129cfb14e7d26d5b8903ff33fdd3 Mon Sep 17 00:00:00 2001 From: Ari Mayer Date: Wed, 1 Jul 2026 16:31:22 -0400 Subject: [PATCH 1/3] fix(tui): sort credentials case-insensitively and sort the main table The sidebar sorted categories and credentials with a case-sensitive comparison, so lowercase services sorted after all uppercase ones (e.g. "azure" landed after "GitHub" instead of after "AWS"). The main credential table did not sort at all, rendering in map-iteration order and disagreeing with the sidebar. Add a shared case-insensitive `lessFold` comparator (with a case-sensitive tie-break for deterministic ordering) and use it for the category sort, the in-category credential sort, and the main table's Refresh(). Update TestCredentialTablePopulateRows to assert sorted order, including a lowercase entry that locks the case-insensitive behavior. Reported in discussion #89. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014kVLjbUL4F4CkoA7RbMYy8 --- cmd/tui/components/sidebar.go | 21 ++++++++++++++++++--- cmd/tui/components/table.go | 7 +++++++ cmd/tui/components/table_test.go | 29 +++++++++++++++++------------ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/cmd/tui/components/sidebar.go b/cmd/tui/components/sidebar.go index b5cf8ab..2ea3f85 100644 --- a/cmd/tui/components/sidebar.go +++ b/cmd/tui/components/sidebar.go @@ -2,6 +2,7 @@ package components import ( "sort" + "strings" "github.com/arimxyer/pass-cli/cmd/tui/models" "github.com/arimxyer/pass-cli/cmd/tui/styles" @@ -10,6 +11,17 @@ import ( "github.com/rivo/tview" ) +// lessFold reports whether a should sort before b, compared case-insensitively. +// It falls back to a case-sensitive comparison as a tie-break so that strings +// differing only in case keep a stable, deterministic order. +func lessFold(a, b string) bool { + la, lb := strings.ToLower(a), strings.ToLower(b) + if la != lb { + return la < lb + } + return a < b +} + // NodeReference identifies the type and value of a tree node. // Used to distinguish categories from credentials without relying on tree position. type NodeReference struct { @@ -96,7 +108,10 @@ func (s *Sidebar) Refresh() { for category := range groups { categories = append(categories, category) } - sort.Strings(categories) // Sort categories alphabetically + // Sort categories alphabetically (case-insensitive) + sort.Slice(categories, func(i, j int) bool { + return lessFold(categories[i], categories[j]) + }) // Build category nodes with credential children for _, category := range categories { @@ -110,10 +125,10 @@ func (s *Sidebar) Refresh() { SetReference(NodeReference{Kind: "category", Value: category}). SetExpanded(false) // Collapsed by default - // Sort credentials within category for deterministic ordering + // Sort credentials within category alphabetically (case-insensitive) credList := groups[category] sort.Slice(credList, func(i, j int) bool { - return credList[i].Service < credList[j].Service + return lessFold(credList[i].Service, credList[j].Service) }) // Add credential nodes from sorted list diff --git a/cmd/tui/components/table.go b/cmd/tui/components/table.go index 36b0d76..93bd763 100644 --- a/cmd/tui/components/table.go +++ b/cmd/tui/components/table.go @@ -2,6 +2,7 @@ package components import ( "fmt" + "sort" "time" "github.com/arimxyer/pass-cli/cmd/tui/models" @@ -83,6 +84,12 @@ func (ct *CredentialTable) Refresh() { searchState := ct.appState.GetSearchState() ct.filteredCreds = ct.filterBySearch(categoryFiltered, searchState) + // Sort by service alphabetically (case-insensitive) so the table matches + // the sidebar's ordering; GetCredentials returns map-iteration order. + sort.Slice(ct.filteredCreds, func(i, j int) bool { + return lessFold(ct.filteredCreds[i].Service, ct.filteredCreds[j].Service) + }) + // Get current row count (excluding header) currentRowCount := ct.GetRowCount() - 1 newRowCount := len(ct.filteredCreds) diff --git a/cmd/tui/components/table_test.go b/cmd/tui/components/table_test.go index f85b2f5..1e95f06 100644 --- a/cmd/tui/components/table_test.go +++ b/cmd/tui/components/table_test.go @@ -282,10 +282,13 @@ func TestCredentialTablePopulateRows(t *testing.T) { // Setup credentials with different last accessed times now := time.Now() + // Includes a lowercase "azure" entry: under a case-sensitive sort it would + // land last (ASCII 'a' > 'G'), but case-insensitively it belongs after AWS. mockCreds := []vault.CredentialMetadata{ {Service: "AWS", Username: "admin", CreatedAt: now, LastAccessed: now.Add(-30 * time.Second)}, {Service: "GitHub", Username: "user", CreatedAt: now, LastAccessed: now.Add(-5 * time.Minute)}, {Service: "Database", Username: "dbuser", CreatedAt: now, LastAccessed: time.Time{}}, + {Service: "azure", Username: "svc", CreatedAt: now, LastAccessed: now.Add(-1 * time.Minute)}, } mockVault.SetCredentials(mockCreds) _ = state.LoadCredentials() @@ -293,27 +296,29 @@ func TestCredentialTablePopulateRows(t *testing.T) { table := NewCredentialTable(state) table.Refresh() - // Verify row 1 (AWS) - row1Col0 := table.GetCell(1, 0) - if row1Col0.Text != "AWS" { - t.Errorf("Expected 'AWS', got '%s'", row1Col0.Text) + // Rows are sorted alphabetically by service (case-insensitive), so the + // expected order is AWS, azure, Database, GitHub regardless of insertion order. + wantOrder := []string{"AWS", "azure", "Database", "GitHub"} + for i, want := range wantOrder { + got := table.GetCell(i+1, 0).Text + if got != want { + t.Errorf("Expected row %d service '%s', got '%s'", i+1, want, got) + } } // Verify credential reference stored in cell - if row1Col0.GetReference() == nil { + if table.GetCell(1, 0).GetReference() == nil { t.Error("Expected credential reference in cell, got nil") } - // Verify row 2 (GitHub) last used formatted - row2Col2 := table.GetCell(2, 2) - if row2Col2.Text == "" { + // GitHub (row 4) last used formatted + if got := table.GetCell(4, 2).Text; got == "" { t.Error("Expected formatted last used time") } - // Verify row 3 (Database) shows "Never" for zero time - row3Col2 := table.GetCell(3, 2) - if row3Col2.Text != "Never" { - t.Errorf("Expected 'Never' for zero LastAccessed, got '%s'", row3Col2.Text) + // Database (row 3) shows "Never" for zero time + if got := table.GetCell(3, 2).Text; got != "Never" { + t.Errorf("Expected 'Never' for zero LastAccessed, got '%s'", got) } } From 3ef8647e3e04a4474f26103a75cdb71363df46d6 Mon Sep 17 00:00:00 2001 From: Ari Mayer Date: Wed, 1 Jul 2026 16:44:24 -0400 Subject: [PATCH 2/3] fix(list): sort service and project names case-insensitively The CLI `list` command sorted by service name (and grouped project / credential names) with a case-sensitive comparison, so its ordering could disagree with the now case-insensitive TUI for mixed-case vaults. Add a `lessFold` helper in the cmd package (mirroring the TUI one) and apply it to the standard list sort, the by-project credential lists, and the project-name ordering so the whole tool agrees on ordering. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014kVLjbUL4F4CkoA7RbMYy8 --- cmd/list.go | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/cmd/list.go b/cmd/list.go index 3671306..b56775b 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -159,9 +159,9 @@ func runList(cmd *cobra.Command, args []string) error { } // Standard list mode (existing behavior) - // Sort by service name + // Sort by service name (case-insensitive) sort.Slice(metadata, func(i, j int) bool { - return metadata[i].Service < metadata[j].Service + return lessFold(metadata[i].Service, metadata[j].Service) }) // Output in requested format @@ -382,7 +382,7 @@ func groupCredentialsByProject(metadata []vault.CredentialMetadata) map[string][ for cred := range credSet { creds = append(creds, cred) } - sort.Strings(creds) + sort.Slice(creds, func(i, j int) bool { return lessFold(creds[i], creds[j]) }) result[project] = creds } @@ -415,7 +415,7 @@ func outputByProjectTable(projects map[string][]string) error { for project := range projects { projectNames = append(projectNames, project) } - sort.Strings(projectNames) + sort.Slice(projectNames, func(i, j int) bool { return lessFold(projectNames[i], projectNames[j]) }) // Display each project group for _, project := range projectNames { @@ -469,7 +469,7 @@ func outputByProjectSimple(projects map[string][]string) error { for project := range projects { projectNames = append(projectNames, project) } - sort.Strings(projectNames) + sort.Slice(projectNames, func(i, j int) bool { return lessFold(projectNames[i], projectNames[j]) }) // Output each project on one line for _, project := range projectNames { @@ -479,3 +479,14 @@ func outputByProjectSimple(projects map[string][]string) error { return nil } + +// lessFold reports whether a should sort before b, compared case-insensitively. +// It falls back to a case-sensitive comparison as a tie-break so that strings +// differing only in case keep a stable, deterministic order. +func lessFold(a, b string) bool { + la, lb := strings.ToLower(a), strings.ToLower(b) + if la != lb { + return la < lb + } + return a < b +} From 5305e807c044acbc0f6ec21bbe6af09f5fdf3ee3 Mon Sep 17 00:00:00 2001 From: Ari Mayer Date: Wed, 1 Jul 2026 17:05:25 -0400 Subject: [PATCH 3/3] fix: sort remaining user-facing name lists case-insensitively Complete the case-insensitive ordering across the tool. Besides the TUI credential lists and `list` command, several other name lists still used a case-sensitive sort.Strings and could disagree with the rest: - the category list feeding the Add/Edit form dropdowns (models.AppState.updateCategories -> GetCategories) - the service names in `backup preview` output - field-name breakdowns in usage output and the TUI detail panel Route them all through lessFold (adding a copy to the models package, which lacked one) so every ordering in the tool is consistent. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014kVLjbUL4F4CkoA7RbMYy8 --- cmd/helpers.go | 4 ++-- cmd/tui/components/detail.go | 4 ++-- cmd/tui/models/state.go | 16 ++++++++++++++-- cmd/vault_backup_preview.go | 4 ++-- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/cmd/helpers.go b/cmd/helpers.go index dfe77c6..6898b1a 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -220,12 +220,12 @@ func formatFieldCounts(fieldCounts map[string]int) string { return "-" } - // Sort field names for consistent output + // Sort field names for consistent output (case-insensitive) fields := make([]string, 0, len(fieldCounts)) for field := range fieldCounts { fields = append(fields, field) } - sort.Strings(fields) + sort.Slice(fields, func(i, j int) bool { return lessFold(fields[i], fields[j]) }) // Build formatted string parts := make([]string, 0, len(fields)) diff --git a/cmd/tui/components/detail.go b/cmd/tui/components/detail.go index fcd5467..3b2418c 100644 --- a/cmd/tui/components/detail.go +++ b/cmd/tui/components/detail.go @@ -562,8 +562,8 @@ func FormatUsageLocations(cred *vault.Credential) string { for field, count := range record.FieldAccess { fieldParts = append(fieldParts, fmt.Sprintf("%s:%d", field, count)) } - // Sort field names for consistent display - sort.Strings(fieldParts) + // Sort field names for consistent display (case-insensitive) + sort.Slice(fieldParts, func(i, j int) bool { return lessFold(fieldParts[i], fieldParts[j]) }) b.WriteString(strings.Join(fieldParts, ", ")) b.WriteString(fmt.Sprintf(")%s", textColor())) } diff --git a/cmd/tui/models/state.go b/cmd/tui/models/state.go index 8f71b6e..46066b5 100644 --- a/cmd/tui/models/state.go +++ b/cmd/tui/models/state.go @@ -6,6 +6,7 @@ package models import ( "fmt" "sort" + "strings" "sync" "github.com/arimxyer/pass-cli/internal/vault" @@ -13,6 +14,17 @@ import ( "github.com/rivo/tview" ) +// lessFold reports whether a should sort before b, compared case-insensitively. +// It falls back to a case-sensitive comparison as a tie-break so that strings +// differing only in case keep a stable, deterministic order. +func lessFold(a, b string) bool { + la, lb := strings.ToLower(a), strings.ToLower(b) + if la != lb { + return la < lb + } + return a < b +} + // VaultService interface defines the vault operations needed by AppState. // This interface enables testing with mock implementations. // T020d: Updated AddCredential signature to accept []byte password @@ -567,12 +579,12 @@ func (s *AppState) updateCategories() { } } - // Convert map to sorted slice + // Convert map to sorted slice (case-insensitive) categories := make([]string, 0, len(categoryMap)) for category := range categoryMap { categories = append(categories, category) } - sort.Strings(categories) + sort.Slice(categories, func(i, j int) bool { return lessFold(categories[i], categories[j]) }) s.categories = categories } diff --git a/cmd/vault_backup_preview.go b/cmd/vault_backup_preview.go index eef7131..f188dcf 100644 --- a/cmd/vault_backup_preview.go +++ b/cmd/vault_backup_preview.go @@ -107,12 +107,12 @@ func runVaultBackupPreview(cmd *cobra.Command, args []string) error { fmt.Printf("Found %d credential(s) in backup:\n\n", credCount) - // Sort service names alphabetically + // Sort service names alphabetically (case-insensitive) services := make([]string, 0, credCount) for service := range vaultData.Credentials { services = append(services, service) } - sort.Strings(services) + sort.Slice(services, func(i, j int) bool { return lessFold(services[i], services[j]) }) if previewVerbose { // Verbose: show table with more details