Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
21 changes: 16 additions & 5 deletions cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
4 changes: 2 additions & 2 deletions cmd/tui/components/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}
Expand Down
21 changes: 18 additions & 3 deletions cmd/tui/components/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions cmd/tui/components/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package components

import (
"fmt"
"sort"
"time"

"github.com/arimxyer/pass-cli/cmd/tui/models"
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 17 additions & 12 deletions cmd/tui/components/table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,38 +282,43 @@ 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()

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)
}
}

Expand Down
16 changes: 14 additions & 2 deletions cmd/tui/models/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@ package models
import (
"fmt"
"sort"
"strings"
"sync"

"github.com/arimxyer/pass-cli/internal/vault"

"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
Expand Down Expand Up @@ -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
}
4 changes: 2 additions & 2 deletions cmd/vault_backup_preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading