~sircmpwn/aerc

Wire up threads backend for imap v1 PROPOSED

y0ast: 2
 Wire up threads backend for imap
 Frontend support for threading

 13 files changed, 273 insertions(+), 82 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~sircmpwn/aerc/patches/14243/mbox | git am -3
Learn more about email & git
View this thread in the archives

[PATCH 1/2] Wire up threads backend for imap Export this patch

Add threaded message requests for imap based on go-imap-sortthread.

Co-authored-by: Kevin Kuehler <keur@xcf.berkeley.edu>
---
Depends on https://lists.sr.ht/~sircmpwn/aerc/patches/13893
This only works with IMAP servers that support THREAD, no manual
threading implementation is attempted.

 worker/imap/open.go      | 43 ++++++++++++++++++++++++++++++++++++++++
 worker/imap/worker.go    |  9 ++++++---
 worker/types/messages.go | 10 ++++++++++
 worker/types/thread.go   |  6 ++++++
 4 files changed, 65 insertions(+), 3 deletions(-)
 create mode 100644 worker/types/thread.go

diff --git a/worker/imap/open.go b/worker/imap/open.go
index 4605eb9..5d3b93b 100644
--- a/worker/imap/open.go
+++ b/worker/imap/open.go
@@ -95,3 +95,46 @@ func translageSortCriterions(
	}
	return result
}

func (imapw *IMAPWorker) handleDirectoryThreaded(
	msg *types.FetchDirectoryThreaded) {
	imapw.worker.Logger.Printf("Fetching threaded UID list")

	seqSet := &imap.SeqSet{}
	seqSet.AddRange(1, imapw.selected.Messages)
	threads, err := imapw.client.thread.UidThread(sortthread.References,
		&imap.SearchCriteria{SeqNum: seqSet})
	if err != nil {
		imapw.worker.PostMessage(&types.Error{
			Message: types.RespondTo(msg),
			Error:   err,
		}, nil)
	} else {
		aercThreads, count := convertThreads(threads)
		imapw.seqMap = make([]uint32, count)
		imapw.worker.PostMessage(&types.DirectoryThreaded{
			Message: types.RespondTo(msg),
			Threads: aercThreads,
		}, nil)
		imapw.worker.PostMessage(&types.Done{types.RespondTo(msg)}, nil)
	}
}

func convertThreads(threads []*sortthread.Thread) ([]*types.Thread, int) {
	if threads == nil {
		return nil, 0
	}
	conv := make([]*types.Thread, len(threads))
	count := 0

	for i := 0; i < len(threads); i++ {
		t := threads[i]
		children, childCount := convertThreads(t.Children)
		conv[i] = &types.Thread{
			Uid:      t.Id,
			Children: children,
		}
		count += childCount + 1
	}
	return conv, count
}
diff --git a/worker/imap/worker.go b/worker/imap/worker.go
index dab0afb..e48db2e 100644
--- a/worker/imap/worker.go
+++ b/worker/imap/worker.go
@@ -27,8 +27,9 @@ var errUnsupported = fmt.Errorf("unsupported command")

type imapClient struct {
	*client.Client
	idle *idle.IdleClient
	sort *sortthread.SortClient
	idle	 *idle.IdleClient
	thread   *sortthread.ThreadClient
	sort	 *sortthread.SortClient
}

type IMAPWorker struct {
@@ -157,7 +158,7 @@ func (w *IMAPWorker) handleMessage(msg types.WorkerMessage) error {
		}

		c.Updates = w.updates
		w.client = &imapClient{c, idle.NewClient(c), sortthread.NewSortClient(c)}
		w.client = &imapClient{c, idle.NewClient(c), sortthread.NewThreadClient(c), sortthread.NewSortClient(c)}
		w.worker.PostMessage(&types.Done{types.RespondTo(msg)}, nil)
	case *types.ListDirectories:
		w.handleListDirectories(msg)
@@ -165,6 +166,8 @@ func (w *IMAPWorker) handleMessage(msg types.WorkerMessage) error {
		w.handleOpenDirectory(msg)
	case *types.FetchDirectoryContents:
		w.handleFetchDirectoryContents(msg)
	case *types.FetchDirectoryThreaded:
		w.handleDirectoryThreaded(msg)
	case *types.CreateDirectory:
		w.handleCreateDirectory(msg)
	case *types.RemoveDirectory:
diff --git a/worker/types/messages.go b/worker/types/messages.go
index ab0e545..27fb131 100644
--- a/worker/types/messages.go
+++ b/worker/types/messages.go
@@ -81,11 +81,21 @@ type FetchDirectoryContents struct {
	SortCriteria []*SortCriterion
}

type FetchDirectoryThreaded struct {
	Message
	SortCriteria []*SortCriterion
}

type SearchDirectory struct {
	Message
	Argv []string
}

type DirectoryThreaded struct {
	Message
	Threads []*Thread
}

type CreateDirectory struct {
	Message
	Directory string
diff --git a/worker/types/thread.go b/worker/types/thread.go
new file mode 100644
index 0000000..265438f
--- /dev/null
+++ b/worker/types/thread.go
@@ -0,0 +1,6 @@
package types

type Thread struct {
	Uid      uint32
	Children []*Thread
}
-- 
2.21.1 (Apple Git-122.3)

[PATCH 2/2] Frontend support for threading Export this patch

Co-authored-by: Kevin Kuehler <keur@xcf.berkeley.edu>
---
This works locally for me, even when turning threading on at run time.

Probably the part that can use most work is in thread.go. The changes to
msglist.go look significant, but it's mostly taking a whole bunch of
lines out into a function.

I've left out the later commits from Kevin, and attempted to take the
minimum of his changes that produce a working setting.

 config/aerc.conf.in    |   6 ++
 config/config.go       |   1 +
 config/triggers.go     |   2 +-
 lib/format/format.go   |   4 +-
 lib/msgstore.go        |  44 ++++++++++-
 widgets/account.go     |   8 ++
 widgets/msglist.go     | 171 ++++++++++++++++++++++++-----------------
 worker/imap/open.go    |   1 +
 worker/types/thread.go |  50 ++++++++++++
 9 files changed, 208 insertions(+), 79 deletions(-)

diff --git a/config/aerc.conf.in b/config/aerc.conf.in
index b9381a8..050b53c 100644
--- a/config/aerc.conf.in
+++ b/config/aerc.conf.in
@@ -32,6 +32,12 @@ empty-message=(no messages)
# Default: (no folders)
empty-dirlist=(no folders)

#
# Enable threading in the ui
#
# Default: false
threading-enabled=false

# Enable mouse events in the ui, e.g. clicking and scrolling with the mousewheel
#
# Default: false
diff --git a/config/config.go b/config/config.go
index 3ae26c1..83739b7 100644
--- a/config/config.go
+++ b/config/config.go
@@ -37,6 +37,7 @@ type UIConfig struct {
	EmptyMessage        string        `ini:"empty-message"`
	EmptyDirlist        string        `ini:"empty-dirlist"`
	MouseEnabled        bool          `ini:"mouse-enabled"`
	ThreadingEnabled    bool          `ini:"threading-enabled"`
	NewMessageBell      bool          `ini:"new-message-bell"`
	Spinner             string        `ini:"spinner"`
	SpinnerDelimiter    string        `ini:"spinner-delimiter"`
diff --git a/config/triggers.go b/config/triggers.go
index 3187cf7..1bf31c8 100644
--- a/config/triggers.go
+++ b/config/triggers.go
@@ -39,7 +39,7 @@ func (trig *TriggersConfig) ExecNewEmail(account *AccountConfig,
			formatstr, args, err := format.ParseMessageFormat(
				account.From,
				part,
				conf.Ui.TimestampFormat, account.Name, 0, msg, false)
				conf.Ui.TimestampFormat, account.Name, 0, msg, false, "")
			if err != nil {
				return "", err
			}
diff --git a/lib/format/format.go b/lib/format/format.go
index 66dced1..3b230bc 100644
--- a/lib/format/format.go
+++ b/lib/format/format.go
@@ -52,7 +52,7 @@ func ParseMessageFormat(
	fromAddress string,
	format string, timestampformat string,
	accountName string, number int, msg *models.MessageInfo,
	marked bool) (string,
	marked bool, subjectThread string) (string,
	[]interface{}, error) {
	retval := make([]byte, 0, len(format))
	var args []interface{}
@@ -243,7 +243,7 @@ func ParseMessageFormat(
					errors.New("no envelope available for this message")
			}
			retval = append(retval, 's')
			args = append(args, msg.Envelope.Subject)
			args = append(args, subjectThread+msg.Envelope.Subject)
		case 't':
			if msg.Envelope == nil {
				return "", nil,
diff --git a/lib/msgstore.go b/lib/msgstore.go
index b95b68f..515cde8 100644
--- a/lib/msgstore.go
+++ b/lib/msgstore.go
@@ -18,6 +18,7 @@ type MessageStore struct {

	// Ordered list of known UIDs
	uids []uint32
	Threads []*types.Thread

	selected        int
	bodyCallbacks   map[uint32][]func(*types.FullMessage)
@@ -35,6 +36,8 @@ type MessageStore struct {

	defaultSortCriteria []*types.SortCriterion

	thread		bool

	// Map of uids we've asked the worker to fetch
	onUpdate       func(store *MessageStore) // TODO: multiple onUpdate handlers
	onUpdateDirs   func()
@@ -52,6 +55,7 @@ type MessageStore struct {
func NewMessageStore(worker *types.Worker,
	dirInfo *models.DirectoryInfo,
	defaultSortCriteria []*types.SortCriterion,
	thread bool,
	triggerNewEmail func(*models.MessageInfo),
	triggerDirectoryChange func()) *MessageStore {

@@ -67,6 +71,8 @@ func NewMessageStore(worker *types.Worker,
		bodyCallbacks:   make(map[uint32][]func(*types.FullMessage)),
		headerCallbacks: make(map[uint32][]func(*types.MessageInfo)),

		thread: thread,

		defaultSortCriteria: defaultSortCriteria,

		pendingBodies:  make(map[uint32]interface{}),
@@ -189,6 +195,27 @@ func (store *MessageStore) Update(msg types.WorkerMessage) {
		store.Messages = newMap
		store.uids = msg.Uids
		update = true
	case *types.DirectoryThreaded:
		var uids []uint32
		newMap := make(map[uint32]*models.MessageInfo)

		for i := len(msg.Threads) - 1; i >= 0; i-- {
			msg.Threads[i].FormatThread(func(t *types.Thread, x []rune) bool {
				uid := t.Uid
				uids = append([]uint32{uid}, uids...)
				if msg, ok := store.Messages[uid]; ok {
					newMap[uid] = msg
				} else {
					newMap[uid] = nil
					directoryChange = true
				}
				return false
			})
		}
		store.Messages = newMap
		store.uids = uids
		store.Threads = msg.Threads
		update = true
	case *types.MessageInfo:
		if existing, ok := store.Messages[msg.Info.Uid]; ok && existing != nil {
			merge(existing, msg.Info)
@@ -592,14 +619,23 @@ func (store *MessageStore) ModifyLabels(uids []uint32, add, remove []string,

func (store *MessageStore) Sort(criteria []*types.SortCriterion, cb func()) {
	store.Sorting = true
	store.worker.PostAction(&types.FetchDirectoryContents{
		SortCriteria: criteria,
	}, func(msg types.WorkerMessage) {

	handle_return := func(msg types.WorkerMessage) {
		store.Sorting = false
		if cb != nil {
			cb()
		}
	})
	}

	if store.thread {
		store.worker.PostAction(&types.FetchDirectoryThreaded{
			SortCriteria: criteria,
		}, handle_return)
	} else {
		store.worker.PostAction(&types.FetchDirectoryContents{
			SortCriteria: criteria,
		}, handle_return)
	}
}

// returns the index of needle in haystack or -1 if not found
diff --git a/widgets/account.go b/widgets/account.go
index f279513..5f661d4 100644
--- a/widgets/account.go
+++ b/widgets/account.go
@@ -246,6 +246,7 @@ func (acct *AccountView) onMessage(msg types.WorkerMessage) {
		} else {
			store = lib.NewMessageStore(acct.worker, msg.Info,
				acct.getSortCriteria(),
				acct.conf.Ui.ThreadingEnabled,
				func(msg *models.MessageInfo) {
					acct.conf.Triggers.ExecNewEmail(acct.acct,
						acct.conf, msg)
@@ -263,6 +264,13 @@ func (acct *AccountView) onMessage(msg types.WorkerMessage) {
			}
			store.Update(msg)
		}
	case *types.DirectoryThreaded:
		if store, ok := acct.dirlist.SelectedMsgStore(); ok {
			if acct.msglist.Store() == nil {
				acct.msglist.SetStore(store)
			}
			store.Update(msg)
		}
	case *types.FullMessage:
		if store, ok := acct.dirlist.SelectedMsgStore(); ok {
			store.Update(msg)
diff --git a/widgets/msglist.go b/widgets/msglist.go
index 09b0868..8c24118 100644
--- a/widgets/msglist.go
+++ b/widgets/msglist.go
@@ -13,6 +13,7 @@ import (
	"git.sr.ht/~sircmpwn/aerc/lib/format"
	"git.sr.ht/~sircmpwn/aerc/lib/ui"
	"git.sr.ht/~sircmpwn/aerc/models"
	"git.sr.ht/~sircmpwn/aerc/worker/types"
)

type MessageList struct {
@@ -84,84 +85,33 @@ func (ml *MessageList) Draw(ctx *ui.Context) {
	var (
		needsHeaders []uint32
		row          int = 0
		units        int
	)
	uids := store.Uids()

	for i := len(uids) - 1 - ml.scroll; i >= 0; i-- {
		uid := uids[i]
		msg := store.Messages[uid]

		if row >= ctx.Height() {
			break
		}
	if ml.conf.Ui.ThreadingEnabled {
		threads := store.Threads
		units = len(threads)

		if msg == nil {
			needsHeaders = append(needsHeaders, uid)
			ml.spinner.Draw(ctx.Subcontext(0, row, textWidth, 1))
			row += 1
			continue
		for i := len(threads) - 1; i >= 0; i-- {
			threads[i].FormatThread(func(thread *types.Thread, threadFmt []rune) bool {
				if ml.drawRow(textWidth, ctx, thread.Uid, row, row, &needsHeaders, string(threadFmt)) {
					return true
				}
				row += 1
				return false
			})
		}
	} else {
		uids := store.Uids()
		units = len(uids)

		uiConfig := ml.conf.GetUiConfig(map[config.ContextType]string{
			config.UI_CONTEXT_ACCOUNT: ml.aerc.SelectedAccount().AccountConfig().Name,
			config.UI_CONTEXT_FOLDER:  ml.aerc.SelectedAccount().Directories().Selected(),
			config.UI_CONTEXT_SUBJECT: msg.Envelope.Subject,
		})

		so := config.STYLE_MSGLIST_DEFAULT

		// deleted message
		if _, ok := store.Deleted[msg.Uid]; ok {
			so = config.STYLE_MSGLIST_DELETED
		}
		// unread message
		seen := false
		flagged := false
		for _, flag := range msg.Flags {
			switch flag {
			case models.SeenFlag:
				seen = true
			case models.FlaggedFlag:
				flagged = true
		for i := len(uids) - 1 - ml.scroll; i >= 0; i-- {
			uid := uids[i]
			if ml.drawRow(textWidth, ctx, uid, i, row, &needsHeaders, "") {
				break
			}
			row += 1
		}

		if seen {
			so = config.STYLE_MSGLIST_READ
		} else {
			so = config.STYLE_MSGLIST_UNREAD
		}

		if flagged {
			so = config.STYLE_MSGLIST_FLAGGED
		}

		// marked message
		if store.IsMarked(msg.Uid) {
			so = config.STYLE_MSGLIST_MARKED
		}

		style := uiConfig.GetStyle(so)

		// current row
		if row == ml.store.SelectedIndex()-ml.scroll {
			style = uiConfig.GetStyleSelected(so)
		}

		ctx.Fill(0, row, ctx.Width(), 1, ' ', style)
		fmtStr, args, err := format.ParseMessageFormat(
			ml.aerc.SelectedAccount().acct.From,
			uiConfig.IndexFormat,
			uiConfig.TimestampFormat, "", i, msg, store.IsMarked(uid))
		if err != nil {
			ctx.Printf(0, row, style, "%v", err)
		} else {
			line := fmt.Sprintf(fmtStr, args...)
			line = runewidth.Truncate(line, textWidth, "…")
			ctx.Printf(0, row, style, "%s", line)
		}

		row += 1
	}

	if needScrollbar {
@@ -169,7 +119,7 @@ func (ml *MessageList) Draw(ctx *ui.Context) {
		ml.drawScrollbar(scrollbarCtx, percentVisible)
	}

	if len(uids) == 0 {
	if units == 0 {
		if store.Sorting {
			ml.spinner.Start()
			ml.spinner.Draw(ctx)
@@ -187,6 +137,83 @@ func (ml *MessageList) Draw(ctx *ui.Context) {
	}
}

func (ml *MessageList) drawRow(textWidth int, ctx *ui.Context, uid uint32, number int, row int, needsHeaders *[]uint32, subjectThread string) bool {
	store := ml.store
	msg := store.Messages[uid]

	if row >= ctx.Height() {
		return true
	}

	if msg == nil {
		*needsHeaders = append(*needsHeaders, uid)
		ml.spinner.Draw(ctx.Subcontext(0, row, textWidth, 1))
		row += 1
		return false
	}

	uiConfig := ml.conf.GetUiConfig(map[config.ContextType]string{
		config.UI_CONTEXT_ACCOUNT: ml.aerc.SelectedAccount().AccountConfig().Name,
		config.UI_CONTEXT_FOLDER:  ml.aerc.SelectedAccount().Directories().Selected(),
		config.UI_CONTEXT_SUBJECT: msg.Envelope.Subject,
	})

	so := config.STYLE_MSGLIST_DEFAULT

	// deleted message
	if _, ok := store.Deleted[msg.Uid]; ok {
		so = config.STYLE_MSGLIST_DELETED
	}
	// unread message
	seen := false
	flagged := false
	for _, flag := range msg.Flags {
		switch flag {
		case models.SeenFlag:
			seen = true
		case models.FlaggedFlag:
			flagged = true
		}
	}

	if seen {
		so = config.STYLE_MSGLIST_READ
	} else {
		so = config.STYLE_MSGLIST_UNREAD
	}

	if flagged {
		so = config.STYLE_MSGLIST_FLAGGED
	}

	// marked message
	if store.IsMarked(msg.Uid) {
		so = config.STYLE_MSGLIST_MARKED
	}

	style := uiConfig.GetStyle(so)

	// current row
	if row == ml.store.SelectedIndex()-ml.scroll {
		style = uiConfig.GetStyleSelected(so)
	}

	ctx.Fill(0, row, ctx.Width(), 1, ' ', style)
	fmtStr, args, err := format.ParseMessageFormat(
		ml.aerc.SelectedAccount().acct.From,
		uiConfig.IndexFormat,
		uiConfig.TimestampFormat, "", number, msg, store.IsMarked(uid), subjectThread)
	if err != nil {
		ctx.Printf(0, row, style, "%v", err)
	} else {
		line := fmt.Sprintf(fmtStr, args...)
		line = runewidth.Truncate(line, textWidth, "…")
		ctx.Printf(0, row, style, "%s", line)
	}

	return false
}

func (ml *MessageList) drawScrollbar(ctx *ui.Context, percentVisible float64) {
	gutterStyle := tcell.StyleDefault
	pillStyle := tcell.StyleDefault.Reverse(true)
diff --git a/worker/imap/open.go b/worker/imap/open.go
index 5d3b93b..726d208 100644
--- a/worker/imap/open.go
+++ b/worker/imap/open.go
@@ -111,6 +111,7 @@ func (imapw *IMAPWorker) handleDirectoryThreaded(
		}, nil)
	} else {
		aercThreads, count := convertThreads(threads)
		imapw.worker.Logger.Printf("Found %d threads", count)
		imapw.seqMap = make([]uint32, count)
		imapw.worker.PostMessage(&types.DirectoryThreaded{
			Message: types.RespondTo(msg),
diff --git a/worker/types/thread.go b/worker/types/thread.go
index 265438f..7db68c6 100644
--- a/worker/types/thread.go
+++ b/worker/types/thread.go
@@ -4,3 +4,53 @@ type Thread struct {
	Uid      uint32
	Children []*Thread
}

func (t *Thread) FormatThread(cb func(*Thread, []rune) bool) {
	cb(t, []rune{})
	walkThreads(t.Children, []rune{}, cb)
}

var (
	BINDENT []rune = []rune{'\u0020', '\u0020'}
	CINDENT []rune = []rune{'\u2502', '\u0020'}
	LARROW  []rune = []rune{'\u2514', '\u2500', '\u003e'}
	TARROW  []rune = []rune{'\u252c', '\u2500', '\u003e'}
	IARROW  []rune = []rune{'\u251c', '\u2500', '\u003e'}
)

func walkThreads(threads []*Thread, threadFmt []rune,
	cb func(*Thread, []rune) bool) {
	if threads == nil {
		return
	}

	var (
		threadPrefixStart     []rune = append(threadFmt, TARROW...)
		threadPrefix          []rune = append(threadFmt, IARROW...)
		threadPrefixConnected []rune = append(threadFmt, LARROW...)
		nextThread            []rune = append(threadFmt, BINDENT...)
		nextThreadConnected   []rune = append(threadFmt, CINDENT...)
	)


	for i := len(threads) - 1; i >= 0; i-- {
		t := threads[i]
		if i == len(threads) - 1 && len(threads) > 1 {
			if cb(t, threadPrefixStart) {
				return
			}
			walkThreads(t.Children, nextThreadConnected, cb)
		} else if i > 0 && len(threads) > 1 {
			if cb(t, threadPrefix) {
				return
			}
			walkThreads(t.Children, nextThreadConnected, cb)
		} else {
			if cb(t, threadPrefixConnected) {
				return
			}
			walkThreads(t.Children, nextThread, cb)
		}
	}

}
-- 
2.21.1 (Apple Git-122.3)