Collection optimizations.

This commit is contained in:
Burak Kaan Köse
2026-02-08 01:41:09 +01:00
parent 5bfa61a218
commit 9f13bcd991
@@ -33,6 +33,9 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
// Cache uniqueId to MailItemViewModel for faster GetMailItemContainer lookups // Cache uniqueId to MailItemViewModel for faster GetMailItemContainer lookups
private readonly ConcurrentDictionary<Guid, MailItemViewModel> _uniqueIdToMailItemMap = new(); private readonly ConcurrentDictionary<Guid, MailItemViewModel> _uniqueIdToMailItemMap = new();
// Cache uniqueId to ThreadMailItemViewModel for O(1) thread membership checks
private readonly ConcurrentDictionary<Guid, ThreadMailItemViewModel> _uniqueIdToThreadMap = new();
public event EventHandler<MailItemViewModel> MailItemRemoved; public event EventHandler<MailItemViewModel> MailItemRemoved;
public event EventHandler ItemSelectionChanged; public event EventHandler ItemSelectionChanged;
@@ -110,6 +113,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
_threadIdToItemsMap.Clear(); _threadIdToItemsMap.Clear();
_itemToGroupMap.Clear(); _itemToGroupMap.Clear();
_uniqueIdToMailItemMap.Clear(); _uniqueIdToMailItemMap.Clear();
_uniqueIdToThreadMap.Clear();
}); });
} }
@@ -123,25 +127,30 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
private void UpdateUniqueIdHashes(IMailHashContainer itemContainer, bool isAdd) private void UpdateUniqueIdHashes(IMailHashContainer itemContainer, bool isAdd)
{ {
foreach (var item in itemContainer.GetContainingIds()) if (isAdd)
{ {
if (isAdd) if (itemContainer is MailItemViewModel mailItemVM)
{ {
if (MailCopyIdHashSet.TryAdd(item, true)) MailCopyIdHashSet.TryAdd(mailItemVM.MailCopy.UniqueId, true);
_uniqueIdToMailItemMap[mailItemVM.MailCopy.UniqueId] = mailItemVM;
}
else if (itemContainer is ThreadMailItemViewModel threadVM)
{
foreach (var email in threadVM.ThreadEmails)
{ {
// Update the uniqueId to MailItemViewModel cache MailCopyIdHashSet.TryAdd(email.MailCopy.UniqueId, true);
if (itemContainer is MailItemViewModel mailItemVM) _uniqueIdToMailItemMap[email.MailCopy.UniqueId] = email;
{ _uniqueIdToThreadMap[email.MailCopy.UniqueId] = threadVM;
_uniqueIdToMailItemMap[item] = mailItemVM;
}
} }
} }
else }
else
{
foreach (var id in itemContainer.GetContainingIds())
{ {
if (MailCopyIdHashSet.TryRemove(item, out _)) MailCopyIdHashSet.TryRemove(id, out _);
{ _uniqueIdToMailItemMap.TryRemove(id, out _);
_uniqueIdToMailItemMap.TryRemove(item, out _); _uniqueIdToThreadMap.TryRemove(id, out _);
}
} }
} }
} }
@@ -156,19 +165,15 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
if (isAdd) if (isAdd)
{ {
// TODO: Sometimes the key is not present in the dict. var list = _threadIdToItemsMap.GetOrAdd(threadId, _ => new List<IMailListItem>());
if (!_threadIdToItemsMap.ContainsKey(threadId)) list.Add(item);
{
_threadIdToItemsMap[threadId] = new List<IMailListItem>();
}
_threadIdToItemsMap[threadId].Add(item);
} }
else else
{ {
if (_threadIdToItemsMap.ContainsKey(threadId)) if (_threadIdToItemsMap.TryGetValue(threadId, out var list))
{ {
_threadIdToItemsMap[threadId].Remove(item); list.Remove(item);
if (_threadIdToItemsMap[threadId].Count == 0) if (list.Count == 0)
{ {
_threadIdToItemsMap.TryRemove(threadId, out _); _threadIdToItemsMap.TryRemove(threadId, out _);
} }
@@ -199,12 +204,12 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
private IMailListItem FindThreadableItem(string threadId) private IMailListItem FindThreadableItem(string threadId)
{ {
if (string.IsNullOrEmpty(threadId) || !_threadIdToItemsMap.ContainsKey(threadId)) if (string.IsNullOrEmpty(threadId) || !_threadIdToItemsMap.TryGetValue(threadId, out var items))
{ {
return null; return null;
} }
return _threadIdToItemsMap[threadId].FirstOrDefault(); return items.FirstOrDefault();
} }
/// <summary> /// <summary>
@@ -224,19 +229,20 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
/// <returns>The MailItemViewModel if found, otherwise null.</returns> /// <returns>The MailItemViewModel if found, otherwise null.</returns>
public MailItemViewModel Find(Guid uniqueId) public MailItemViewModel Find(Guid uniqueId)
{ {
// First check the cache for fast lookup // Fast path: check the cache for O(1) lookup
if (_uniqueIdToMailItemMap.TryGetValue(uniqueId, out var cachedMailItem)) if (_uniqueIdToMailItemMap.TryGetValue(uniqueId, out var cachedMailItem))
{ {
return cachedMailItem; return cachedMailItem;
} }
// If not in cache, search through all groups // Fallback: scan all groups and populate caches
foreach (var group in _mailItemSource) foreach (var group in _mailItemSource)
{ {
foreach (var item in group) foreach (var item in group)
{ {
if (item is MailItemViewModel mailItem && mailItem.MailCopy.UniqueId == uniqueId) if (item is MailItemViewModel mailItem && mailItem.MailCopy.UniqueId == uniqueId)
{ {
_uniqueIdToMailItemMap[uniqueId] = mailItem;
return mailItem; return mailItem;
} }
else if (item is ThreadMailItemViewModel threadItem) else if (item is ThreadMailItemViewModel threadItem)
@@ -244,6 +250,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
var foundInThread = threadItem.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == uniqueId); var foundInThread = threadItem.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == uniqueId);
if (foundInThread != null) if (foundInThread != null)
{ {
_uniqueIdToMailItemMap[uniqueId] = foundInThread;
_uniqueIdToThreadMap[uniqueId] = threadItem;
return foundInThread; return foundInThread;
} }
} }
@@ -320,15 +328,21 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
// Update ThreadId cache before modifying the thread // Update ThreadId cache before modifying the thread
UpdateThreadIdCache(threadViewModel, false); UpdateThreadIdCache(threadViewModel, false);
var newMailItem = new MailItemViewModel(addedItem);
await ExecuteUIThread(() => await ExecuteUIThread(() =>
{ {
var newMailItem = new MailItemViewModel(addedItem);
threadViewModel.AddEmail(newMailItem); threadViewModel.AddEmail(newMailItem);
}); });
// Update ThreadId cache after modifying the thread // Update ThreadId cache after modifying the thread
UpdateThreadIdCache(threadViewModel, true); UpdateThreadIdCache(threadViewModel, true);
// Update caches for the new mail item (use the actual instance, not a throwaway)
MailCopyIdHashSet.TryAdd(addedItem.UniqueId, true);
_uniqueIdToMailItemMap[addedItem.UniqueId] = newMailItem;
_uniqueIdToThreadMap[addedItem.UniqueId] = threadViewModel;
var newGroupKey = GetGroupingKey(threadViewModel); var newGroupKey = GetGroupingKey(threadViewModel);
if (!existingGroupKey.Equals(newGroupKey)) if (!existingGroupKey.Equals(newGroupKey))
@@ -339,8 +353,6 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
{ {
await ExecuteUIThread(() => { threadViewModel.ThreadEmails = threadViewModel.ThreadEmails; }); await ExecuteUIThread(() => { threadViewModel.ThreadEmails = threadViewModel.ThreadEmails; });
} }
UpdateUniqueIdHashes(new MailItemViewModel(addedItem), true);
} }
private async Task HandleNewThreadAsync(ObservableGroup<object, IMailListItem> group, MailItemViewModel item, MailCopy addedItem) private async Task HandleNewThreadAsync(ObservableGroup<object, IMailListItem> group, MailItemViewModel item, MailCopy addedItem)
@@ -459,6 +471,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
{ {
MailCopyIdHashSet.Clear(); MailCopyIdHashSet.Clear();
_threadIdToItemsMap.Clear(); _threadIdToItemsMap.Clear();
_uniqueIdToThreadMap.Clear();
} }
var itemsList = items as List<MailItemViewModel> ?? items.ToList(); var itemsList = items as List<MailItemViewModel> ?? items.ToList();
@@ -630,34 +643,19 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
public MailItemContainer GetMailItemContainer(Guid uniqueMailId) public MailItemContainer GetMailItemContainer(Guid uniqueMailId)
{ {
// Try cache first for fast lookup // Fast path: use caches for O(1) lookup
if (_uniqueIdToMailItemMap.TryGetValue(uniqueMailId, out var cachedMailItem)) if (_uniqueIdToMailItemMap.TryGetValue(uniqueMailId, out var cachedMailItem))
{ {
// Check if it's in a thread if (_uniqueIdToThreadMap.TryGetValue(uniqueMailId, out var threadVM))
if (_itemToGroupMap.TryGetValue(cachedMailItem, out var cachedGroup))
{ {
return new MailItemContainer(cachedMailItem); return new MailItemContainer(cachedMailItem, threadVM);
}
// Check all threads for this mail item
foreach (var group in _mailItemSource)
{
foreach (var item in group)
{
if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(uniqueMailId))
{
return new MailItemContainer(cachedMailItem, threadMailItemViewModel);
}
}
} }
return new MailItemContainer(cachedMailItem); return new MailItemContainer(cachedMailItem);
} }
// Fallback to full search if not in cache // Fallback: scan all groups and populate caches
var groupCount = _mailItemSource.Count; for (int i = 0; i < _mailItemSource.Count; i++)
for (int i = 0; i < groupCount; i++)
{ {
var group = _mailItemSource[i]; var group = _mailItemSource[i];
@@ -677,6 +675,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
if (singleItemViewModel != null) if (singleItemViewModel != null)
{ {
_uniqueIdToMailItemMap[uniqueMailId] = singleItemViewModel; _uniqueIdToMailItemMap[uniqueMailId] = singleItemViewModel;
_uniqueIdToThreadMap[uniqueMailId] = threadMailItemViewModel;
} }
return new MailItemContainer(singleItemViewModel, threadMailItemViewModel); return new MailItemContainer(singleItemViewModel, threadMailItemViewModel);
@@ -824,96 +823,88 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
// This item doesn't exist in the list. // This item doesn't exist in the list.
if (!MailCopyIdHashSet.ContainsKey(removeItem.UniqueId)) return; if (!MailCopyIdHashSet.ContainsKey(removeItem.UniqueId)) return;
// Check all items for whether this item should be threaded with them. if (_uniqueIdToThreadMap.TryGetValue(removeItem.UniqueId, out var threadMailItemViewModel))
bool shouldExit = false;
var groupCount = _mailItemSource.Count;
for (int i = 0; i < groupCount; i++)
{ {
if (shouldExit) break; // Item is inside a thread - use cached lookups instead of scanning all groups.
var group = FindGroupContainingItem(threadMailItemViewModel);
if (group == null) return;
var group = _mailItemSource[i]; var removalItem = threadMailItemViewModel.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == removeItem.UniqueId);
if (removalItem == null) return;
for (int k = 0; k < group.Count; k++) // Update ThreadId cache before modifying the thread
UpdateThreadIdCache(threadMailItemViewModel, false);
await ExecuteUIThread(() => { threadMailItemViewModel.RemoveEmail(removalItem); });
// Always clean up the removed item's hashes (fixes leak when thread converts to single)
UpdateUniqueIdHashes(removalItem, false);
// Update ThreadId cache after modifying the thread
if (threadMailItemViewModel.EmailCount > 0)
{ {
var item = group[k]; UpdateThreadIdCache(threadMailItemViewModel, true);
}
if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(removeItem.UniqueId)) if (threadMailItemViewModel.EmailCount == 1)
{
// Convert to single item.
var singleViewModel = threadMailItemViewModel.ThreadEmails.First();
var groupKey = GetGroupingKey(singleViewModel);
await RemoveItemInternalAsync(group, threadMailItemViewModel);
await InsertItemInternalAsync(groupKey, singleViewModel);
// If thread->single conversion is being done, we should ignore it for non-draft items.
// eg. Deleting a reply message from draft folder. Single non-draft item should not be re-added.
if (PruneSingleNonDraftItems && !singleViewModel.IsDraft)
{ {
var removalItem = threadMailItemViewModel.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == removeItem.UniqueId); var newGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey);
if (newGroup != null)
if (removalItem == null) return;
// Threads' Id is equal to the last item they hold.
// We can't do Id check here because that'd remove the whole thread.
/* Remove item from the thread.
* If thread had 1 item inside:
* -> Remove the thread and insert item as single item.
* If thread had 0 item inside:
* -> Remove the thread.
*/
var oldGroupKey = GetGroupingKey(threadMailItemViewModel);
// Update ThreadId cache before modifying the thread
UpdateThreadIdCache(threadMailItemViewModel, false);
await ExecuteUIThread(() => { threadMailItemViewModel.RemoveEmail(removalItem); });
// Update ThreadId cache after modifying the thread
if (threadMailItemViewModel.EmailCount > 0)
{ {
UpdateThreadIdCache(threadMailItemViewModel, true); await RemoveItemInternalAsync(newGroup, singleViewModel);
} }
}
}
else if (threadMailItemViewModel.EmailCount == 0)
{
await RemoveItemInternalAsync(group, threadMailItemViewModel);
}
}
else
{
// Standalone item - use cached lookup.
IMailListItem mailItem = null;
ObservableGroup<object, IMailListItem> group = null;
if (threadMailItemViewModel.EmailCount == 1) if (_uniqueIdToMailItemMap.TryGetValue(removeItem.UniqueId, out var cachedItem))
{
mailItem = cachedItem;
group = FindGroupContainingItem(mailItem);
}
// Fallback to scan if not in cache
if (mailItem == null || group == null)
{
for (int i = 0; i < _mailItemSource.Count; i++)
{
var g = _mailItemSource[i];
for (int k = 0; k < g.Count; k++)
{ {
// Convert to single item. if (g[k] is MailItemViewModel mvm && mvm.MailCopy.UniqueId == removeItem.UniqueId)
var singleViewModel = threadMailItemViewModel.ThreadEmails.First();
var groupKey = GetGroupingKey(singleViewModel);
await RemoveItemInternalAsync(group, threadMailItemViewModel);
await InsertItemInternalAsync(groupKey, singleViewModel);
// If thread->single conversion is being done, we should ignore it for non-draft items.
// eg. Deleting a reply message from draft folder. Single non-draft item should not be re-added.
if (PruneSingleNonDraftItems && !singleViewModel.IsDraft)
{ {
// This item should not be here anymore. mailItem = mvm;
// It's basically a reply mail in Draft folder. group = g;
var newGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey); break;
if (newGroup != null)
{
await RemoveItemInternalAsync(newGroup, singleViewModel);
}
} }
} }
else if (threadMailItemViewModel.EmailCount == 0) if (mailItem != null) break;
{
await RemoveItemInternalAsync(group, threadMailItemViewModel);
}
else
{
// Item inside the thread is removed - update hash
UpdateUniqueIdHashes(removalItem, false);
}
shouldExit = true;
break;
} }
else if (item is MailItemViewModel mailItemViewModel && mailItemViewModel.MailCopy.UniqueId == removeItem.UniqueId) }
{
await RemoveItemInternalAsync(group, item);
shouldExit = true; if (mailItem != null && group != null)
{
break; await RemoveItemInternalAsync(group, mailItem);
}
} }
} }
@@ -1045,7 +1036,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
} }
public Task SelectAllAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => a.IsSelected = true, true); public Task SelectAllAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => a.IsSelected = true, true);
public Task UnselectAllAsync(IMailListItem exceptItem = null) => ExecuteWithoutRaiseSelectionChangedAsync(a => a.IsSelected = false && a != exceptItem, true); public Task UnselectAllAsync(IMailListItem exceptItem = null) => ExecuteWithoutRaiseSelectionChangedAsync(a => { if (a != exceptItem) a.IsSelected = false; }, true);
public Task CollapseAllThreadsAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => { if (a is ThreadMailItemViewModel thread) thread.IsThreadExpanded = false; }, true); public Task CollapseAllThreadsAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => { if (a is ThreadMailItemViewModel thread) thread.IsThreadExpanded = false; }, true);
private Task ExecuteUIThread(Action action) => CoreDispatcher?.ExecuteOnUIThread(action); private Task ExecuteUIThread(Action action) => CoreDispatcher?.ExecuteOnUIThread(action);