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;