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.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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user