From 1428b58f68c7488093318a691caeb604f632fc2d Mon Sep 17 00:00:00 2001 From: Ayyan Date: Sat, 7 Feb 2026 01:43:34 -0500 Subject: [PATCH] perf(ui/chat): cache rendered message TextViews in messages list (#737) --- internal/ui/chat/messages_list.go | 32 ++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index 349a652..d9ac3ee 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -41,6 +41,10 @@ type messagesList struct { messages []discord.Message renderer *markdown.Renderer + // itemByID caches fully built message TextViews. ScrollList may ask the + // builder repeatedly during drawing, so we avoid reparsing markdown and + // reconstructing TextViews unless a message actually changed. + itemByID map[discord.MessageID]*tview.TextView fetchingMembers struct { mu sync.Mutex @@ -56,6 +60,7 @@ func newMessagesList(cfg *config.Config, chatView *View) *messagesList { cfg: cfg, chatView: chatView, renderer: markdown.NewRenderer(cfg.Theme.MessagesList), + itemByID: make(map[discord.MessageID]*tview.TextView), } ml.Box = ui.ConfigureBox(ml.Box, &cfg.Theme) @@ -68,6 +73,7 @@ func newMessagesList(cfg *config.Config, chatView *View) *messagesList { func (ml *messagesList) reset() { ml.messages = nil + clear(ml.itemByID) ml. Clear(). SetBuilder(ml.buildItem). @@ -86,11 +92,16 @@ func (ml *messagesList) setTitle(channel discord.Channel) { func (ml *messagesList) setMessages(messages []discord.Message) { ml.messages = slices.Clone(messages) slices.Reverse(ml.messages) + // New channel payload / refetch: replace the cache wholesale to keep it in + // lockstep with the current message slice. + clear(ml.itemByID) ml.MarkDirty() } func (ml *messagesList) addMessage(message discord.Message) { ml.messages = append(ml.messages, message) + // Defensive invalidation for ID reuse/edits delivered out-of-order. + delete(ml.itemByID, message.ID) ml.MarkDirty() } @@ -100,6 +111,7 @@ func (ml *messagesList) setMessage(index int, message discord.Message) { } ml.messages[index] = message + delete(ml.itemByID, message.ID) ml.MarkDirty() } @@ -108,6 +120,7 @@ func (ml *messagesList) deleteMessage(index int) { return } + delete(ml.itemByID, ml.messages[index].ID) ml.messages = append(ml.messages[:index], ml.messages[index+1:]...) ml.MarkDirty() } @@ -122,11 +135,16 @@ func (ml *messagesList) buildItem(index int, cursor int) tview.ScrollListItem { } message := ml.messages[index] - tv := tview.NewTextView(). - SetWrap(true). - SetWordWrap(true). - SetDynamicColors(true). - SetText(ml.renderMessage(message)) + tv, ok := ml.itemByID[message.ID] + if !ok { + tv = tview.NewTextView(). + SetWrap(true). + SetWordWrap(true). + SetDynamicColors(true). + SetText(ml.renderMessage(message)) + ml.itemByID[message.ID] = tv + } + // Selection state is visual only; we mutate style on the cached view. if index == cursor { tv.SetTextStyle(ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style) } else { @@ -391,6 +409,10 @@ func (ml *messagesList) _select(name string) { older := slices.Clone(messages) slices.Reverse(older) + // Defensive invalidation if Discord returns overlapping windows. + for _, message := range older { + delete(ml.itemByID, message.ID) + } ml.messages = slices.Concat(older, ml.messages) cursor = len(messages) - 1 }