From 221ca7b4eae96ad4525ae0d6e29ca1092fe518da Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 6 Aug 2026 14:23:59 +0800 Subject: [PATCH 1/5] Add delete history feature with icon and tests Implemented Remove method in QueryHistory to delete entries. Added delete icon and context menu option in MainViewModel. Updated Constant.cs for icon reference. Added unit tests for single and multiple entry removal. --- Flow.Launcher.Infrastructure/Constant.cs | 3 +- Flow.Launcher.Test/QueryHistoryTest.cs | 50 +++++++++++++++++++++++ Flow.Launcher/Images/delete.png | Bin 0 -> 914 bytes Flow.Launcher/Storage/QueryHistory.cs | 25 +++++++++++- Flow.Launcher/ViewModel/MainViewModel.cs | 23 +++++++++++ 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 Flow.Launcher.Test/QueryHistoryTest.cs create mode 100644 Flow.Launcher/Images/delete.png 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 0000000000000000000000000000000000000000..85e293bff5d77293fa2d89b70d57a7320a2ca486 GIT binary patch literal 914 zcmV;D18w|?P)FY12mOowml44vW6YlQehUIo#)V+B89k zR$6I=CA-&0uI>$weEl&zf?Dhi-_HKLc3TrCExX#S?4EWDjj*fT%!reg-N7697n-&) zEH0ixGWiUtR1T>Wn#3wnsi*X}|6hOxHE-iM##0V~W}ZC$BAHy{5P+Qsj;x;c!VUyi zR?qlg2ZAfPHU)Md*aXfhumiy+(C&vF2sQyn0Cpg_vgQcFiku^em39Rm+k8m3`z?EA zs6$=4Cs(J-C*TZ0M$Q?+=XO8V7v7_=e*k@S8Y>RJv`eHNfntZcYp>rG@1+@5eLjJ6 zVaQl<2C)9-J<9oYbOim*G`={4Rb5)6Yt$)cec@ekhShvFfpu3HGM1cv6b=q58!Af} zW79~V3s!pcn%4f@Jf*O|k0pm9olWowbo4@o?h3&eo2lAZNn!?RSExv>j_wLcXA^t^ zoe@}}cZ8vjPDwUcfO&5Y#<9nya&7Vv(j8&TxdfkpJBnK9oxLz7W~HYrJ^6`Qr0JC1 zwdb-4cxtoJ;Tt_!5v|_QYV`lb<}T8$Gci zmYvCm$arJY*#w)wH>DpkK6zB>Pl?R^IhfvfrAM!6?YP9@&JLD)ROxJjPr!d3G7N7V z`L`dcH}6YSb!qLmMDF>^lBmUeK7qgm$k+&6K-yQ5pr6Q@smCytgtSX!l#;~Z&UfTq zyei&HGpzc20>J@Tu@M}AL64I!j%=XcvR8&W%Ss%k(qFu{R+mj+Gc*7@5NrbBi?9R1 zCeS+wI}lvi>b(Rz5NraG%di8%CJ-Hh9SAl7^>TJg9YQnNj1KYrA8kw3v~5+(Zuebo+;CeTVNt%wtw-*7XPttYdqkpKVy07*qoM6N<$f_w&~c>n+a literal 0 HcmV?d00001 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 2dfd161a3e2..f77ea732570 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -1357,6 +1357,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) { @@ -1834,6 +1835,28 @@ 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, + Action = context => + { + var removeAllMatchingResults = Settings.HistoryStyle == HistoryStyle.LastOpened; + if (_history.Remove(historyItem, removeAllMatchingResults) > 0) + { + _historyItemsStorage.Save(); + } + + return false; + }, + OriginQuery = historyItem.OriginQuery + }; + } + private static Result ContextMenuPluginSettings(Result result) { var id = result.PluginID; From 6bf02ae87827a102590da8e9162613cd0ce79fdf Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Fri, 7 Aug 2026 16:15:08 +0800 Subject: [PATCH 2/5] Restore results view and query after context menu Remember and restore previous results view and query text when opening/closing the context menu. Add fields to track context menu state. Refactor context menu logic for regular/history results. Implement ReturnFromContextMenu() to centralize UI restoration. Update Esc command to close context menu and restore state. Improve context menu population and handle history result deletion with results refresh. --- Flow.Launcher/ViewModel/MainViewModel.cs | 86 +++++++++++++++++++----- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index f77ea732570..05d43d4b8b2 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; @@ -432,23 +436,49 @@ private void LoadContextMenu() 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; - } + ReturnFromContextMenu(); + 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 void ReturnFromContextMenu() + { + 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; - _ = UpdatePreviewAsync(); + ChangeQueryText(queryText); } + + // Refresh the preview panel to show the previously selected result + PreviewSelectedItem = source.SelectedItem; + _ = UpdatePreviewAsync(); + + _contextMenuSource = null; + _contextMenuTarget = null; + _queryTextBeforeContextMenu = string.Empty; } [RelayCommand] @@ -657,7 +687,11 @@ private void SelectNextItem() [RelayCommand] private void Esc() { - if (!QueryResultsSelected()) + if (ContextMenuSelected()) + { + ReturnFromContextMenu(); + } + else if (!QueryResultsSelected()) { SelectedResults = Results; PreviewSelectedItem = Results.SelectedItem; @@ -890,7 +924,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. @@ -1274,7 +1311,14 @@ 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)], 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. @@ -1845,12 +1889,22 @@ private Result ContextMenuDeleteHistory(LastOpenedHistoryResult historyItem) PluginDirectory = Constant.ProgramDirectory, Action = context => { + var source = _contextMenuSource; var removeAllMatchingResults = Settings.HistoryStyle == HistoryStyle.LastOpened; if (_history.Remove(historyItem, removeAllMatchingResults) > 0) { _historyItemsStorage.Save(); } + ReturnFromContextMenu(); + + // 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) + { + _ = QueryResultsAsync(false, isReQuery: true); + } + return false; }, OriginQuery = historyItem.OriginQuery From 2493cadfc27f7223bed06823825962826331d425 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Fri, 7 Aug 2026 16:26:07 +0800 Subject: [PATCH 3/5] Add "History Info" to history item context menu Added a "History Info" option to the history item context menu in MainViewModel. The new ContextMenuHistoryInfo method creates this entry, allowing users to view information about a history item. The menu now includes both "Delete History" and "History Info" options. --- Flow.Launcher/ViewModel/MainViewModel.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index 05d43d4b8b2..89736f3b655 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -1316,7 +1316,10 @@ private void QueryContextMenu() if (selected is LastOpenedHistoryResult && selected.ContextData is LastOpenedHistoryResult historyItem) { - ContextMenu.AddResults([ContextMenuDeleteHistory(historyItem)], id); + ContextMenu.AddResults([ + ContextMenuDeleteHistory(historyItem), + ContextMenuHistoryInfo(historyItem) + ], id); return; } @@ -1911,6 +1914,19 @@ private Result ContextMenuDeleteHistory(LastOpenedHistoryResult historyItem) }; } + 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; From 6f43beaaca3386afb7370cb956c5c572888e9825 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Fri, 7 Aug 2026 16:27:39 +0800 Subject: [PATCH 4/5] Expand Right arrow logic to support history selection The context menu now loads when a history item is selected and the caret is at the end of the text box, in addition to query results. Made HistorySelected internal in MainViewModel.cs to enable this behavior from MainWindow.xaml.cs. --- Flow.Launcher/MainWindow.xaml.cs | 4 ++-- Flow.Launcher/ViewModel/MainViewModel.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Flow.Launcher/MainWindow.xaml.cs b/Flow.Launcher/MainWindow.xaml.cs index 1d71e88d940..1d2074e690d 100644 --- a/Flow.Launcher/MainWindow.xaml.cs +++ b/Flow.Launcher/MainWindow.xaml.cs @@ -460,8 +460,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/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index 89736f3b655..98300fae843 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -1989,7 +1989,7 @@ private bool ContextMenuSelected() return selected; } - private bool HistorySelected() + internal bool HistorySelected() { var selected = SelectedResults == History; return selected; From 5876575ded3b55b911658d0f9e7ac49cde377519 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Fri, 7 Aug 2026 16:35:51 +0800 Subject: [PATCH 5/5] Refactor context menu logic to be fully async Refactored ReturnFromContextMenu to ReturnFromContextMenuAsync and updated all call sites to use await. Related methods like ChangeQueryText and UpdatePreviewAsync are now awaited. Updated context menu delete action to use an async lambda, ensuring UI and data updates complete asynchronously before proceeding. --- Flow.Launcher/ViewModel/MainViewModel.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index 5d7324e8b2d..00ff2fcaef8 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -420,7 +420,7 @@ private async Task LoadContextMenuAsync() // If the context menu is already open, we need to return to the previous results view if (ContextMenuSelected()) { - ReturnFromContextMenu(); + await ReturnFromContextMenuAsync(); return; } @@ -441,7 +441,7 @@ private async Task LoadContextMenuAsync() SelectedResults = ContextMenu; } - private void ReturnFromContextMenu() + private async Task ReturnFromContextMenuAsync() { var source = _contextMenuSource ?? Results; var queryText = _queryTextBeforeContextMenu; @@ -450,12 +450,12 @@ private void ReturnFromContextMenu() SelectedResults = source; if (source == History) { - ChangeQueryText(queryText); + await ChangeQueryTextAsync(queryText); } // Refresh the preview panel to show the previously selected result PreviewSelectedItem = source.SelectedItem; - _ = UpdatePreviewAsync(); + await UpdatePreviewAsync(); _contextMenuSource = null; _contextMenuTarget = null; @@ -670,7 +670,7 @@ private async Task EscAsync() { if (ContextMenuSelected()) { - ReturnFromContextMenu(); + await ReturnFromContextMenuAsync(); } else if (!QueryResultsSelected()) { @@ -1923,7 +1923,7 @@ private Result ContextMenuDeleteHistory(LastOpenedHistoryResult historyItem) IcoPath = Constant.DeleteIcon, Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE74D"), PluginDirectory = Constant.ProgramDirectory, - Action = context => + AsyncAction = async context => { var source = _contextMenuSource; var removeAllMatchingResults = Settings.HistoryStyle == HistoryStyle.LastOpened; @@ -1932,13 +1932,13 @@ private Result ContextMenuDeleteHistory(LastOpenedHistoryResult historyItem) _historyItemsStorage.Save(); } - ReturnFromContextMenu(); + 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) { - _ = QueryResultsAsync(false, isReQuery: true); + await QueryResultsAsync(false, isReQuery: true); } return false;