···23612361func (m *Model) sortEmails() tea.Cmd {
23622362 field, rev := m.sortField, m.sortReverse
23632363 sort.SliceStable(m.emails, func(i, j int) bool {
23642364- a, b := m.emails[i], m.emails[j]
23652365- var less bool
23662366- switch field {
23672367- case "from":
23682368- less = strings.ToLower(a.From) < strings.ToLower(b.From)
23692369- case "subject":
23702370- less = strings.ToLower(a.Subject) < strings.ToLower(b.Subject)
23712371- case "size":
23722372- less = a.Size < b.Size
23732373- default: // "date"
23742374- less = a.Date.Before(b.Date)
23752375- }
23642364+ cmp := compareEmails(m.emails[i], m.emails[j], field)
23652365+ // Apply sort direction
23762366 if rev {
23772377- return !less
23672367+ return cmp > 0 // descending: a > b means a comes first
23782368 }
23792379- return less
23692369+ return cmp < 0 // ascending: a < b means a comes first
23802370 })
23812371 return m.applyFilter()
23822372}
+64-14
internal/ui/thread.go
···1111// replyPrefixRe matches common reply/forward prefixes.
1212var replyPrefixRe = regexp.MustCompile(`(?i)^(re|fwd?|fw|aw|sv|vs|ref|rif)\s*(\[\d+\])?\s*:\s*`)
13131414+// compareEmails returns -1 if a < b, 0 if a == b, 1 if a > b.
1515+// Comparison uses the specified sortField with deterministic tie-breakers:
1616+// 1. Primary sort field (from/subject/size/date)
1717+// 2. Date (newest first) if primary keys match and sortField != "date"
1818+// 3. UID for fully deterministic ordering
1919+func compareEmails(a, b imap.Email, sortField string) int {
2020+ // Primary sort comparison
2121+ var cmp int // -1 = a < b, 0 = equal, 1 = a > b
2222+ switch sortField {
2323+ case "from":
2424+ aFrom, bFrom := strings.ToLower(a.From), strings.ToLower(b.From)
2525+ if aFrom < bFrom {
2626+ cmp = -1
2727+ } else if aFrom > bFrom {
2828+ cmp = 1
2929+ }
3030+ case "subject":
3131+ aSubj, bSubj := strings.ToLower(a.Subject), strings.ToLower(b.Subject)
3232+ if aSubj < bSubj {
3333+ cmp = -1
3434+ } else if aSubj > bSubj {
3535+ cmp = 1
3636+ }
3737+ case "size":
3838+ if a.Size < b.Size {
3939+ cmp = -1
4040+ } else if a.Size > b.Size {
4141+ cmp = 1
4242+ }
4343+ default: // "date"
4444+ if a.Date.Before(b.Date) {
4545+ cmp = -1
4646+ } else if a.Date.After(b.Date) {
4747+ cmp = 1
4848+ }
4949+ }
5050+5151+ // Tie-breaker 1: date (newest first) if primary keys are equal
5252+ if cmp == 0 && sortField != "date" {
5353+ if a.Date.After(b.Date) {
5454+ cmp = -1
5555+ } else if a.Date.Before(b.Date) {
5656+ cmp = 1
5757+ }
5858+ }
5959+6060+ // Tie-breaker 2: UID for deterministic ordering
6161+ if cmp == 0 {
6262+ if a.UID < b.UID {
6363+ cmp = -1
6464+ } else if a.UID > b.UID {
6565+ cmp = 1
6666+ }
6767+ }
6868+6969+ return cmp
7070+}
7171+1472// hasReplyPrefix returns true if the subject starts with a reply/forward prefix.
1573func hasReplyPrefix(subject string) bool {
1674 return replyPrefixRe.MatchString(strings.TrimSpace(subject))
···144202145203 // Sort threads by user's chosen sort field and order.
146204 // We use the newest email in each thread as the representative for sorting.
147147- sort.Slice(threads, func(i, j int) bool {
205205+ sort.SliceStable(threads, func(i, j int) bool {
148206 a := emails[threads[i].newestIdx]
149207 b := emails[threads[j].newestIdx]
150150- var less bool
151151- switch sortField {
152152- case "from":
153153- less = strings.ToLower(a.From) < strings.ToLower(b.From)
154154- case "subject":
155155- less = strings.ToLower(a.Subject) < strings.ToLower(b.Subject)
156156- case "size":
157157- less = a.Size < b.Size
158158- default: // "date"
159159- less = a.Date.Before(b.Date)
160160- }
208208+ cmp := compareEmails(a, b, sortField)
209209+210210+ // Apply sort direction
161211 if sortReverse {
162162- return !less
212212+ return cmp > 0 // descending: a > b means a comes first
163213 }
164164- return less
214214+ return cmp < 0 // ascending: a < b means a comes first
165215 })
166216167217 // Build output with thread connector lines.