config/folder_cache.go π
@@ -9,7 +9,7 @@ import (
"strings"
"time"
- "github.com/floatpane/matcha/internal/threading"
+ threading "github.com/floatpane/jwz-go"
)
// CachedFolders stores folder names for a single account.
Drew Smirnoff created
## What?
Replaces the `internal/threading` with our library
[`jwz-go`](https://github.com/floatpane/jwz-go).
Replaces helper `overlay.go` with a library
[`bubble-overlay`](https://github.com/floatpane/bubble-overlay)
## Why?
These libraries may be used by others, and it will make it easier to
maintain them separate from our project.
---------
Signed-off-by: drew <me@andrinoff.com>
config/folder_cache.go | 2
go.mod | 2
go.sum | 4
internal/threading/jwz.go | 365 ------------------------------------
internal/threading/jwz_test.go | 154 ---------------
internal/threading/subject.go | 20 -
tui/composer.go | 3
tui/inbox.go | 2
tui/overlay.go | 58 -----
9 files changed, 10 insertions(+), 600 deletions(-)
@@ -9,7 +9,7 @@ import (
"strings"
"time"
- "github.com/floatpane/matcha/internal/threading"
+ threading "github.com/floatpane/jwz-go"
)
// CachedFolders stores folder names for a single account.
@@ -19,7 +19,9 @@ require (
github.com/emersion/go-message v0.18.2
github.com/emersion/go-pgpmail v0.2.2
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
+ github.com/floatpane/bubble-overlay v0.0.1
github.com/floatpane/go-uds-jsonrpc v0.0.1
+ github.com/floatpane/jwz-go v0.0.1
github.com/floatpane/termimage v0.2.1
github.com/google/uuid v1.6.0
github.com/hashicorp/golang-lru/v2 v2.0.7
@@ -68,6 +68,10 @@ github.com/emersion/go-pgpmail v0.2.2/go.mod h1:mRB5P7QKiAuOvcT36tdRZvm7nSt7V+f6
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
+github.com/floatpane/bubble-overlay v0.0.1 h1:5xU8cNigDPYegvgGMfOG23fIDXhrqXPvLTaEB7uHGK4=
+github.com/floatpane/bubble-overlay v0.0.1/go.mod h1:Csi1byxb9L8EAb8X13XdWF5aX5YiBD5C9WEWACyGa8A=
+github.com/floatpane/jwz-go v0.0.1 h1:OAl/vaUYn+/8zFR47WaCybBGoQItb1ZkFplNrmeO3ps=
+github.com/floatpane/jwz-go v0.0.1/go.mod h1:1b/JE4K+LBIBGtWbFdM1BXpN6ZbGWIv7IXPRbXD4oRE=
github.com/floatpane/go-uds-jsonrpc v0.0.1 h1:/sBlCXVAP9SyLWLj0wlFI07dX/SfXeUM67B4tRwK2QA=
github.com/floatpane/go-uds-jsonrpc v0.0.1/go.mod h1:G/YeDIocGkPIU+uyhJ/e8ynn9wIEMIkJ74d3VUuC4rM=
github.com/floatpane/termimage v0.2.1 h1:jYPBg+SSl5PHFpN96tccBYXG4ZERoJ61ALyRDJMqonU=
@@ -1,365 +0,0 @@
-package threading
-
-import (
- "regexp"
- "sort"
- "strings"
- "time"
-)
-
-type EmailHeader struct {
- ID string
- InReplyTo string
- References []string
- Subject string
- Date time.Time
- EmailID string
- Sender string
-}
-
-type Thread struct {
- Root *ThreadNode
- LatestAt time.Time
- Count int
- Subject string
- Senders []string
-}
-
-type ThreadNode struct {
- EmailID string
- Children []*ThreadNode
- Date time.Time
- Sender string
- Subject string
-}
-
-type container struct {
- id string
- node *ThreadNode
- parent *container
- children []*container
-}
-
-var messageIDRE = regexp.MustCompile(`<[^>]+>`)
-
-func Build(headers []EmailHeader) []Thread {
- containers := make(map[string]*container)
- ordered := make([]*container, 0, len(headers))
-
- get := func(id string) *container {
- if c := containers[id]; c != nil {
- return c
- }
- c := &container{id: id}
- containers[id] = c
- ordered = append(ordered, c)
- return c
- }
-
- for _, h := range headers {
- msgID := normalizeMessageID(h.ID)
- if msgID == "" {
- msgID = "email:" + h.EmailID
- }
- c := get(msgID)
- if c.node != nil {
- msgID = msgID + "#email:" + h.EmailID
- c = get(msgID)
- }
- c.node = &ThreadNode{
- EmailID: h.EmailID,
- Date: h.Date,
- Sender: h.Sender,
- Subject: h.Subject,
- }
-
- var prev *container
- refs := normalizeReferences(h.References)
- for _, ref := range refs {
- refc := get(ref)
- if prev != nil {
- link(prev, refc)
- }
- prev = refc
- }
-
- parentID := normalizeMessageID(h.InReplyTo)
- if parentID == "" && len(refs) > 0 {
- parentID = refs[len(refs)-1]
- }
- if parentID != "" {
- link(get(parentID), c)
- }
- }
-
- var roots []*container
- for _, c := range ordered {
- if c.parent == nil {
- if root := prune(c); root != nil {
- roots = append(roots, root)
- }
- }
- }
- roots = groupBySubject(roots)
-
- threads := make([]Thread, 0, len(roots))
- for _, root := range roots {
- sortContainer(root)
- thread := buildThread(root)
- if thread.Count > 0 {
- threads = append(threads, thread)
- }
- }
-
- sort.SliceStable(threads, func(i, j int) bool {
- if !threads[i].LatestAt.Equal(threads[j].LatestAt) {
- return threads[i].LatestAt.After(threads[j].LatestAt)
- }
- return threadKey(threads[i].Root) < threadKey(threads[j].Root)
- })
-
- return threads
-}
-
-func normalizeReferences(refs []string) []string {
- seen := make(map[string]bool)
- var out []string
- for _, ref := range refs {
- for _, id := range extractMessageIDs(ref) {
- if !seen[id] {
- out = append(out, id)
- seen[id] = true
- }
- }
- }
- return out
-}
-
-func extractMessageIDs(s string) []string {
- matches := messageIDRE.FindAllString(s, -1)
- if len(matches) == 0 {
- if id := normalizeMessageID(s); id != "" {
- return []string{id}
- }
- return nil
- }
- ids := make([]string, 0, len(matches))
- for _, match := range matches {
- if id := normalizeMessageID(match); id != "" {
- ids = append(ids, id)
- }
- }
- return ids
-}
-
-func normalizeMessageID(id string) string {
- id = strings.TrimSpace(id)
- if id == "" {
- return ""
- }
- if matches := messageIDRE.FindAllString(id, -1); len(matches) > 0 {
- id = matches[len(matches)-1]
- }
- id = strings.TrimSpace(id)
- id = strings.TrimPrefix(id, "<")
- id = strings.TrimSuffix(id, ">")
- id = strings.TrimSpace(id)
- return strings.ToLower(id)
-}
-
-func link(parent, child *container) {
- if parent == nil || child == nil || parent == child {
- return
- }
- if child.parent != nil || child.hasDescendant(parent) {
- return
- }
- child.parent = parent
- for _, existing := range parent.children {
- if existing == child {
- return
- }
- }
- parent.children = append(parent.children, child)
-}
-
-func (c *container) hasDescendant(target *container) bool {
- for _, child := range c.children {
- if child == target || child.hasDescendant(target) {
- return true
- }
- }
- return false
-}
-
-func prune(c *container) *container {
- if c == nil {
- return nil
- }
- var children []*container
- for _, child := range c.children {
- if pruned := prune(child); pruned != nil {
- pruned.parent = c
- children = append(children, pruned)
- }
- }
- c.children = children
-
- if c.node != nil {
- return c
- }
- switch len(c.children) {
- case 0:
- return nil
- case 1:
- child := c.children[0]
- child.parent = c.parent
- return child
- default:
- return c
- }
-}
-
-func groupBySubject(roots []*container) []*container {
- subjects := make(map[string]*container)
- var grouped []*container
- for _, root := range roots {
- subject := firstSubject(root)
- if subject == "" {
- grouped = append(grouped, root)
- continue
- }
- if existing := subjects[subject]; existing != nil {
- link(existing, root)
- continue
- }
- subjects[subject] = root
- grouped = append(grouped, root)
- }
- return grouped
-}
-
-func firstSubject(c *container) string {
- if c == nil {
- return ""
- }
- if c.node != nil {
- return canonicalSubject(c.node.Subject)
- }
- for _, child := range c.children {
- if subject := firstSubject(child); subject != "" {
- return subject
- }
- }
- return ""
-}
-
-func sortContainer(c *container) {
- for _, child := range c.children {
- sortContainer(child)
- }
- sort.SliceStable(c.children, func(i, j int) bool {
- a, b := c.children[i], c.children[j]
- ad, bd := containerDate(a), containerDate(b)
- if !ad.Equal(bd) {
- return ad.Before(bd)
- }
- return containerKey(a) < containerKey(b)
- })
-}
-
-func buildThread(root *container) Thread {
- node := toThreadNode(root)
- thread := Thread{Root: node, Subject: canonicalSubject(firstDisplaySubject(node))}
- seenSenders := make(map[string]bool)
- walkThread(node, &thread, seenSenders)
- return thread
-}
-
-func toThreadNode(c *container) *ThreadNode {
- node := &ThreadNode{}
- if c.node != nil {
- *node = *c.node
- node.Children = nil
- }
- for _, child := range c.children {
- node.Children = append(node.Children, toThreadNode(child))
- }
- return node
-}
-
-func walkThread(node *ThreadNode, thread *Thread, seenSenders map[string]bool) {
- if node == nil {
- return
- }
- if node.EmailID != "" {
- thread.Count++
- if node.Date.After(thread.LatestAt) {
- thread.LatestAt = node.Date
- }
- if node.Sender != "" && !seenSenders[node.Sender] {
- thread.Senders = append(thread.Senders, node.Sender)
- seenSenders[node.Sender] = true
- }
- }
- for _, child := range node.Children {
- walkThread(child, thread, seenSenders)
- }
-}
-
-func containerDate(c *container) time.Time {
- if c == nil {
- return time.Time{}
- }
- if c.node != nil {
- return c.node.Date
- }
- var earliest time.Time
- for _, child := range c.children {
- date := containerDate(child)
- if earliest.IsZero() || (!date.IsZero() && date.Before(earliest)) {
- earliest = date
- }
- }
- return earliest
-}
-
-func containerKey(c *container) string {
- if c == nil {
- return ""
- }
- if c.node != nil && c.node.EmailID != "" {
- return c.node.EmailID
- }
- return c.id
-}
-
-func threadKey(n *ThreadNode) string {
- if n == nil {
- return ""
- }
- if n.EmailID != "" {
- return n.EmailID
- }
- for _, child := range n.Children {
- if key := threadKey(child); key != "" {
- return key
- }
- }
- return ""
-}
-
-func firstDisplaySubject(node *ThreadNode) string {
- if node == nil {
- return ""
- }
- if node.Subject != "" {
- return node.Subject
- }
- for _, child := range node.Children {
- if subject := firstDisplaySubject(child); subject != "" {
- return subject
- }
- }
- return ""
-}
@@ -1,154 +0,0 @@
-package threading
-
-import (
- "reflect"
- "testing"
- "time"
-)
-
-func TestBuildThreeMessageChain(t *testing.T) {
- base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
- threads := Build([]EmailHeader{
- {ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1", Sender: "a"},
- {ID: "<b@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2", Sender: "b"},
- {ID: "<c@example>", References: []string{"<a@example>", "<b@example>"}, Subject: "Re: Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3", Sender: "c"}, //nolint:dupword
- })
-
- if len(threads) != 1 {
- t.Fatalf("got %d threads, want 1", len(threads))
- }
- if threads[0].Count != 3 {
- t.Fatalf("got count %d, want 3", threads[0].Count)
- }
- if got := threads[0].Root.Children[0].Children[0].EmailID; got != "3" {
- t.Fatalf("got chain leaf %q, want 3", got)
- }
-}
-
-func TestBuildForkedThread(t *testing.T) {
- base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
- threads := Build([]EmailHeader{
- {ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
- {ID: "<c@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"},
- {ID: "<b@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2"},
- })
-
- if len(threads) != 1 {
- t.Fatalf("got %d threads, want 1", len(threads))
- }
- children := threads[0].Root.Children
- if len(children) != 2 {
- t.Fatalf("got %d children, want 2", len(children))
- }
- if children[0].EmailID != "2" || children[1].EmailID != "3" {
- t.Fatalf("got child order %q, %q; want 2, 3", children[0].EmailID, children[1].EmailID)
- }
-}
-
-func TestBuildMissingParentPlaceholderRoot(t *testing.T) {
- base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
- threads := Build([]EmailHeader{
- {ID: "<child@example>", References: []string{"<missing@example>"}, Subject: "Re: Foo", Date: base, EmailID: "child"},
- {ID: "<other@example>", References: []string{"<missing@example>"}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "other"},
- })
-
- if len(threads) != 1 {
- t.Fatalf("got %d threads, want 1", len(threads))
- }
- if threads[0].Root.EmailID != "" {
- t.Fatalf("got root EmailID %q, want placeholder", threads[0].Root.EmailID)
- }
- if len(threads[0].Root.Children) != 2 {
- t.Fatalf("got %d placeholder children, want 2", len(threads[0].Root.Children))
- }
-}
-
-func TestBuildSubjectFallbackGroupingForOrphans(t *testing.T) {
- base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
- threads := Build([]EmailHeader{
- {ID: "<a@example>", Subject: "Re: Foo", Date: base, EmailID: "1"},
- {ID: "<b@example>", Subject: "Fwd: foo", Date: base.Add(time.Minute), EmailID: "2"},
- {ID: "<c@example>", Subject: "Bar", Date: base.Add(2 * time.Minute), EmailID: "3"},
- })
-
- if len(threads) != 2 {
- t.Fatalf("got %d threads, want 2", len(threads))
- }
- var grouped Thread
- for _, thread := range threads {
- if thread.Subject == "foo" {
- grouped = thread
- break
- }
- }
- if grouped.Count != 2 {
- t.Fatalf("got grouped count %d, want 2", grouped.Count)
- }
-}
-
-func TestBuildSubjectFallbackGroupsLocalePrefixes(t *testing.T) {
- base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
- threads := Build([]EmailHeader{
- {ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
- {ID: "<b@example>", Subject: "SV: Foo", Date: base.Add(time.Minute), EmailID: "2"},
- {ID: "<c@example>", Subject: "RV: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"},
- {ID: "<d@example>", Subject: "Antw: Foo", Date: base.Add(3 * time.Minute), EmailID: "4"},
- })
-
- if len(threads) != 1 {
- t.Fatalf("got %d threads, want 1", len(threads))
- }
- if threads[0].Subject != "foo" {
- t.Fatalf("got subject %q, want foo", threads[0].Subject)
- }
- if threads[0].Count != 4 {
- t.Fatalf("got grouped count %d, want 4", threads[0].Count)
- }
-}
-
-func TestBuildEmptyReferencesList(t *testing.T) {
- threads := Build([]EmailHeader{
- {ID: "<a@example>", References: nil, Subject: "Foo", Date: time.Now(), EmailID: "1"},
- })
-
- if len(threads) != 1 {
- t.Fatalf("got %d threads, want 1", len(threads))
- }
- if threads[0].Root.EmailID != "1" {
- t.Fatalf("got root %q, want 1", threads[0].Root.EmailID)
- }
-}
-
-func TestBuildStableOrderingAcrossCalls(t *testing.T) {
- base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
- headers := []EmailHeader{
- {ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
- {ID: "<b@example>", Subject: "Bar", Date: base, EmailID: "2"},
- {ID: "<c@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base, EmailID: "3"},
- }
-
- first := Build(headers)
- second := Build(headers)
- if !reflect.DeepEqual(first, second) {
- t.Fatalf("Build output differed across calls:\n%#v\n%#v", first, second)
- }
-}
-
-func TestCanonicalSubjectNormalizesReplyAndForwardPrefixes(t *testing.T) {
- tests := map[string]string{
- "Re: Re: Foo": "foo", //nolint:dupword
- "Fwd: FW: Foo": "foo",
- "AW: WG: Tr: Foo": "foo",
- "ReΓ©: Resp: Foo": "foo",
- "SV: VS: RV: Foo": "foo",
- "ENC: Antw: Foo": "foo",
- "Odp: R: I: Foo": "foo",
- " Foo ": "foo",
- }
-
- for in, want := range tests {
- if got := canonicalSubject(in); got != want {
- t.Fatalf("canonicalSubject(%q) = %q, want %q", in, got, want)
- }
- }
-}
@@ -1,20 +0,0 @@
-package threading
-
-import (
- "regexp"
- "strings"
-)
-
-var subjectPrefixRE = regexp.MustCompile(`(?i)^(Re|Fwd|Fw|AW|WG|Tr|ReΓ©|Resp|SV|VS|RV|ENC|Antw|Odp|R|I)\s*:\s*`)
-
-func canonicalSubject(s string) string {
- s = strings.TrimSpace(s)
- for {
- next := subjectPrefixRE.ReplaceAllString(s, "")
- if next == s {
- break
- }
- s = strings.TrimSpace(next)
- }
- return strings.ToLower(strings.TrimSpace(s))
-}
@@ -13,6 +13,7 @@ import (
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
+ overlay "github.com/floatpane/bubble-overlay"
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/spellcheck"
"github.com/google/uuid"
@@ -1337,7 +1338,7 @@ func (m *Composer) overlaySpellPopup(view string, elementsBeforeBody []string) s
anchorCol = max(0, m.width-popupWidth)
}
- return overlayBlock(view, popup, anchorRow, anchorCol)
+ return overlay.Block(view, popup, anchorRow, anchorCol)
}
// renderSpellPopupLines builds the styled, bordered suggestion box and
@@ -11,9 +11,9 @@ import (
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
+ threading "github.com/floatpane/jwz-go"
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/fetcher"
- "github.com/floatpane/matcha/internal/threading"
"github.com/floatpane/matcha/theme"
)
@@ -1,58 +0,0 @@
-package tui
-
-import (
- "strings"
-
- "github.com/charmbracelet/x/ansi"
-)
-
-// overlayBlock paints the lines of block on top of base starting at the
-// (row, col) cell position. Lines that extend past the bottom of base are
-// appended. The result preserves existing ANSI styling around the
-// overlaid region.
-func overlayBlock(base string, block []string, row, col int) string {
- if len(block) == 0 {
- return base
- }
- lines := strings.Split(base, "\n")
- for i, overlay := range block {
- r := row + i
- for r >= len(lines) {
- lines = append(lines, "")
- }
- lines[r] = overlayLine(lines[r], overlay, col)
- }
- return strings.Join(lines, "\n")
-}
-
-// overlayLine returns base with overlay painted starting at column col.
-// Existing cells under the overlay are removed; cells to the left and
-// right of the overlay are preserved with their ANSI styling intact.
-// When col exceeds the visible width of base the gap is padded with
-// spaces.
-func overlayLine(base, overlay string, col int) string {
- if overlay == "" {
- return base
- }
- overlayWidth := ansi.StringWidth(overlay)
- baseWidth := ansi.StringWidth(base)
-
- left := ansi.Truncate(base, col, "")
- leftWidth := ansi.StringWidth(left)
-
- var pad string
- if leftWidth < col {
- pad = strings.Repeat(" ", col-leftWidth)
- }
-
- var right string
- rightStart := col + overlayWidth
- if rightStart < baseWidth {
- right = ansi.Cut(base, rightStart, baseWidth)
- }
-
- // Reset SGR after the overlay so the overlay's styles don't bleed
- // into the surrounding cells (the rest of the row may inherit ANSI
- // from earlier in the string).
- return left + pad + overlay + "\x1b[0m" + right
-}