~sircmpwn/aerc

Wire up threads backend for imap v3 PROPOSED

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

 14 files changed, 302 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/14287/mbox | git am -3
Learn more about email & git
View this thread in the archives

[PATCH v3 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>
---
 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 891b8a2..6e0eef2 100644
--- a/worker/imap/open.go
+++ b/worker/imap/open.go
@@ -95,3 +95,46 @@ func translateSortCriterions(
	}
	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 v3 2/2] Frontend support for threading Export this patch

Also implements deletion, configs, and adds docs.

Co-authored-by: Kevin Kuehler <keur@xcf.berkeley.edu>
---
This updated patch adds support for deleting messages (including
archive), previously this created a seg fault.

Also documents the new config value.

 config/aerc.conf.in    |   6 ++
 config/config.go       |   1 +
 config/triggers.go     |   2 +-
 doc/aerc-config.5.scd  |   5 ++
 lib/format/format.go   |   4 +-
 lib/msgstore.go        |  46 ++++++++++-
 widgets/account.go     |   8 ++
 widgets/msglist.go     | 175 ++++++++++++++++++++++++-----------------
 worker/imap/open.go    |   1 +
 worker/types/thread.go |  68 ++++++++++++++++
 10 files changed, 237 insertions(+), 79 deletions(-)

diff --git a/config/aerc.conf.in b/config/aerc.conf.in
index b9381a8..b5c0953 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 (does not work with sorting)
#
# 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/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index c96ea12..07680fc 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -108,6 +108,11 @@ These options are configured in the *[ui]* section of aerc.conf.

	Default: false

*threading-enabled*
	Enable a threaded viewing of messages, does not work with sorting and only supported with IMAP.

	Default: false

*new-message-bell*
	Ring the bell when a new message is received.

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..25a8e2b 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)
@@ -257,6 +284,8 @@ func (store *MessageStore) Update(msg types.WorkerMessage) {
		}
		store.results = newResults

		store.Threads = types.DeleteThreadElements(store.Threads, toDelete)

		update = true
	}

@@ -592,14 +621,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..4513b37 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 {
@@ -85,83 +86,37 @@ func (ml *MessageList) Draw(ctx *ui.Context) {
		needsHeaders []uint32
		row          int = 0
	)
	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 msg == nil {
			needsHeaders = append(needsHeaders, uid)
			ml.spinner.Draw(ctx.Subcontext(0, row, textWidth, 1))
			row += 1
			continue
		}

		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,
		})
	if ml.conf.Ui.ThreadingEnabled {
		threads := store.Threads
		counter := len(store.Uids())

		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(threads) - 1; i >= 0; i-- {
			threads[i].FormatThread(func(thread *types.Thread, threadFmt []rune) bool {
				counter -= 1
				if counter > len(store.Uids()) - 1 - ml.scroll  {
					return false
				}
				if ml.drawRow(textWidth, ctx, thread.Uid, counter, row, &needsHeaders, string(threadFmt)) {
					return true
				}
				row += 1
				return false
			})
			if row >= ctx.Height() {
				break
			}
		}
	} else {
		uids := store.Uids()

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

		row += 1
	}

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

	if len(uids) == 0 {
	if len(store.Uids()) == 0 {
		if store.Sorting {
			ml.spinner.Start()
			ml.spinner.Draw(ctx)
@@ -187,6 +142,82 @@ 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))
		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 6e0eef2..b2c3448 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..c860f7c 100644
--- a/worker/types/thread.go
+++ b/worker/types/thread.go
@@ -4,3 +4,71 @@ type Thread struct {
	Uid      uint32
	Children []*Thread
}

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


func DeleteThreadElements(threads []*Thread, toDelete map[uint32]interface{}) []*Thread {
	var newThreads []*Thread
	for _, thread := range threads {
		if _, deleted := toDelete[thread.Uid]; !deleted {
			// Don't delete this message, but check Children
			thread.Children = DeleteThreadElements(thread.Children, toDelete)
			newThreads = append(newThreads, thread)
		} else {
			// Delete this message and check/append Children
			children := DeleteThreadElements(thread.Children, toDelete)
			newThreads = append(newThreads, children...)
		}
	}
	return newThreads
}


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)