diff --git a/src/LogExpert.Configuration/ConfigManager.cs b/src/LogExpert.Configuration/ConfigManager.cs
index 4b80b9e3..e4f3db67 100644
--- a/src/LogExpert.Configuration/ConfigManager.cs
+++ b/src/LogExpert.Configuration/ConfigManager.cs
@@ -329,6 +329,21 @@ public void RemoveFromFileHistory (string fileName)
Save(SettingsFlags.FileHistory);
}
+ ///
+ /// Removes every entry from the file history list and persists the result immediately.
+ ///
+ [SupportedOSPlatform("windows")]
+ public void ClearFileHistory ()
+ {
+ // Held across the save so a concurrent save cannot serialise the list while it is being cleared.
+ lock (_loadSaveLock)
+ {
+ Instance.Settings.FileHistoryList.Clear();
+
+ Save(SettingsFlags.FileHistory);
+ }
+ }
+
public void ClearLastOpenFilesList ()
{
lock (_loadSaveLock)
diff --git a/src/LogExpert.Core/Interfaces/IConfigManager.cs b/src/LogExpert.Core/Interfaces/IConfigManager.cs
index ac523daf..c19d5be9 100644
--- a/src/LogExpert.Core/Interfaces/IConfigManager.cs
+++ b/src/LogExpert.Core/Interfaces/IConfigManager.cs
@@ -182,6 +182,15 @@ public interface IConfigManager
/// The name of the file to remove from the file history list. Comparison is case-insensitive.
void RemoveFromFileHistory (string fileName);
+ ///
+ /// Removes all entries from the file history list.
+ ///
+ /// Only the file history list is cleared — the other history lists (search, filter, filter
+ /// range, URI, regex, columnizer) keep their entries. The settings are saved immediately; because
+ /// the save writes the whole settings file, the flag passed along names the change rather than
+ /// narrowing what is written.
+ void ClearFileHistory ();
+
///
/// Clears the list of recently opened files.
///
diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs
index 6bf4905d..5e9f174d 100644
--- a/src/LogExpert.Resources/Resources.Designer.cs
+++ b/src/LogExpert.Resources/Resources.Designer.cs
@@ -2383,6 +2383,15 @@ public static string LogTabWindow_UI_ToolStripMenuItem_cellSelectModeToolStripMe
}
}
+ ///
+ /// Looks up a localized string similar to Clear recently used.
+ ///
+ public static string LogTabWindow_UI_ToolStripMenuItem_clearFileHistoryToolStripMenuItem {
+ get {
+ return ResourceManager.GetString("LogTabWindow_UI_ToolStripMenuItem_clearFileHistoryToolStripMenuItem", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Close all tabs.
///
diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx
index 67ef82bf..736bc8a4 100644
--- a/src/LogExpert.Resources/Resources.de.resx
+++ b/src/LogExpert.Resources/Resources.de.resx
@@ -1240,6 +1240,9 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools
Zuletzt benutzt
+
+ Zuletzt benutzte löschen
+
Exit
diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx
index bd9b3a53..441e4b87 100644
--- a/src/LogExpert.Resources/Resources.resx
+++ b/src/LogExpert.Resources/Resources.resx
@@ -1176,6 +1176,9 @@ Checked tools will appear in the icon bar. All other tools are available in the
Last used
+
+ Clear recently used
+
Exit
diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx
index 9b875814..58f62e03 100644
--- a/src/LogExpert.Resources/Resources.zh-CN.resx
+++ b/src/LogExpert.Resources/Resources.zh-CN.resx
@@ -1046,6 +1046,9 @@
最近使用
+
+ 清除最近使用
+
退出
diff --git a/src/LogExpert.Tests/ConfigManagerTests/ClearFileHistoryTests.cs b/src/LogExpert.Tests/ConfigManagerTests/ClearFileHistoryTests.cs
new file mode 100644
index 00000000..3bbdc5d0
--- /dev/null
+++ b/src/LogExpert.Tests/ConfigManagerTests/ClearFileHistoryTests.cs
@@ -0,0 +1,157 @@
+using System.Reflection;
+
+using LogExpert.Core.Config;
+
+using Newtonsoft.Json;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.ConfigManagerTests;
+
+///
+/// Tests for the "clear recently used" action of the file history menu.
+///
+[TestFixture]
+public class ClearFileHistoryTests
+{
+ private string _testDir;
+ private Configuration.ConfigManager _configManager;
+ private object? _originalSettings;
+ private bool _originalIsInitialized;
+ private string _originalConfigDir;
+
+ [SetUp]
+ public void SetUp ()
+ {
+ _configManager = Configuration.ConfigManager.Instance;
+
+ // Capture the singleton state before touching it. Other fixtures in this assembly rely on
+ // ConfigManager.Instance still pointing at the directory ConfigDirIsolationSetup prepared.
+ _originalIsInitialized = GetPrivateField("_isInitialized") is true;
+ _originalSettings = GetPrivateField("_settings");
+ _originalConfigDir = _configManager.ConfigDir;
+
+ _testDir = Path.Join(Path.GetTempPath(), "LogExpert_ClearHistoryTest_" + Guid.NewGuid().ToString("N"));
+ _ = Directory.CreateDirectory(_testDir);
+
+ ResetConfigManagerInitialization();
+ _configManager.Initialize(_testDir, new Rectangle(0, 0, 1920, 1080));
+ _configManager.ConfigDir = _testDir;
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ // Restore the singleton before removing the directory. While ConfigDir still points at this
+ // test's directory, the next fixture that saves recreates it and writes there instead of the
+ // shared isolation directory.
+ SetPrivateField("_isInitialized", _originalIsInitialized);
+ SetPrivateField("_settings", _originalSettings);
+ _configManager.ConfigDir = _originalConfigDir;
+
+ try
+ {
+ if (Directory.Exists(_testDir))
+ {
+ Directory.Delete(_testDir, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ // Ignore IO errors during cleanup
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Ignore access errors during cleanup
+ }
+ }
+
+ [Test]
+ public void ClearFileHistory_RemovesAllEntries ()
+ {
+ _configManager.AddToFileHistory(@"C:\logs\one.log");
+ _configManager.AddToFileHistory(@"C:\logs\two.log");
+ Assert.That(_configManager.Settings.FileHistoryList, Has.Count.EqualTo(2));
+
+ _configManager.ClearFileHistory();
+
+ Assert.That(_configManager.Settings.FileHistoryList, Is.Empty);
+ }
+
+ [Test]
+ public void ClearFileHistory_WritesEmptyListToSettingsFile ()
+ {
+ _configManager.AddToFileHistory(@"C:\logs\one.log");
+
+ _configManager.ClearFileHistory();
+
+ var settingsFile = Path.Join(_configManager.ActiveConfigDir, "settings.json");
+ Assert.That(File.Exists(settingsFile), Is.True, "clearing the history has to persist immediately");
+
+ var saved = JsonConvert.DeserializeObject(File.ReadAllText(settingsFile));
+ Assert.That(saved, Is.Not.Null);
+ Assert.That(saved.FileHistoryList, Is.Empty);
+ }
+
+ ///
+ /// Clearing the recent files list leaves every other history list alone — including the ones the
+ /// settings dialog and the regex test dialog keep for their own inputs.
+ ///
+ [Test]
+ public void ClearFileHistory_LeavesOtherHistoryListsUntouched ()
+ {
+ _configManager.AddToFileHistory(@"C:\logs\one.log");
+ _configManager.Settings.LastOpenFilesList.Add(@"C:\logs\one.log");
+ _configManager.Settings.SearchHistoryList.Add("error");
+ _configManager.Settings.FilterHistoryList.Add("WARN");
+ _configManager.Settings.FilterRangeHistoryList.Add("10");
+ _configManager.Settings.UriHistoryList.Add("https://example.org/one.log");
+ _configManager.Settings.RegexHistory.ExpressionHistoryList.Add(@"^\d+");
+ _configManager.Settings.RegexHistory.TesttextHistoryList.Add("42");
+ _configManager.Settings.ColumnizerHistoryList.Add(new ColumnizerHistoryEntry(@"C:\logs\one.log", "JsonColumnizer"));
+
+ _configManager.ClearFileHistory();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_configManager.Settings.FileHistoryList, Is.Empty);
+ Assert.That(_configManager.Settings.LastOpenFilesList, Has.Count.EqualTo(1));
+ Assert.That(_configManager.Settings.SearchHistoryList, Has.Count.EqualTo(1));
+ Assert.That(_configManager.Settings.FilterHistoryList, Has.Count.EqualTo(1));
+ Assert.That(_configManager.Settings.FilterRangeHistoryList, Has.Count.EqualTo(1));
+ Assert.That(_configManager.Settings.UriHistoryList, Has.Count.EqualTo(1));
+ Assert.That(_configManager.Settings.RegexHistory.ExpressionHistoryList, Has.Count.EqualTo(1));
+ Assert.That(_configManager.Settings.RegexHistory.TesttextHistoryList, Has.Count.EqualTo(1));
+ Assert.That(_configManager.Settings.ColumnizerHistoryList, Has.Count.EqualTo(1));
+ });
+ }
+
+ ///
+ /// Resets the singleton so each test loads its settings from its own temp directory.
+ ///
+ private void ResetConfigManagerInitialization ()
+ {
+ SetPrivateField("_isInitialized", false);
+ SetPrivateField("_settings", null);
+ }
+
+ private object? GetPrivateField (string fieldName)
+ {
+ return Field(fieldName).GetValue(_configManager);
+ }
+
+ private void SetPrivateField (string fieldName, object? value)
+ {
+ Field(fieldName).SetValue(_configManager, value);
+ }
+
+ ///
+ /// Throws instead of returning null: a renamed field would otherwise make the reset silently do
+ /// nothing and the whole fixture pass against settings it never re-loaded.
+ ///
+ private static FieldInfo Field (string fieldName)
+ {
+ return typeof(Configuration.ConfigManager).GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance)
+ ?? throw new InvalidOperationException($"ConfigManager.{fieldName} is gone; update this fixture's reset.");
+ }
+}
diff --git a/src/LogExpert.UI.Tests/Services/MenuToolbarControllerTests.cs b/src/LogExpert.UI.Tests/Services/MenuToolbarControllerTests.cs
index f41d720d..ef06fc62 100644
--- a/src/LogExpert.UI.Tests/Services/MenuToolbarControllerTests.cs
+++ b/src/LogExpert.UI.Tests/Services/MenuToolbarControllerTests.cs
@@ -268,8 +268,45 @@ public void PopulateFileHistory_CreatesMenuItems ()
_controller.PopulateFileHistory(history);
var lastUsed = FindMenuItem("lastUsedToolStripMenuItem");
- Assert.That(lastUsed.DropDownItems, Has.Count.EqualTo(2));
+ Assert.That(lastUsed.DropDownItems, Has.Count.EqualTo(4), "2 files + separator + clear item");
Assert.That(lastUsed.DropDownItems[0].Text, Is.EqualTo(@"C:\log1.txt"));
+ Assert.That(lastUsed.DropDownItems[2], Is.TypeOf());
+ Assert.That(lastUsed.DropDownItems[3].Name, Is.EqualTo("clearFileHistoryToolStripMenuItem"));
+ Assert.That(lastUsed.DropDownItems[3].Enabled, Is.True);
+ }
+
+ [Test]
+ public void PopulateFileHistory_DisablesClearItem_WhenHistoryIsEmpty ()
+ {
+ _controller.PopulateFileHistory([]);
+
+ var lastUsed = FindMenuItem("lastUsedToolStripMenuItem");
+ Assert.That(lastUsed.DropDownItems, Has.Count.EqualTo(1), "clear item only, no separator");
+ Assert.That(lastUsed.DropDownItems[0].Name, Is.EqualTo("clearFileHistoryToolStripMenuItem"));
+ Assert.That(lastUsed.DropDownItems[0].Enabled, Is.False);
+ }
+
+ [Test]
+ public void ClearHistoryItem_RaisesHistoryClearRequested_AndNotFileClicked ()
+ {
+ var clearRequested = 0;
+ string clickedFile = null;
+ _controller.HistoryClearRequested += (_, _) => clearRequested++;
+ _controller.HistoryItemClicked += (_, e) => clickedFile = e.FileName;
+
+ _controller.PopulateFileHistory(["test.log"]);
+ var lastUsed = FindMenuItem("lastUsedToolStripMenuItem");
+ var clearItem = lastUsed.DropDownItems[^1];
+
+ var args = new ToolStripItemClickedEventArgs(clearItem);
+ var onItemClicked = typeof(ToolStrip).GetMethod("OnItemClicked", BindingFlags.Instance | BindingFlags.NonPublic);
+ _ = onItemClicked.Invoke(lastUsed.DropDown, [args]);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(clearRequested, Is.EqualTo(1));
+ Assert.That(clickedFile, Is.Null, "the clear item must not be treated as a file entry");
+ });
}
[Test]
diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
index 5ec992ae..828b012c 100644
--- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
+++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
@@ -236,6 +236,7 @@ private void InitializeMenuToolbarControllerEvents ()
{
_menuToolbarController.HistoryItemClicked += OnMenuControllerHistoryItemClicked;
_menuToolbarController.HistoryItemRemoveRequested += OnMenuControllerHistoryItemRemoveRequested;
+ _menuToolbarController.HistoryClearRequested += OnMenuControllerHistoryClearRequested;
_menuToolbarController.HighlightGroupSelected += OnMenuControllerHighlightGroupSelected;
}
@@ -250,6 +251,12 @@ private void OnMenuControllerHistoryItemRemoveRequested (object? sender, History
FillHistoryMenu();
}
+ private void OnMenuControllerHistoryClearRequested (object? sender, EventArgs e)
+ {
+ ConfigManager.ClearFileHistory();
+ FillHistoryMenu();
+ }
+
private void OnMenuControllerHistoryItemClicked (object? sender, HistoryItemClickedEventArgs e)
{
_ = _fileOperationService.AddFileTab(new FileTabRequest { FileName = e.FileName });
diff --git a/src/LogExpert.UI/Interface/IMenuToolbarController.cs b/src/LogExpert.UI/Interface/IMenuToolbarController.cs
index da3a921a..2aaed7a1 100644
--- a/src/LogExpert.UI/Interface/IMenuToolbarController.cs
+++ b/src/LogExpert.UI/Interface/IMenuToolbarController.cs
@@ -53,5 +53,6 @@ void InitializeMenus (MenuStrip mainMenu, ToolStrip buttonToolbar, ToolStrip ext
// Events
event EventHandler HistoryItemClicked;
event EventHandler HistoryItemRemoveRequested;
+ event EventHandler HistoryClearRequested;
event EventHandler HighlightGroupSelected;
}
diff --git a/src/LogExpert.UI/Services/MenuToolbarService/MenuToolbarController.cs b/src/LogExpert.UI/Services/MenuToolbarService/MenuToolbarController.cs
index 5e1644f0..30613b45 100644
--- a/src/LogExpert.UI/Services/MenuToolbarService/MenuToolbarController.cs
+++ b/src/LogExpert.UI/Services/MenuToolbarService/MenuToolbarController.cs
@@ -14,6 +14,9 @@ namespace LogExpert.UI.Services.MenuToolbarService;
[SupportedOSPlatform("windows")]
internal sealed class MenuToolbarController : IMenuToolbarController
{
+ // Identifies the row for tests and UI automation. The handlers do not look the item up by it.
+ private const string CLEAR_HISTORY_ITEM_NAME = "clearFileHistoryToolStripMenuItem";
+
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
private MenuStrip _mainMenu;
@@ -49,6 +52,11 @@ internal sealed class MenuToolbarController : IMenuToolbarController
// History menu
private ToolStripMenuItem _lastUsedMenuItem;
+ // The "clear recently used files" row of the history dropdown. The click handler tells it apart
+ // from the file entries by this reference; the right-click handler needs no special case, because
+ // only the file entries carry a Tag with a path.
+ private ToolStripMenuItem _clearHistoryItem;
+
private readonly SynchronizationContext _uiContext;
private readonly int _uiThreadId = Environment.CurrentManagedThreadId;
private bool _disposed;
@@ -56,6 +64,7 @@ internal sealed class MenuToolbarController : IMenuToolbarController
public event EventHandler HistoryItemClicked;
public event EventHandler HistoryItemRemoveRequested;
+ public event EventHandler HistoryClearRequested;
public event EventHandler HighlightGroupSelected;
public MenuToolbarController ()
@@ -255,9 +264,12 @@ public void PopulateFileHistory (IEnumerable fileHistory)
return;
}
+ // Copy before the hop: callers hand over the live Settings.FileHistoryList, which can change
+ // while the post is still queued.
if (Environment.CurrentManagedThreadId != _uiThreadId)
{
- _uiContext.Post(_ => PopulateFileHistory(fileHistory), null);
+ var snapshot = fileHistory.ToList();
+ _uiContext.Post(_ => PopulateFileHistory(snapshot), null);
return;
}
@@ -270,11 +282,27 @@ public void PopulateFileHistory (IEnumerable fileHistory)
var strip = new ToolStripDropDownMenu();
- foreach (var file in fileHistory)
+ var history = fileHistory as IList ?? [.. fileHistory];
+
+ foreach (var file in history)
{
- _ = strip.Items.Add(new ToolStripMenuItem(file));
+ // The path lives in Tag, so the handlers never have to treat the displayed text as data.
+ _ = strip.Items.Add(new ToolStripMenuItem(file) { Tag = file });
}
+ if (history.Count > 0)
+ {
+ _ = strip.Items.Add(new ToolStripSeparator());
+ }
+
+ _clearHistoryItem = new ToolStripMenuItem(Resources.LogTabWindow_UI_ToolStripMenuItem_clearFileHistoryToolStripMenuItem)
+ {
+ Name = CLEAR_HISTORY_ITEM_NAME,
+ Enabled = history.Count > 0
+ };
+
+ _ = strip.Items.Add(_clearHistoryItem);
+
strip.ItemClicked += OnHistoryMenuItemClicked;
strip.MouseUp += OnHistoryStripMouseUp;
_lastUsedMenuItem.DropDown = strip;
@@ -302,16 +330,26 @@ private void OnHighlightGroupComboSelectedIndexChanged (object sender, EventArgs
private void OnHistoryMenuItemClicked (object sender, ToolStripItemClickedEventArgs e)
{
- var fileName = e.ClickedItem?.Text;
- if (!string.IsNullOrEmpty(fileName))
+ if (ReferenceEquals(e.ClickedItem, _clearHistoryItem))
+ {
+ HistoryClearRequested?.Invoke(this, EventArgs.Empty);
+ return;
+ }
+
+ if (e.ClickedItem?.Tag is string fileName && !string.IsNullOrEmpty(fileName))
{
HistoryItemClicked?.Invoke(this, new HistoryItemClickedEventArgs(fileName));
}
}
+ ///
+ /// Right-click on a file entry removes that file from the history.
+ ///
+ /// Separators and the clear row carry no file path, so they never reach the remove path.
+ /// Pre-existing: 's OnMouseUp override does not call the base
+ /// implementation, so the dropdown never raises this event. Behavior is unchanged here.
private void OnHistoryStripMouseUp (object sender, MouseEventArgs e)
{
- // Right-click to remove from history (preserves existing LogTabWindow behavior)
if (e.Button != MouseButtons.Right)
{
return;
@@ -319,10 +357,9 @@ private void OnHistoryStripMouseUp (object sender, MouseEventArgs e)
if (sender is ToolStripDropDownMenu strip)
{
- var item = strip.GetItemAt(e.Location);
- if (item != null && !string.IsNullOrEmpty(item.Text))
+ if (strip.GetItemAt(e.Location)?.Tag is string fileName && !string.IsNullOrEmpty(fileName))
{
- HistoryItemRemoveRequested?.Invoke(this, new HistoryItemClickedEventArgs(item.Text));
+ HistoryItemRemoveRequested?.Invoke(this, new HistoryItemClickedEventArgs(fileName));
}
}
}