Fix activity log refresh losing scroll position and selection
This commit is contained in:
@@ -2,21 +2,98 @@ namespace SpamGuard.Tray;
|
||||
|
||||
using SpamGuard.Models;
|
||||
using SpamGuard.Services;
|
||||
using SpamGuard.State;
|
||||
|
||||
public sealed class ActivityLogForm : Form
|
||||
{
|
||||
private readonly ActivityLog _activityLog;
|
||||
private readonly IReadOnlyList<InboxMonitorService> _monitors;
|
||||
private readonly TrustedSenderStore _trustedSenders;
|
||||
private readonly BlockedDomainStore _blockedDomains;
|
||||
private readonly OverrideStore _overrideStore;
|
||||
private readonly DataGridView _grid;
|
||||
private readonly ToolStrip _toolbar;
|
||||
private readonly ContextMenuStrip _rowMenu;
|
||||
|
||||
public ActivityLogForm(ActivityLog activityLog)
|
||||
private const int DisplayCount = 200;
|
||||
|
||||
public ActivityLogForm(
|
||||
ActivityLog activityLog,
|
||||
IReadOnlyList<InboxMonitorService> monitors,
|
||||
TrustedSenderStore trustedSenders,
|
||||
BlockedDomainStore blockedDomains,
|
||||
OverrideStore overrideStore)
|
||||
{
|
||||
_activityLog = activityLog;
|
||||
_monitors = monitors;
|
||||
_trustedSenders = trustedSenders;
|
||||
_blockedDomains = blockedDomains;
|
||||
_overrideStore = overrideStore;
|
||||
|
||||
Text = "SpamGuard - Activity Log";
|
||||
Size = new System.Drawing.Size(800, 500);
|
||||
Size = new System.Drawing.Size(900, 550);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Icon = IconGenerator.Green;
|
||||
KeyPreview = true;
|
||||
|
||||
// Toolbar
|
||||
_toolbar = new ToolStrip();
|
||||
var btnMarkSpam = new ToolStripButton("Mark Spam (S)")
|
||||
{
|
||||
ForeColor = System.Drawing.Color.Red,
|
||||
Tag = "spam"
|
||||
};
|
||||
btnMarkSpam.Click += OnMarkSpam;
|
||||
|
||||
var btnMarkNotSpam = new ToolStripButton("Not Spam (H)")
|
||||
{
|
||||
ForeColor = System.Drawing.Color.Green,
|
||||
Tag = "ham"
|
||||
};
|
||||
btnMarkNotSpam.Click += OnMarkNotSpam;
|
||||
|
||||
var btnBlockDomain = new ToolStripButton("Block Domain (D)")
|
||||
{
|
||||
ForeColor = System.Drawing.Color.DarkRed,
|
||||
Tag = "block"
|
||||
};
|
||||
btnBlockDomain.Click += OnBlockDomain;
|
||||
|
||||
var btnAllowDomain = new ToolStripButton("Allow Domain")
|
||||
{
|
||||
ForeColor = System.Drawing.Color.DarkGreen,
|
||||
Tag = "allow"
|
||||
};
|
||||
btnAllowDomain.Click += OnAllowDomain;
|
||||
|
||||
_toolbar.Items.AddRange(new ToolStripItem[]
|
||||
{
|
||||
btnMarkSpam,
|
||||
btnMarkNotSpam,
|
||||
new ToolStripSeparator(),
|
||||
btnBlockDomain,
|
||||
btnAllowDomain
|
||||
});
|
||||
|
||||
// Context menu
|
||||
_rowMenu = new ContextMenuStrip();
|
||||
var ctxSpam = new ToolStripMenuItem("Mark as Spam (S)");
|
||||
ctxSpam.Click += OnMarkSpam;
|
||||
var ctxNotSpam = new ToolStripMenuItem("Mark as Not Spam (H)");
|
||||
ctxNotSpam.Click += OnMarkNotSpam;
|
||||
var ctxBlockDomain = new ToolStripMenuItem("Always Block Domain (D)");
|
||||
ctxBlockDomain.Click += OnBlockDomain;
|
||||
var ctxAllowDomain = new ToolStripMenuItem("Never Block Domain");
|
||||
ctxAllowDomain.Click += OnAllowDomain;
|
||||
|
||||
_rowMenu.Items.AddRange(new ToolStripItem[]
|
||||
{
|
||||
ctxSpam, ctxNotSpam,
|
||||
new ToolStripSeparator(),
|
||||
ctxBlockDomain, ctxAllowDomain
|
||||
});
|
||||
|
||||
// Grid
|
||||
_grid = new DataGridView
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
@@ -26,7 +103,8 @@ public sealed class ActivityLogForm : Form
|
||||
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
|
||||
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
|
||||
BackgroundColor = System.Drawing.SystemColors.Window,
|
||||
BorderStyle = BorderStyle.None
|
||||
BorderStyle = BorderStyle.None,
|
||||
ContextMenuStrip = _rowMenu
|
||||
};
|
||||
|
||||
_grid.Columns.Add("Time", "Time");
|
||||
@@ -35,17 +113,142 @@ public sealed class ActivityLogForm : Form
|
||||
_grid.Columns.Add("Verdict", "Verdict");
|
||||
_grid.Columns.Add("Confidence", "Confidence");
|
||||
_grid.Columns.Add("Reason", "Reason");
|
||||
_grid.Columns.Add("Status", "");
|
||||
|
||||
_grid.Columns["Time"]!.Width = 70;
|
||||
_grid.Columns["From"]!.Width = 150;
|
||||
_grid.Columns["Verdict"]!.Width = 80;
|
||||
_grid.Columns["Confidence"]!.Width = 80;
|
||||
_grid.Columns["Status"]!.Width = 30;
|
||||
_grid.Columns["Status"]!.AutoSizeMode = DataGridViewAutoSizeColumnMode.None;
|
||||
|
||||
Controls.Add(_grid);
|
||||
Controls.Add(_toolbar);
|
||||
|
||||
RefreshData();
|
||||
}
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
if (_grid.SelectedRows.Count == 0)
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
|
||||
switch (keyData)
|
||||
{
|
||||
case Keys.S:
|
||||
OnMarkSpam(this, EventArgs.Empty);
|
||||
return true;
|
||||
case Keys.H:
|
||||
OnMarkNotSpam(this, EventArgs.Empty);
|
||||
return true;
|
||||
case Keys.D:
|
||||
OnBlockDomain(this, EventArgs.Empty);
|
||||
return true;
|
||||
default:
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnMarkSpam(object? sender, EventArgs e)
|
||||
{
|
||||
var entry = GetSelectedEntry();
|
||||
if (entry == null || entry.Uid == 0) return;
|
||||
|
||||
var monitor = FindMonitor(entry.AccountName);
|
||||
if (monitor == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await monitor.MoveToSpamAsync(entry.Uid);
|
||||
RecordOverride(entry, "spam");
|
||||
_activityLog.UpdateEntry(entry, Verdict.Spam, UserOverride.MarkedSpam);
|
||||
RefreshData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Failed to move email: {ex.Message}", "Error",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnMarkNotSpam(object? sender, EventArgs e)
|
||||
{
|
||||
var entry = GetSelectedEntry();
|
||||
if (entry == null || entry.Uid == 0) return;
|
||||
|
||||
var monitor = FindMonitor(entry.AccountName);
|
||||
if (monitor == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await monitor.MoveToInboxAsync(entry.Uid);
|
||||
_trustedSenders.Add(entry.Sender);
|
||||
_trustedSenders.Save();
|
||||
RecordOverride(entry, "legitimate");
|
||||
_activityLog.UpdateEntry(entry, Verdict.Legitimate, UserOverride.MarkedLegitimate);
|
||||
RefreshData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Failed to move email: {ex.Message}", "Error",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBlockDomain(object? sender, EventArgs e)
|
||||
{
|
||||
var entry = GetSelectedEntry();
|
||||
if (entry == null || string.IsNullOrEmpty(entry.Sender)) return;
|
||||
|
||||
var domain = BlockedDomainStore.ExtractDomain(entry.Sender);
|
||||
if (string.IsNullOrEmpty(domain)) return;
|
||||
|
||||
_blockedDomains.Add(domain);
|
||||
_blockedDomains.Save();
|
||||
|
||||
OnMarkSpam(sender, e);
|
||||
}
|
||||
|
||||
private void OnAllowDomain(object? sender, EventArgs e)
|
||||
{
|
||||
var entry = GetSelectedEntry();
|
||||
if (entry == null || string.IsNullOrEmpty(entry.Sender)) return;
|
||||
|
||||
var domain = BlockedDomainStore.ExtractDomain(entry.Sender);
|
||||
if (string.IsNullOrEmpty(domain)) return;
|
||||
|
||||
_blockedDomains.Remove(domain);
|
||||
_blockedDomains.Save();
|
||||
|
||||
OnMarkNotSpam(sender, e);
|
||||
}
|
||||
|
||||
private ActivityEntry? GetSelectedEntry()
|
||||
{
|
||||
if (_grid.SelectedRows.Count == 0) return null;
|
||||
return _grid.SelectedRows[0].Tag as ActivityEntry;
|
||||
}
|
||||
|
||||
private InboxMonitorService? FindMonitor(string accountName)
|
||||
{
|
||||
return _monitors.FirstOrDefault(m =>
|
||||
m.AccountName.Equals(accountName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private void RecordOverride(ActivityEntry entry, string userVerdict)
|
||||
{
|
||||
var originalVerdict = entry.Verdict.ToString().ToLowerInvariant();
|
||||
_overrideStore.Add(new OverrideRecord(
|
||||
DateTime.UtcNow,
|
||||
entry.Sender,
|
||||
entry.Subject,
|
||||
entry.BodySnippet,
|
||||
userVerdict,
|
||||
originalVerdict
|
||||
));
|
||||
_overrideStore.Save();
|
||||
}
|
||||
|
||||
public void RefreshData()
|
||||
{
|
||||
if (InvokeRequired)
|
||||
@@ -54,21 +257,41 @@ public sealed class ActivityLogForm : Form
|
||||
return;
|
||||
}
|
||||
|
||||
var entries = _activityLog.GetRecent(200);
|
||||
// Preserve selection and scroll position across refresh
|
||||
var selectedEntry = GetSelectedEntry();
|
||||
var selectedUid = selectedEntry?.Uid ?? 0;
|
||||
var selectedTimestamp = selectedEntry?.Timestamp ?? default;
|
||||
var firstVisible = _grid.FirstDisplayedScrollingRowIndex;
|
||||
|
||||
var entries = _activityLog.GetRecent(DisplayCount);
|
||||
|
||||
_grid.SuspendLayout();
|
||||
_grid.Rows.Clear();
|
||||
|
||||
int restoreIndex = -1;
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var overrideMarker = entry.Override switch
|
||||
{
|
||||
UserOverride.MarkedSpam => "\u2716", // heavy X
|
||||
UserOverride.MarkedLegitimate => "\u2714", // heavy checkmark
|
||||
_ => ""
|
||||
};
|
||||
|
||||
var rowIndex = _grid.Rows.Add(
|
||||
entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"),
|
||||
entry.Sender,
|
||||
entry.Subject,
|
||||
entry.Verdict.ToString(),
|
||||
entry.Confidence?.ToString("P0") ?? "--",
|
||||
entry.Reason ?? ""
|
||||
entry.Reason ?? "",
|
||||
overrideMarker
|
||||
);
|
||||
|
||||
var row = _grid.Rows[rowIndex];
|
||||
row.Tag = entry; // attach entry for reliable retrieval
|
||||
|
||||
row.DefaultCellStyle.ForeColor = entry.Verdict switch
|
||||
{
|
||||
Verdict.Spam => System.Drawing.Color.Red,
|
||||
@@ -77,7 +300,37 @@ public sealed class ActivityLogForm : Form
|
||||
Verdict.Error => System.Drawing.Color.Gray,
|
||||
_ => System.Drawing.SystemColors.ControlText
|
||||
};
|
||||
|
||||
if (entry.Override != null)
|
||||
{
|
||||
row.DefaultCellStyle.Font = new System.Drawing.Font(
|
||||
_grid.DefaultCellStyle.Font ?? System.Drawing.SystemFonts.DefaultFont,
|
||||
System.Drawing.FontStyle.Bold);
|
||||
}
|
||||
|
||||
// Match by Uid (preferred) or timestamp fallback for Uid==0 entries
|
||||
if (restoreIndex < 0)
|
||||
{
|
||||
if (selectedUid != 0 && entry.Uid == selectedUid)
|
||||
restoreIndex = rowIndex;
|
||||
else if (selectedUid == 0 && entry.Timestamp == selectedTimestamp)
|
||||
restoreIndex = rowIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore scroll position
|
||||
if (_grid.RowCount > 0 && firstVisible >= 0)
|
||||
{
|
||||
var clampedFirst = Math.Min(firstVisible, _grid.RowCount - 1);
|
||||
_grid.FirstDisplayedScrollingRowIndex = clampedFirst;
|
||||
}
|
||||
|
||||
// Restore selection without triggering a scroll jump
|
||||
_grid.ClearSelection();
|
||||
if (restoreIndex >= 0)
|
||||
_grid.Rows[restoreIndex].Selected = true;
|
||||
|
||||
_grid.ResumeLayout();
|
||||
}
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Options;
|
||||
using SpamGuard.Configuration;
|
||||
using SpamGuard.Models;
|
||||
using SpamGuard.Services;
|
||||
using SpamGuard.State;
|
||||
|
||||
public class EmailClassifierTests
|
||||
{
|
||||
@@ -35,7 +36,8 @@ public class EmailClassifierTests
|
||||
var classifier = new EmailClassifier(
|
||||
Options.Create(DefaultOptions),
|
||||
new NullLogger<EmailClassifier>(),
|
||||
new HttpClient()
|
||||
new HttpClient(),
|
||||
new OverrideStore(Path.GetTempPath())
|
||||
);
|
||||
|
||||
var prompt = classifier.BuildPrompt(SampleEmail);
|
||||
@@ -54,7 +56,8 @@ public class EmailClassifierTests
|
||||
var classifier = new EmailClassifier(
|
||||
Options.Create(DefaultOptions),
|
||||
new NullLogger<EmailClassifier>(),
|
||||
new HttpClient()
|
||||
new HttpClient(),
|
||||
new OverrideStore(Path.GetTempPath())
|
||||
);
|
||||
|
||||
var prompt = classifier.BuildPrompt(email);
|
||||
|
||||
Reference in New Issue
Block a user