diff --git a/Directory.Build.props b/Directory.Build.props
index a5545af12..b8c1d13ea 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,7 +1,7 @@
true
-
+
false
\ No newline at end of file
diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs
index 37ff59a08..7524d6c1a 100644
--- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs
+++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs
@@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.Hotkey;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.Storage;
+using Flow.Launcher.Localization.Attributes;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedModels;
@@ -514,6 +515,21 @@ namespace Flow.Launcher.Infrastructure.UserSettings
[JsonConverter(typeof(JsonStringEnumConverter))]
public LastQueryMode LastQueryMode { get; set; } = LastQueryMode.Selected;
+ private HistoryStyle _historyStyle = HistoryStyle.Query;
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public HistoryStyle HistoryStyle
+ {
+ get => _historyStyle;
+ set
+ {
+ if (_historyStyle != value)
+ {
+ _historyStyle = value;
+ OnPropertyChanged();
+ }
+ }
+ }
+
[JsonConverter(typeof(JsonStringEnumConverter))]
public AnimationSpeeds AnimationSpeed { get; set; } = AnimationSpeeds.Medium;
public int CustomAnimationLength { get; set; } = 360;
@@ -696,4 +712,14 @@ namespace Flow.Launcher.Infrastructure.UserSettings
FullPathOpen,
Directory
}
+
+ [EnumLocalize]
+ public enum HistoryStyle
+ {
+ [EnumLocalizeKey(nameof(Localize.queryHistory))]
+ Query,
+
+ [EnumLocalizeKey(nameof(Localize.executedHistory))]
+ LastOpened
+ }
}
diff --git a/Flow.Launcher.Plugin/Result.cs b/Flow.Launcher.Plugin/Result.cs
index 2c9b8d4fd..9404d1107 100644
--- a/Flow.Launcher.Plugin/Result.cs
+++ b/Flow.Launcher.Plugin/Result.cs
@@ -4,11 +4,13 @@ using System.IO;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Media;
+using System.Text.Json.Serialization;
namespace Flow.Launcher.Plugin
{
///
- /// Describes a result of a executed by a plugin
+ /// Describes a result of a executed by a plugin.
+ /// This or its child classes is serializable.
///
public class Result
{
@@ -21,6 +23,8 @@ namespace Flow.Launcher.Plugin
private string _icoPath;
+ private string _icoPathAbsolute;
+
private string _copyText = string.Empty;
private string _badgeIcoPath;
@@ -64,15 +68,27 @@ namespace Flow.Launcher.Plugin
public string AutoCompleteText { get; set; }
///
- /// The image to be displayed for the result.
+ /// Path or URI to the icon image for this result.
+ /// Updates appropriately when set.
///
- /// Can be a local file path or a URL.
- /// GlyphInfo is prioritized if not null
+ ///
+ /// Preferred usage: provide a path relative to the plugin directory (for example: "Images\icon.png").
+ /// Because is serialized, using relative paths keeps the icon reference portable
+ /// when Flow is moved.
+ ///
+ /// Accepted formats:
+ /// - Relative file paths (resolved against into )
+ /// - Absolute file paths (left as-is)
+ /// - HTTP/HTTPS URLs (left as-is)
+ /// - Data URIs (left as-is)
+ ///
public string IcoPath
{
get => _icoPath;
set
{
+ _icoPath = value;
+
// As a standard this property will handle prepping and converting to absolute local path for icon image processing
if (!string.IsNullOrEmpty(value)
&& !string.IsNullOrEmpty(PluginDirectory)
@@ -81,15 +97,23 @@ namespace Flow.Launcher.Plugin
&& !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
&& !value.StartsWith("data:image", StringComparison.OrdinalIgnoreCase))
{
- _icoPath = Path.Combine(PluginDirectory, value);
+ _icoPathAbsolute = Path.Combine(PluginDirectory, value);
}
else
{
- _icoPath = value;
+ _icoPathAbsolute = value;
}
}
}
+ ///
+ /// Absolute path or URI which is used to load and display the result icon for Flow.
+ /// This is populated by the setter.
+ /// If a relative path was provided to , this property will contain the resolved
+ /// absolute local path after combining with .
+ ///
+ public string IcoPathAbsolute => _icoPathAbsolute;
+
///
/// The image to be displayed for the badge of the result.
///
@@ -131,17 +155,34 @@ namespace Flow.Launcher.Plugin
///
/// Delegate to load an icon for this result.
///
+ [JsonIgnore]
public IconDelegate Icon = null;
///
/// Delegate to load an icon for the badge of this result.
///
+ [JsonIgnore]
public IconDelegate BadgeIcon = null;
+ private GlyphInfo _glyph;
+
///
/// Information for Glyph Icon (Prioritized than IcoPath/Icon if user enable Glyph Icons)
///
- public GlyphInfo Glyph { get; init; }
+ public GlyphInfo Glyph
+ {
+ get => _glyph;
+ init => _glyph = value;
+ }
+
+ ///
+ /// Set the Glyph Icon after initialization
+ ///
+ ///
+ public void SetGlyph(GlyphInfo glyph)
+ {
+ _glyph = glyph;
+ }
///
/// An action to take in the form of a function call when the result has been selected.
@@ -151,6 +192,7 @@ namespace Flow.Launcher.Plugin
/// Its result determines what happens to Flow Launcher's query form:
/// when true, the form will be hidden; when false, it will stay in focus.
///
+ [JsonIgnore]
public Func Action { get; set; }
///
@@ -161,6 +203,7 @@ namespace Flow.Launcher.Plugin
/// Its result determines what happens to Flow Launcher's query form:
/// when true, the form will be hidden; when false, it will stay in focus.
///
+ [JsonIgnore]
public Func> AsyncAction { get; set; }
///
@@ -203,11 +246,13 @@ namespace Flow.Launcher.Plugin
///
/// As external information for ContextMenu
///
+ [JsonIgnore]
public object ContextData { get; set; }
///
/// Plugin ID that generated this result
///
+ [JsonInclude]
public string PluginID { get; internal set; }
///
@@ -223,6 +268,7 @@ namespace Flow.Launcher.Plugin
///
/// Customized Preview Panel
///
+ [JsonIgnore]
public Lazy PreviewPanel { get; set; }
///
@@ -352,6 +398,7 @@ namespace Flow.Launcher.Plugin
///
/// Delegate to get the preview panel's image
///
+ [JsonIgnore]
public IconDelegate PreviewDelegate { get; set; } = null;
///
diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs
index b45bbc549..da11380b8 100644
--- a/Flow.Launcher/App.xaml.cs
+++ b/Flow.Launcher/App.xaml.cs
@@ -259,6 +259,9 @@ namespace Flow.Launcher
await PluginManager.InitializePluginsAsync(_mainVM);
+ // Refresh the history results after plugins are initialized so that we can parse the absolute icon paths
+ _mainVM.RefreshLastOpenedHistoryResults();
+
// Refresh home page after plugins are initialized because users may open main window during plugin initialization
// And home page is created without full plugin list
if (_settings.ShowHomePage && _mainVM.QueryResultsSelected() && string.IsNullOrEmpty(_mainVM.QueryText))
diff --git a/Flow.Launcher/Flow.Launcher.csproj b/Flow.Launcher/Flow.Launcher.csproj
index 8c7670426..576bf6f2f 100644
--- a/Flow.Launcher/Flow.Launcher.csproj
+++ b/Flow.Launcher/Flow.Launcher.csproj
@@ -185,7 +185,7 @@
-
+
diff --git a/Flow.Launcher/Helper/ResultHelper.cs b/Flow.Launcher/Helper/ResultHelper.cs
new file mode 100644
index 000000000..017651fdf
--- /dev/null
+++ b/Flow.Launcher/Helper/ResultHelper.cs
@@ -0,0 +1,45 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Flow.Launcher.Core.Plugin;
+using Flow.Launcher.Plugin;
+using Flow.Launcher.Storage;
+
+namespace Flow.Launcher.Helper;
+
+#nullable enable
+
+public static class ResultHelper
+{
+ public static async Task PopulateResultsAsync(LastOpenedHistoryResult item)
+ {
+ return await PopulateResultsAsync(item.PluginID, item.Query, item.Title, item.SubTitle, item.RecordKey);
+ }
+
+ public static async Task PopulateResultsAsync(string pluginId, string trimmedQuery, string title, string subTitle, string recordKey)
+ {
+ var plugin = PluginManager.GetPluginForId(pluginId);
+ if (plugin == null) return null;
+ var query = QueryBuilder.Build(trimmedQuery, trimmedQuery, PluginManager.GetNonGlobalPlugins());
+ if (query == null) return null;
+ try
+ {
+ var freshResults = await PluginManager.QueryForPluginAsync(plugin, query, CancellationToken.None);
+ // Try to match by record key first if it is valid, otherwise fall back to title + subtitle match
+ if (string.IsNullOrEmpty(recordKey))
+ {
+ return freshResults?.FirstOrDefault(r => r.Title == title && r.SubTitle == subTitle);
+ }
+ else
+ {
+ return freshResults?.FirstOrDefault(r => r.RecordKey == recordKey) ??
+ freshResults?.FirstOrDefault(r => r.Title == title && r.SubTitle == subTitle);
+ }
+ }
+ catch (System.Exception e)
+ {
+ App.API.LogException(nameof(ResultHelper), $"Failed to query results for {plugin.Metadata.Name}", e);
+ return null;
+ }
+ }
+}
diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml
index 451a21ad7..145e1883d 100644
--- a/Flow.Launcher/Languages/en.xaml
+++ b/Flow.Launcher/Languages/en.xaml
@@ -172,6 +172,10 @@
Show home page results when query text is empty.
Show History Results in Home Page
Maximum History Results Shown in Home Page
+ History Style
+ Choose the type of history to show in the History and Home Page
+ Query history
+ Last opened history
This can only be edited if plugin supports Home feature and Home Page is enabled.
Show Search Window at Foremost
Overrides other programs' 'Always on Top' setting and displays Flow in the foremost position.
diff --git a/Flow.Launcher/MainWindow.xaml.cs b/Flow.Launcher/MainWindow.xaml.cs
index 942cccbe9..06b2dda9e 100644
--- a/Flow.Launcher/MainWindow.xaml.cs
+++ b/Flow.Launcher/MainWindow.xaml.cs
@@ -322,6 +322,7 @@ namespace Flow.Launcher
break;
case nameof(Settings.ShowHomePage):
case nameof(Settings.ShowHistoryResultsForHomePage):
+ case nameof(Settings.HistoryStyle):
if (_viewModel.QueryResultsSelected() && string.IsNullOrEmpty(_viewModel.QueryText))
{
_viewModel.QueryResults();
@@ -859,7 +860,7 @@ namespace Flow.Launcher
public void UpdatePosition()
{
- // Initialize call twice to work around multi-display alignment issue- https://github.com/Flow-Launcher/Flow.Launcher/issues/2910
+ // Initialize call twice to workaround multi-display alignment issue- https://github.com/Flow-Launcher/Flow.Launcher/issues/2910
if (_viewModel.IsDialogJumpWindowUnderDialog())
{
InitializeDialogJumpPosition();
@@ -883,7 +884,7 @@ namespace Flow.Launcher
private void InitializePosition()
{
- // Initialize call twice to work around multi-display alignment issue- https://github.com/Flow-Launcher/Flow.Launcher/issues/2910
+ // Initialize call twice to workaround multi-display alignment issue- https://github.com/Flow-Launcher/Flow.Launcher/issues/2910
InitializePositionInner();
InitializePositionInner();
return;
diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs
index 6641ac689..aa78849ba 100644
--- a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs
+++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs
@@ -147,6 +147,8 @@ public partial class SettingsPaneGeneralViewModel : BaseModel
public List LastQueryModes { get; } =
DropdownDataGeneric.GetValues("LastQuery");
+ public List HistoryStyles { get; } = HistoryStyleLocalized.GetValues();
+
public bool EnableDialogJump
{
get => Settings.EnableDialogJump;
@@ -213,6 +215,7 @@ public partial class SettingsPaneGeneralViewModel : BaseModel
DropdownDataGeneric.UpdateLabels(SearchWindowAligns);
DropdownDataGeneric.UpdateLabels(SearchPrecisionScores);
DropdownDataGeneric.UpdateLabels(LastQueryModes);
+ HistoryStyleLocalized.UpdateLabels(HistoryStyles);
DropdownDataGeneric.UpdateLabels(DoublePinyinSchemas);
DropdownDataGeneric.UpdateLabels(DialogJumpWindowPositions);
DropdownDataGeneric.UpdateLabels(DialogJumpResultBehaviours);
diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml
index 4ab0ef330..b4c94cb35 100644
--- a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml
+++ b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml
@@ -255,6 +255,22 @@
SelectedValuePath="Value" />
+
+
+
+
+
+
+
+
+ Source="{Binding IcoPathAbsolute, IsAsync=True}" />
+/// A serializable result used to record the last opened history for reopening results.
+/// Inherits common result fields from and adds the original query and execution time.
+///
+public class LastOpenedHistoryResult : Result
+{
+ ///
+ /// The query string from Query.TrimmedQuery property, it is stored as a string instead of the entire Query class .
+ /// This is used so results can be reopened or re-run using the serialized query string.
+ ///
+ public string Query { get; set; } = string.Empty;
+
+ ///
+ /// The local date and time when this result was executed/opened.
+ ///
+ public DateTime ExecutedDateTime { get; set; }
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ public LastOpenedHistoryResult()
+ {
+ }
+
+ ///
+ /// Creates a from an existing .
+ /// Copies required fields and sets up default reopening actions.
+ ///
+ /// The original result to create history from.
+ public LastOpenedHistoryResult(Result result)
+ {
+ Title = result.Title;
+ SubTitle = result.SubTitle;
+ PluginID = result.PluginID;
+ Query = result.OriginQuery.TrimmedQuery;
+ OriginQuery = result.OriginQuery;
+ RecordKey = result.RecordKey;
+ IcoPath = result.IcoPath;
+ PluginDirectory = result.PluginDirectory;
+ Glyph = result.Glyph;
+ ExecutedDateTime = DateTime.Now;
+ // Used for Query History style reopening
+ Action = _ =>
+ {
+ App.API.BackToQueryResults();
+ App.API.ChangeQuery(result.OriginQuery.TrimmedQuery);
+ return false;
+ };
+ // Used for Last Opened History style reopening, currently need to be assigned at MainViewModel.cs
+ AsyncAction = null;
+ }
+
+ ///
+ /// Selectively creates a deep copy of the required properties for
+ /// based on the style of history- Last Opened or Query.
+ /// This copy should be independent of original and full isolated.
+ ///
+ /// A new containing the same required data.
+ public LastOpenedHistoryResult DeepCopyForHistoryStyle(bool isHistoryStyleLastOpened)
+ {
+ // queryValue and glyphValue are captured to ensure they are correctly referenced in the Action delegate.
+ var queryValue = Query;
+ var glyphValue = Glyph;
+
+ var title = string.Empty;
+ var showBadge = false;
+ var badgeIcoPath = string.Empty;
+ var icoPath = string.Empty;
+ var glyph = null as GlyphInfo;
+
+ if (isHistoryStyleLastOpened)
+ {
+ title = Title;
+ icoPath = IcoPath;
+ glyph = glyphValue != null
+ ? new GlyphInfo(glyphValue.FontFamily, glyphValue.Glyph)
+ : null;
+ showBadge = true;
+ badgeIcoPath = Constant.HistoryIcon;
+ }
+ else
+ {
+ title = Localize.executeQuery(Query);
+ icoPath = Constant.HistoryIcon;
+ glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE81C");
+ showBadge = false;
+ }
+
+ return new LastOpenedHistoryResult
+ {
+ Title = title,
+ // Subtitle has datetime which can cause duplicates when saving.
+ SubTitle = Localize.lastExecuteTime(ExecutedDateTime),
+ // Empty PluginID so the source of last opened history results won't be updated, this copy is meant to be temporary.
+ PluginID = string.Empty,
+ Query = Query,
+ OriginQuery = new Query { TrimmedQuery = Query },
+ RecordKey = RecordKey,
+ IcoPath = icoPath,
+ ShowBadge = showBadge,
+ BadgeIcoPath = badgeIcoPath,
+ PluginDirectory = PluginDirectory,
+ // Used for Query History style reopening
+ Action = _ =>
+ {
+ App.API.BackToQueryResults();
+ App.API.ChangeQuery(queryValue);
+ return false;
+ },
+ // Used for Last Opened History style reopening, currently need to be assigned at MainViewModel.cs
+ AsyncAction = null,
+ Glyph = glyph,
+ ExecutedDateTime = ExecutedDateTime
+ // Note: Other properties are left as default — copy if needed.
+ };
+ }
+
+ ///
+ /// Determines whether the specified is equivalent to this history result.
+ /// Comparison uses when available; otherwise falls back to title/subtitle/plugin id and query.
+ ///
+ /// The result to compare to.
+ /// true if the results are considered equal; otherwise false.
+ public bool Equals(Result r)
+ {
+ if (string.IsNullOrEmpty(RecordKey) || string.IsNullOrEmpty(r.RecordKey))
+ {
+ return Title == r.Title
+ && SubTitle == r.SubTitle
+ && PluginID == r.PluginID
+ && Query == r.OriginQuery.TrimmedQuery;
+ }
+ else
+ {
+ return RecordKey == r.RecordKey
+ && PluginID == r.PluginID
+ && Query == r.OriginQuery.TrimmedQuery;
+ }
+ }
+}
diff --git a/Flow.Launcher/Storage/QueryHistory.cs b/Flow.Launcher/Storage/QueryHistory.cs
index 339f7b91e..d9a527f61 100644
--- a/Flow.Launcher/Storage/QueryHistory.cs
+++ b/Flow.Launcher/Storage/QueryHistory.cs
@@ -2,35 +2,118 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
+using Flow.Launcher.Core.Plugin;
+using Flow.Launcher.Plugin;
namespace Flow.Launcher.Storage
{
public class History
{
[JsonInclude]
- public List Items { get; private set; } = new List();
+#pragma warning disable CS0618 // Type or member is obsolete
+ public List Items { get; private set; } = [];
+#pragma warning restore CS0618 // Type or member is obsolete
- private int _maxHistory = 300;
+ [JsonInclude]
+ public List LastOpenedHistoryItems { get; private set; } = [];
- public void Add(string query)
+ private readonly int _maxHistory = 300;
+
+ ///
+ /// Migrate legacy history data (stored in ) into the new
+ /// format and append them to
+ /// .
+ ///
+ [Obsolete("For backwards compatibility. Remove after release v2.3.0")]
+ public void PopulateHistoryFromLegacyHistory()
{
- if (string.IsNullOrEmpty(query)) return;
- if (Items.Count > _maxHistory)
+ if (Items.Count == 0) return;
+ // Migrate old history items to new LastOpenedHistoryItems
+ foreach (var item in Items)
{
- Items.RemoveAt(0);
+ LastOpenedHistoryItems.Add(new LastOpenedHistoryResult
+ {
+ Title = Localize.executeQuery(item.Query),
+ OriginQuery = new Query { TrimmedQuery = item.Query },
+ Query = item.Query,
+ Action = _ =>
+ {
+ App.API.BackToQueryResults();
+ App.API.ChangeQuery(item.Query);
+ return false;
+ },
+ ExecutedDateTime = item.ExecutedDateTime
+ });
+ }
+ Items.Clear();
+ }
+
+ ///
+ /// Records a result into the last-opened history list ().
+ /// This will also update the IcoPath if existing history item has one that is different.
+ ///
+ /// The result to add to history. Must have a non-empty ..
+ public void Add(Result result)
+ {
+ if (string.IsNullOrEmpty(result.OriginQuery.TrimmedQuery)) return;
+ // History results triggered from homepage do not contain PluginID,
+ // these are intentionally not saved otherwise cause duplicates due to subtitle
+ // containing datetime string.
+ if (string.IsNullOrEmpty(result.PluginID)) return;
+
+ // Maintain the max history limit
+ if (LastOpenedHistoryItems.Count > _maxHistory)
+ {
+ LastOpenedHistoryItems.RemoveAt(0);
}
- if (Items.Count > 0 && Items.Last().Query == query)
+ // If the last item is the same as the current result, just update the timestamp and the icon path
+ if (LastOpenedHistoryItems.Count > 0 &&
+ TryGetLastOpenedHistoryResult(result, out var existingHistoryItem))
{
- Items.Last().ExecutedDateTime = DateTime.Now;
+ existingHistoryItem.ExecutedDateTime = DateTime.Now;
+
+ if (existingHistoryItem.IcoPath != result.IcoPath)
+ existingHistoryItem.IcoPath = result.IcoPath;
+
+ if (existingHistoryItem.Glyph?.Glyph != result.Glyph?.Glyph
+ || existingHistoryItem.Glyph?.FontFamily != result.Glyph?.FontFamily)
+ existingHistoryItem.SetGlyph(result.Glyph);
}
else
{
- Items.Add(new HistoryItem
- {
- Query = query,
- ExecutedDateTime = DateTime.Now
- });
+ LastOpenedHistoryItems.Add(new LastOpenedHistoryResult(result));
+ }
+ }
+
+ ///
+ /// Attempts to find an existing in
+ /// that is considered equal to the supplied .
+ ///
+ private bool TryGetLastOpenedHistoryResult(Result result, out LastOpenedHistoryResult historyItem)
+ {
+ historyItem = LastOpenedHistoryItems.FirstOrDefault(x => x.Equals(result));
+ return historyItem is not null;
+ }
+
+ ///
+ /// Flow uses IcoPathAbsolute property to display result the icons. This refreshes the IcoPathAbsolute
+ /// property using current plugin metadata by updating the PluginDirectory property, which in turn also
+ /// updates IcoPath. This keeps the saved icon paths of results updated correctly if flow is moved around.
+ ///
+ /// Call this after plugins are loaded/initialized.
+ public void UpdateIcoPathAbsolute()
+ {
+ if (LastOpenedHistoryItems.Count == 0) return;
+
+ foreach (var item in LastOpenedHistoryItems)
+ {
+ if (string.IsNullOrEmpty(item.PluginID)) continue;
+
+ var pluginPair = PluginManager.GetPluginForId(item.PluginID);
+ if (pluginPair == null) continue;
+
+ item.PluginDirectory = pluginPair.Metadata.PluginDirectory;
}
}
}
diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs
index 533a72202..333ac3652 100644
--- a/Flow.Launcher/ViewModel/MainViewModel.cs
+++ b/Flow.Launcher/ViewModel/MainViewModel.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
@@ -9,16 +9,17 @@ using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Windows;
-using System.Windows.Input;
using System.Windows.Controls;
+using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.DependencyInjection;
using CommunityToolkit.Mvvm.Input;
using Flow.Launcher.Core.Plugin;
+using Flow.Launcher.Helper;
using Flow.Launcher.Infrastructure;
-using Flow.Launcher.Infrastructure.Hotkey;
using Flow.Launcher.Infrastructure.DialogJump;
+using Flow.Launcher.Infrastructure.Hotkey;
using Flow.Launcher.Infrastructure.Storage;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
@@ -43,10 +44,10 @@ namespace Flow.Launcher.ViewModel
private string _ignoredQueryText; // Used to ignore query text change when switching between context menu and query results
private readonly FlowLauncherJsonStorage _historyItemsStorage;
- private readonly FlowLauncherJsonStorage _userSelectedRecordStorage;
- private readonly FlowLauncherJsonStorageTopMostRecord _topMostRecord;
private readonly History _history;
private int lastHistoryIndex = 1;
+ private readonly FlowLauncherJsonStorage _userSelectedRecordStorage;
+ private readonly FlowLauncherJsonStorageTopMostRecord _topMostRecord;
private readonly UserSelectedRecord _userSelectedRecord;
private CancellationTokenSource _updateSource; // Used to cancel old query flows
@@ -152,10 +153,10 @@ namespace Flow.Launcher.ViewModel
};
_historyItemsStorage = new FlowLauncherJsonStorage();
- _userSelectedRecordStorage = new FlowLauncherJsonStorage();
- _topMostRecord = new FlowLauncherJsonStorageTopMostRecord();
_history = _historyItemsStorage.Load();
+ _userSelectedRecordStorage = new FlowLauncherJsonStorage();
_userSelectedRecord = _userSelectedRecordStorage.Load();
+ _topMostRecord = new FlowLauncherJsonStorageTopMostRecord();
ContextMenu = new ResultsViewModel(Settings, this)
{
@@ -354,11 +355,17 @@ namespace Flow.Launcher.ViewModel
if (QueryResultsSelected())
{
SelectedResults = History;
- History.SelectedIndex = _history.Items.Count - 1;
+ if (History.Results.Count > 0)
+ {
+ SelectedResults.SelectedIndex = 0;
+ SelectedResults.SelectedItem = History.Results[0];
+ }
}
else
{
SelectedResults = Results;
+ PreviewSelectedItem = Results.SelectedItem;
+ _ = UpdatePreviewAsync();
}
}
@@ -382,10 +389,11 @@ namespace Flow.Launcher.ViewModel
[RelayCommand]
public void ReverseHistory()
{
- if (_history.Items.Count > 0)
+ var historyItems = _history.LastOpenedHistoryItems;
+ if (historyItems.Count > 0)
{
- ChangeQueryText(_history.Items[^lastHistoryIndex].Query);
- if (lastHistoryIndex < _history.Items.Count)
+ ChangeQueryText(historyItems[^lastHistoryIndex].Query);
+ if (lastHistoryIndex < historyItems.Count)
{
lastHistoryIndex++;
}
@@ -395,9 +403,10 @@ namespace Flow.Launcher.ViewModel
[RelayCommand]
public void ForwardHistory()
{
- if (_history.Items.Count > 0)
+ var historyItems = _history.LastOpenedHistoryItems;
+ if (historyItems.Count > 0)
{
- ChangeQueryText(_history.Items[^lastHistoryIndex].Query);
+ ChangeQueryText(historyItems[^lastHistoryIndex].Query);
if (lastHistoryIndex > 1)
{
lastHistoryIndex--;
@@ -428,7 +437,8 @@ namespace Flow.Launcher.ViewModel
{
// 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 != null)
+ if (SelectedResults.SelectedItem?.Result != null &&
+ !string.IsNullOrEmpty(SelectedResults.SelectedItem.Result.PluginID)) // Do not show context menu for history results
{
SelectedResults = ContextMenu;
}
@@ -436,6 +446,8 @@ namespace Flow.Launcher.ViewModel
else
{
SelectedResults = Results;
+ PreviewSelectedItem = Results.SelectedItem;
+ _ = UpdatePreviewAsync();
}
}
@@ -489,6 +501,8 @@ namespace Flow.Launcher.ViewModel
[RelayCommand]
private async Task OpenResultAsync(string index)
{
+ // Must check query results selected before executing the action
+ var queryResultsSelected = QueryResultsSelected();
var results = SelectedResults;
if (index is not null)
{
@@ -529,10 +543,12 @@ namespace Flow.Launcher.ViewModel
}
}
- if (QueryResultsSelected())
+ // Record user selected result for result ranking
+ _userSelectedRecord.Add(result);
+ // Add item to history only if it is from results but not context menu or history
+ if (queryResultsSelected)
{
- _userSelectedRecord.Add(result);
- _history.Add(result.OriginQuery.RawQuery);
+ _history.Add(result);
lastHistoryIndex = 1;
}
}
@@ -561,7 +577,7 @@ namespace Flow.Launcher.ViewModel
resultsCopy.Add(resultCopy);
}
}
-
+
return resultsCopy;
}
@@ -608,10 +624,11 @@ namespace Flow.Launcher.ViewModel
[RelayCommand]
private void SelectPrevItem()
{
+ var historyItems = _history.LastOpenedHistoryItems;
if (QueryResultsSelected() // Results selected
&& string.IsNullOrEmpty(QueryText) // No input
&& Results.Visibility != Visibility.Visible // No items in result list, e.g. when home page is off and no query text is entered, therefore the view is collapsed.
- && _history.Items.Count > 0) // Have history items
+ && historyItems.Count > 0) // Have history items
{
lastHistoryIndex = 1;
ReverseHistory();
@@ -634,6 +651,8 @@ namespace Flow.Launcher.ViewModel
if (!QueryResultsSelected())
{
SelectedResults = Results;
+ PreviewSelectedItem = Results.SelectedItem;
+ _ = UpdatePreviewAsync();
}
else
{
@@ -910,7 +929,7 @@ namespace Flow.Launcher.ViewModel
private string _placeholderText;
public string PlaceholderText
{
- get => string.IsNullOrEmpty(_placeholderText) ? Localize.queryTextBoxPlaceholder(): _placeholderText;
+ get => string.IsNullOrEmpty(_placeholderText) ? Localize.queryTextBoxPlaceholder() : _placeholderText;
set
{
_placeholderText = value;
@@ -1152,6 +1171,7 @@ namespace Flow.Launcher.ViewModel
HideInternalPreview();
_ = OpenExternalPreviewAsync(path);
}
+
break;
case true
when !CanExternalPreviewSelectedResult(out var _):
@@ -1243,40 +1263,30 @@ namespace Flow.Launcher.ViewModel
var selected = Results.SelectedItem?.Result;
- if (selected != null) // SelectedItem returns null if selection is empty.
+ if (selected != null && // SelectedItem returns null if selection is empty.
+ !string.IsNullOrEmpty(selected.PluginID)) // SelectedItem must have a valid PluginID, history results do not.
{
- List results;
- if (selected.PluginID == null) // SelectedItem from history in home page.
- {
- results = new()
- {
- ContextMenuTopMost(selected)
- };
- }
- else
- {
- results = PluginManager.GetContextMenusForPlugin(selected);
- results.Add(ContextMenuTopMost(selected));
- results.Add(ContextMenuPluginInfo(selected));
- }
+ List results = PluginManager.GetContextMenusForPlugin(selected);
+ results.Add(ContextMenuTopMost(selected));
+ results.Add(ContextMenuPluginInfo(selected));
if (!string.IsNullOrEmpty(query))
{
var filtered = results.Select(x => x.Clone()).Where
(
r =>
- {
- var match = App.API.FuzzySearch(query, r.Title);
- if (!match.IsSearchPrecisionScoreMet())
- {
- match = App.API.FuzzySearch(query, r.SubTitle);
- }
+ {
+ var match = App.API.FuzzySearch(query, r.Title);
+ if (!match.IsSearchPrecisionScoreMet())
+ {
+ match = App.API.FuzzySearch(query, r.SubTitle);
+ }
- if (!match.IsSearchPrecisionScoreMet()) return false;
+ if (!match.IsSearchPrecisionScoreMet()) return false;
- r.Score = match.Score;
- return true;
- }).ToList();
+ r.Score = match.Score;
+ return true;
+ }).ToList();
ContextMenu.AddResults(filtered, id);
}
else
@@ -1292,7 +1302,7 @@ namespace Flow.Launcher.ViewModel
var query = QueryText.ToLower().Trim();
History.Clear();
- var results = GetHistoryItems(_history.Items);
+ var results = GetHistoryItems(_history.LastOpenedHistoryItems);
if (!string.IsNullOrEmpty(query))
{
@@ -1309,30 +1319,77 @@ namespace Flow.Launcher.ViewModel
}
}
- private static List GetHistoryItems(IEnumerable historyItems)
+ private List GetHistoryItems(IEnumerable historyItems, int? maxResult = null)
{
var results = new List();
- foreach (var h in historyItems)
+
+ // Order by executed time descending: Latest -> Oldest
+ historyItems = historyItems.OrderByDescending(x => x.ExecutedDateTime);
+
+ if (Settings.HistoryStyle == HistoryStyle.LastOpened)
{
- var result = new Result
- {
- Title = Localize.executeQuery(h.Query),
- SubTitle = Localize.lastExecuteTime(h.ExecutedDateTime),
- IcoPath = Constant.HistoryIcon,
- OriginQuery = new Query { RawQuery = h.Query },
- Action = _ =>
- {
- App.API.BackToQueryResults();
- App.API.ChangeQuery(h.Query);
- return false;
- },
- Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE81C")
- };
- results.Add(result);
+ // Items saved to disk are differentiated by Query also, but LastOpened style only cares about unique results
+ historyItems = historyItems
+ .GroupBy(r => new { r.Title, r.SubTitle, r.PluginID, r.RecordKey })
+ .Select(g => g.First());
}
+
+ // Max history results to return for display
+ if (maxResult.HasValue)
+ {
+ historyItems = historyItems.Take(maxResult.Value);
+ }
+
+ foreach (var item in historyItems)
+ {
+ var copiedItem = item.DeepCopyForHistoryStyle(Settings.HistoryStyle == HistoryStyle.LastOpened);
+
+ if (Settings.HistoryStyle == HistoryStyle.LastOpened)
+ {
+ copiedItem.AsyncAction = async c =>
+ {
+ // Use original history item to reflect correct result because properties like subtitle have been modified in copiedItem
+ var reflectResult = await ResultHelper.PopulateResultsAsync(item);
+ if (reflectResult != null)
+ {
+ // Since some actions may need to hide the Flow window to execute
+ // So let us populate the results of them
+ return await reflectResult.ExecuteAsync(c);
+ }
+
+ // If we cannot get the result, fallback to re-query
+ App.API.BackToQueryResults();
+ App.API.ChangeQuery(copiedItem.Query);
+ return false;
+ };
+ }
+
+ results.Add(copiedItem);
+ }
+
return results;
}
+ ///
+ /// Refreshes the last-opened history storage by migrating legacy entries and
+ /// updating stored icon paths to their resolved (absolute) locations.
+ ///
+ ///
+ /// Calls to refresh absolute icon
+ /// paths on the migrated/saved history entries by updating each item's
+ /// PluginDirectory (which in turn resolves IcoPathAbsolute).
+ ///
+ /// Important:
+ /// - Plugins must be initialized (their metadata and PluginDirectory set)
+ /// before calling this method; otherwise icon resolution cannot be performed.
+ ///
+ internal void RefreshLastOpenedHistoryResults()
+ {
+ _history.PopulateHistoryFromLegacyHistory();
+
+ _history.UpdateIcoPathAbsolute();
+ }
+
private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, bool reSelect = true)
{
_updateSource?.Cancel();
@@ -1574,10 +1631,8 @@ namespace Flow.Launcher.ViewModel
void QueryHistoryTask(CancellationToken token)
{
- // Select last history results and revert its order to make sure last history results are on top
- var historyItems = _history.Items.TakeLast(Settings.MaxHistoryResultsToShowForHomePage).Reverse();
-
- var results = GetHistoryItems(historyItems);
+ // Select last history results
+ var results = GetHistoryItems(_history.LastOpenedHistoryItems, Settings.MaxHistoryResultsToShowForHomePage);
if (token.IsCancellationRequested) return;
diff --git a/Flow.Launcher/ViewModel/ResultViewModel.cs b/Flow.Launcher/ViewModel/ResultViewModel.cs
index f2f49f8f1..077acfce7 100644
--- a/Flow.Launcher/ViewModel/ResultViewModel.cs
+++ b/Flow.Launcher/ViewModel/ResultViewModel.cs
@@ -141,7 +141,7 @@ namespace Flow.Launcher.ViewModel
private bool GlyphAvailable => Glyph is not null;
- private bool ImgIconAvailable => !string.IsNullOrEmpty(Result.IcoPath) || Result.Icon is not null;
+ private bool ImgIconAvailable => !string.IsNullOrEmpty(Result.IcoPathAbsolute) || Result.Icon is not null;
private bool BadgeIconAvailable => !string.IsNullOrEmpty(Result.BadgeIcoPath) || Result.BadgeIcon is not null;
@@ -236,7 +236,7 @@ namespace Flow.Launcher.ViewModel
private async Task LoadImageAsync()
{
- var imagePath = Result.IcoPath;
+ var imagePath = Result.IcoPathAbsolute;
var iconDelegate = Result.Icon;
if (ImageLoader.TryGetValue(imagePath, false, out var img))
{
@@ -266,7 +266,7 @@ namespace Flow.Launcher.ViewModel
private async Task LoadPreviewImageAsync()
{
- var imagePath = Result.Preview.PreviewImagePath ?? Result.IcoPath;
+ var imagePath = Result.Preview.PreviewImagePath ?? Result.IcoPathAbsolute;
var iconDelegate = Result.Preview.PreviewDelegate ?? Result.Icon;
if (ImageLoader.TryGetValue(imagePath, true, out var img))
{
diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj
index b17b6f466..aff73ea77 100644
--- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj
+++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj
@@ -51,6 +51,8 @@
$(OutputPath)runtimes\linux-s390x;
$(OutputPath)runtimes\linux-x64;
$(OutputPath)runtimes\linux-x86;
+ $(OutputPath)runtimes\linux-musl-riscv64;
+ $(OutputPath)runtimes\linux-riscv64;
$(OutputPath)runtimes\maccatalyst-arm64;
$(OutputPath)runtimes\maccatalyst-x64;
$(OutputPath)runtimes\osx;
@@ -74,6 +76,8 @@
$(PublishDir)runtimes\linux-s390x;
$(PublishDir)runtimes\linux-x64;
$(PublishDir)runtimes\linux-x86;
+ $(PublishDir)runtimes\linux-musl-riscv64;
+ $(PublishDir)runtimes\linux-riscv64;
$(PublishDir)runtimes\maccatalyst-arm64;
$(PublishDir)runtimes\maccatalyst-x64;
$(PublishDir)runtimes\osx;
@@ -110,4 +114,4 @@
-
+
\ No newline at end of file