Fix activity log refresh losing scroll position and selection

This commit is contained in:
2026-04-12 09:13:43 +01:00
parent 66cca61b21
commit 4d8342b658
2 changed files with 263 additions and 7 deletions

View File

@@ -2,21 +2,98 @@ namespace SpamGuard.Tray;
using SpamGuard.Models; using SpamGuard.Models;
using SpamGuard.Services; using SpamGuard.Services;
using SpamGuard.State;
public sealed class ActivityLogForm : Form public sealed class ActivityLogForm : Form
{ {
private readonly ActivityLog _activityLog; 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 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; _activityLog = activityLog;
_monitors = monitors;
_trustedSenders = trustedSenders;
_blockedDomains = blockedDomains;
_overrideStore = overrideStore;
Text = "SpamGuard - Activity Log"; Text = "SpamGuard - Activity Log";
Size = new System.Drawing.Size(800, 500); Size = new System.Drawing.Size(900, 550);
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
Icon = IconGenerator.Green; 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 _grid = new DataGridView
{ {
Dock = DockStyle.Fill, Dock = DockStyle.Fill,
@@ -26,7 +103,8 @@ public sealed class ActivityLogForm : Form
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
SelectionMode = DataGridViewSelectionMode.FullRowSelect, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
BackgroundColor = System.Drawing.SystemColors.Window, BackgroundColor = System.Drawing.SystemColors.Window,
BorderStyle = BorderStyle.None BorderStyle = BorderStyle.None,
ContextMenuStrip = _rowMenu
}; };
_grid.Columns.Add("Time", "Time"); _grid.Columns.Add("Time", "Time");
@@ -35,17 +113,142 @@ public sealed class ActivityLogForm : Form
_grid.Columns.Add("Verdict", "Verdict"); _grid.Columns.Add("Verdict", "Verdict");
_grid.Columns.Add("Confidence", "Confidence"); _grid.Columns.Add("Confidence", "Confidence");
_grid.Columns.Add("Reason", "Reason"); _grid.Columns.Add("Reason", "Reason");
_grid.Columns.Add("Status", "");
_grid.Columns["Time"]!.Width = 70; _grid.Columns["Time"]!.Width = 70;
_grid.Columns["From"]!.Width = 150; _grid.Columns["From"]!.Width = 150;
_grid.Columns["Verdict"]!.Width = 80; _grid.Columns["Verdict"]!.Width = 80;
_grid.Columns["Confidence"]!.Width = 80; _grid.Columns["Confidence"]!.Width = 80;
_grid.Columns["Status"]!.Width = 30;
_grid.Columns["Status"]!.AutoSizeMode = DataGridViewAutoSizeColumnMode.None;
Controls.Add(_grid); Controls.Add(_grid);
Controls.Add(_toolbar);
RefreshData(); 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() public void RefreshData()
{ {
if (InvokeRequired) if (InvokeRequired)
@@ -54,21 +257,41 @@ public sealed class ActivityLogForm : Form
return; 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(); _grid.Rows.Clear();
int restoreIndex = -1;
foreach (var entry in entries) 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( var rowIndex = _grid.Rows.Add(
entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"), entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"),
entry.Sender, entry.Sender,
entry.Subject, entry.Subject,
entry.Verdict.ToString(), entry.Verdict.ToString(),
entry.Confidence?.ToString("P0") ?? "--", entry.Confidence?.ToString("P0") ?? "--",
entry.Reason ?? "" entry.Reason ?? "",
overrideMarker
); );
var row = _grid.Rows[rowIndex]; var row = _grid.Rows[rowIndex];
row.Tag = entry; // attach entry for reliable retrieval
row.DefaultCellStyle.ForeColor = entry.Verdict switch row.DefaultCellStyle.ForeColor = entry.Verdict switch
{ {
Verdict.Spam => System.Drawing.Color.Red, Verdict.Spam => System.Drawing.Color.Red,
@@ -77,7 +300,37 @@ public sealed class ActivityLogForm : Form
Verdict.Error => System.Drawing.Color.Gray, Verdict.Error => System.Drawing.Color.Gray,
_ => System.Drawing.SystemColors.ControlText _ => 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) protected override void OnFormClosing(FormClosingEventArgs e)

View File

@@ -8,6 +8,7 @@ using Microsoft.Extensions.Options;
using SpamGuard.Configuration; using SpamGuard.Configuration;
using SpamGuard.Models; using SpamGuard.Models;
using SpamGuard.Services; using SpamGuard.Services;
using SpamGuard.State;
public class EmailClassifierTests public class EmailClassifierTests
{ {
@@ -35,7 +36,8 @@ public class EmailClassifierTests
var classifier = new EmailClassifier( var classifier = new EmailClassifier(
Options.Create(DefaultOptions), Options.Create(DefaultOptions),
new NullLogger<EmailClassifier>(), new NullLogger<EmailClassifier>(),
new HttpClient() new HttpClient(),
new OverrideStore(Path.GetTempPath())
); );
var prompt = classifier.BuildPrompt(SampleEmail); var prompt = classifier.BuildPrompt(SampleEmail);
@@ -54,7 +56,8 @@ public class EmailClassifierTests
var classifier = new EmailClassifier( var classifier = new EmailClassifier(
Options.Create(DefaultOptions), Options.Create(DefaultOptions),
new NullLogger<EmailClassifier>(), new NullLogger<EmailClassifier>(),
new HttpClient() new HttpClient(),
new OverrideStore(Path.GetTempPath())
); );
var prompt = classifier.BuildPrompt(email); var prompt = classifier.BuildPrompt(email);