diff --git a/Flow.Launcher.Infrastructure/Constant.cs b/Flow.Launcher.Infrastructure/Constant.cs
index 7e3499ac520..7385b285a48 100644
--- a/Flow.Launcher.Infrastructure/Constant.cs
+++ b/Flow.Launcher.Infrastructure/Constant.cs
@@ -1,4 +1,4 @@
-using System.Diagnostics;
+using System.Diagnostics;
using System.IO;
using System.Reflection;
@@ -35,6 +35,7 @@ public static class Constant
public static readonly string HistoryIcon = Path.Combine(ImagesDirectory, "history.png");
public static readonly string SettingsIcon = Path.Combine(ImagesDirectory, "settings.png");
public static readonly string FolderIcon = Path.Combine(ImagesDirectory, "folder.png");
+ public static readonly string DeleteIcon = Path.Combine(ImagesDirectory, "delete.png");
public static string PythonPath;
public static string NodePath;
diff --git a/Flow.Launcher.Test/QueryHistoryTest.cs b/Flow.Launcher.Test/QueryHistoryTest.cs
new file mode 100644
index 00000000000..1f3fe4cdb1b
--- /dev/null
+++ b/Flow.Launcher.Test/QueryHistoryTest.cs
@@ -0,0 +1,50 @@
+using Flow.Launcher.Storage;
+using NUnit.Framework;
+
+namespace Flow.Launcher.Test
+{
+ [TestFixture]
+ public class QueryHistoryTest
+ {
+ [Test]
+ public void Remove_RemovesOnlySelectedEntryByDefault()
+ {
+ var history = new History();
+ var selected = CreateHistoryItem("query one");
+ var otherQuery = CreateHistoryItem("query two");
+ history.LastOpenedHistoryItems.AddRange([selected, otherQuery]);
+
+ var removedCount = history.Remove(selected);
+
+ Assert.That(removedCount, Is.EqualTo(1));
+ Assert.That(history.LastOpenedHistoryItems, Is.EqualTo(new[] { otherQuery }));
+ }
+
+ [Test]
+ public void Remove_WhenRemovingMatchingResults_RemovesEntriesFromAllQueries()
+ {
+ var history = new History();
+ var selected = CreateHistoryItem("query one");
+ var sameResultFromAnotherQuery = CreateHistoryItem("query two");
+ var differentResult = CreateHistoryItem("query three", recordKey: "different");
+ history.LastOpenedHistoryItems.AddRange([selected, sameResultFromAnotherQuery, differentResult]);
+
+ var removedCount = history.Remove(selected, removeAllMatchingResults: true);
+
+ Assert.That(removedCount, Is.EqualTo(2));
+ Assert.That(history.LastOpenedHistoryItems, Is.EqualTo(new[] { differentResult }));
+ }
+
+ private static LastOpenedHistoryResult CreateHistoryItem(string query, string recordKey = "same")
+ {
+ return new LastOpenedHistoryResult
+ {
+ Title = "Result",
+ SubTitle = "Subtitle",
+ PluginID = "Plugin",
+ RecordKey = recordKey,
+ Query = query
+ };
+ }
+ }
+}
diff --git a/Flow.Launcher/Images/delete.png b/Flow.Launcher/Images/delete.png
new file mode 100644
index 00000000000..85e293bff5d
Binary files /dev/null and b/Flow.Launcher/Images/delete.png differ
diff --git a/Flow.Launcher/MainWindow.xaml.cs b/Flow.Launcher/MainWindow.xaml.cs
index aadfeb991f4..41734bb8161 100644
--- a/Flow.Launcher/MainWindow.xaml.cs
+++ b/Flow.Launcher/MainWindow.xaml.cs
@@ -484,8 +484,8 @@ private void OnKeyDown(object sender, KeyEventArgs e)
e.Handled = true;
break;
case Key.Right:
- if (_viewModel.QueryResultsSelected()
- && QueryTextBox.CaretIndex == QueryTextBox.Text.Length)
+ if ((_viewModel.QueryResultsSelected() || _viewModel.HistorySelected())
+ && QueryTextBox.CaretIndex == QueryTextBox.Text.Length)
{
_viewModel.LoadContextMenuCommand.Execute(null);
e.Handled = true;
diff --git a/Flow.Launcher/Storage/QueryHistory.cs b/Flow.Launcher/Storage/QueryHistory.cs
index d9a527f61b4..d12ab833edd 100644
--- a/Flow.Launcher/Storage/QueryHistory.cs
+++ b/Flow.Launcher/Storage/QueryHistory.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
@@ -86,6 +86,29 @@ public void Add(Result result)
}
}
+ ///
+ /// Removes an item from history.
+ ///
+ /// The stored history item to remove.
+ ///
+ /// Whether to remove every entry representing the same result. This is used by the last-opened history
+ /// style, which displays entries with different queries as a single result.
+ ///
+ /// The number of removed history entries.
+ public int Remove(LastOpenedHistoryResult historyItem, bool removeAllMatchingResults = false)
+ {
+ if (!removeAllMatchingResults)
+ {
+ return LastOpenedHistoryItems.Remove(historyItem) ? 1 : 0;
+ }
+
+ return LastOpenedHistoryItems.RemoveAll(item =>
+ item.Title == historyItem.Title
+ && item.SubTitle == historyItem.SubTitle
+ && item.PluginID == historyItem.PluginID
+ && item.RecordKey == historyItem.RecordKey);
+ }
+
///
/// Attempts to find an existing in
/// that is considered equal to the supplied .
diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs
index b9767d337d4..00ff2fcaef8 100644
--- a/Flow.Launcher/ViewModel/MainViewModel.cs
+++ b/Flow.Launcher/ViewModel/MainViewModel.cs
@@ -41,8 +41,12 @@ public partial class MainViewModel : BaseModel, ISavable, IDisposable, IResultUp
private readonly ConcurrentDictionary _progressQueryDict = new(); // Used for QueryResultAsync
private Query _updateQuery; // Used for ResultsUpdated
private string _queryTextBeforeLeaveResults;
+ private string _queryTextBeforeContextMenu;
private string _ignoredQueryText; // Used to ignore query text change when switching between context menu and query results
+ private ResultsViewModel _contextMenuSource;
+ private Result _contextMenuTarget;
+
private readonly FlowLauncherJsonStorage _historyItemsStorage;
private readonly History _history;
private int lastHistoryIndex = 1;
@@ -413,23 +417,49 @@ private async Task LoadContextMenuAsync()
return;
}
- // For query mode, we load context menu
- if (QueryResultsSelected())
+ // If the context menu is already open, we need to return to the previous results view
+ if (ContextMenuSelected())
{
- // When switch to ContextMenu from QueryResults, but no item being chosen, should do nothing
- // i.e. Shift+Enter/Ctrl+O right after Alt + Space should do nothing
- if (SelectedResults.SelectedItem?.Result != null &&
- !string.IsNullOrEmpty(SelectedResults.SelectedItem.Result.PluginID)) // Do not show context menu for history results
- {
- SelectedResults = ContextMenu;
- }
+ await ReturnFromContextMenuAsync();
+ return;
}
- else
+
+ // Check if the selected result is a history result or a regular result
+ // Regular results need a valid plugin ID. History results use Flow's built-in context menu and
+ // can appear in either the dedicated history view or the home-page result list.
+ var selected = SelectedResults.SelectedItem?.Result;
+ if (selected == null) return;
+ var isHistoryResult = selected is LastOpenedHistoryResult
+ && selected.ContextData is LastOpenedHistoryResult;
+ if (!isHistoryResult && (!QueryResultsSelected() || string.IsNullOrEmpty(selected.PluginID))) return;
+
+ _contextMenuSource = SelectedResults;
+ _contextMenuTarget = selected;
+ _queryTextBeforeContextMenu = QueryText;
+
+ // Load context menu
+ SelectedResults = ContextMenu;
+ }
+
+ private async Task ReturnFromContextMenuAsync()
+ {
+ var source = _contextMenuSource ?? Results;
+ var queryText = _queryTextBeforeContextMenu;
+
+ // Return to the previous results view and restore the query text
+ SelectedResults = source;
+ if (source == History)
{
- SelectedResults = Results;
- PreviewSelectedItem = Results.SelectedItem;
- await UpdatePreviewAsync();
+ await ChangeQueryTextAsync(queryText);
}
+
+ // Refresh the preview panel to show the previously selected result
+ PreviewSelectedItem = source.SelectedItem;
+ await UpdatePreviewAsync();
+
+ _contextMenuSource = null;
+ _contextMenuTarget = null;
+ _queryTextBeforeContextMenu = string.Empty;
}
[RelayCommand]
@@ -638,7 +668,11 @@ private void SelectNextItem()
[RelayCommand]
private async Task EscAsync()
{
- if (!QueryResultsSelected())
+ if (ContextMenuSelected())
+ {
+ await ReturnFromContextMenuAsync();
+ }
+ else if (!QueryResultsSelected())
{
SelectedResults = Results;
PreviewSelectedItem = Results.SelectedItem;
@@ -871,7 +905,10 @@ private ResultsViewModel SelectedResults
ContextMenu.Visibility = Visibility.Visible;
History.Visibility = Visibility.Collapsed;
}
- _queryTextBeforeLeaveResults = QueryText;
+ if (isReturningFromQueryResults)
+ {
+ _queryTextBeforeLeaveResults = QueryText;
+ }
// Because of Fody's optimization
// setter won't be called when property value is not changed.
@@ -1307,7 +1344,17 @@ private void QueryContextMenu()
var query = QueryText.ToLower().Trim();
ContextMenu.Clear();
- var selected = Results.SelectedItem?.Result;
+ var selected = _contextMenuTarget;
+
+ if (selected is LastOpenedHistoryResult
+ && selected.ContextData is LastOpenedHistoryResult historyItem)
+ {
+ ContextMenu.AddResults([
+ ContextMenuDeleteHistory(historyItem),
+ ContextMenuHistoryInfo(historyItem)
+ ], id);
+ return;
+ }
if (selected != null && // SelectedItem returns null if selection is empty.
!string.IsNullOrEmpty(selected.PluginID)) // SelectedItem must have a valid PluginID, history results do not.
@@ -1390,6 +1437,7 @@ private List GetHistoryItems(IEnumerable histor
foreach (var item in historyItems)
{
var copiedItem = item.DeepCopyForHistoryStyle(Settings.HistoryStyle == HistoryStyle.LastOpened);
+ copiedItem.ContextData = item;
if (Settings.HistoryStyle == HistoryStyle.LastOpened)
{
@@ -1867,6 +1915,51 @@ private Result ContextMenuTopMost(Result result)
return menu;
}
+ private Result ContextMenuDeleteHistory(LastOpenedHistoryResult historyItem)
+ {
+ return new Result
+ {
+ Title = Localize.delete(),
+ IcoPath = Constant.DeleteIcon,
+ Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE74D"),
+ PluginDirectory = Constant.ProgramDirectory,
+ AsyncAction = async context =>
+ {
+ var source = _contextMenuSource;
+ var removeAllMatchingResults = Settings.HistoryStyle == HistoryStyle.LastOpened;
+ if (_history.Remove(historyItem, removeAllMatchingResults) > 0)
+ {
+ _historyItemsStorage.Save();
+ }
+
+ await ReturnFromContextMenuAsync();
+
+ // Home-page history is part of the regular result list, so refresh it after deletion.
+ // The dedicated history view is refreshed while returning from the context menu.
+ if (source == Results)
+ {
+ await QueryResultsAsync(false, isReQuery: true);
+ }
+
+ return false;
+ },
+ OriginQuery = historyItem.OriginQuery
+ };
+ }
+
+ private Result ContextMenuHistoryInfo(LastOpenedHistoryResult historyItem)
+ {
+ return new Result
+ {
+ Title = Settings.HistoryStyle == HistoryStyle.Query ? Localize.queryHistory() : Localize.executedHistory(),
+ IcoPath = Constant.HistoryIcon,
+ Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE81C"),
+ PluginDirectory = Constant.ProgramDirectory,
+ Action = _ => false,
+ OriginQuery = historyItem.OriginQuery
+ };
+ }
+
private static Result ContextMenuPluginSettings(Result result)
{
var id = result.PluginID;
@@ -1929,7 +2022,7 @@ private bool ContextMenuSelected()
return selected;
}
- private bool HistorySelected()
+ internal bool HistorySelected()
{
var selected = SelectedResults == History;
return selected;