Merge pull request #4057 from Flow-Launcher/last_history_show_result_icon
Some checks failed
Build / build (push) Has been cancelled

History results display actual result icon for Last Opened history style
This commit is contained in:
Jeremy Wu 2026-01-26 17:50:55 +11:00 committed by GitHub
commit 6c8add051f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 365 additions and 140 deletions

View file

@ -4,11 +4,13 @@ using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Media; using System.Windows.Media;
using System.Text.Json.Serialization;
namespace Flow.Launcher.Plugin namespace Flow.Launcher.Plugin
{ {
/// <summary> /// <summary>
/// Describes a result of a <see cref="Query"/> executed by a plugin /// Describes a result of a <see cref="Query"/> executed by a plugin.
/// This or its child classes is serializable.
/// </summary> /// </summary>
public class Result public class Result
{ {
@ -21,6 +23,8 @@ namespace Flow.Launcher.Plugin
private string _icoPath; private string _icoPath;
private string _icoPathAbsolute;
private string _copyText = string.Empty; private string _copyText = string.Empty;
private string _badgeIcoPath; private string _badgeIcoPath;
@ -64,15 +68,27 @@ namespace Flow.Launcher.Plugin
public string AutoCompleteText { get; set; } public string AutoCompleteText { get; set; }
/// <summary> /// <summary>
/// The image to be displayed for the result. /// Path or URI to the icon image for this result.
/// Updates <see cref="IcoPathAbsolute"/> appropriately when set.
/// </summary> /// </summary>
/// <value>Can be a local file path or a URL.</value> /// <remarks>
/// <remarks>GlyphInfo is prioritized if not null</remarks> /// Preferred usage: provide a path relative to the plugin directory (for example: "Images\icon.png").
/// Because <see cref="IcoPath"/> is serialized, using relative paths keeps the icon reference portable
/// when Flow is moved.
///
/// Accepted formats:
/// - Relative file paths (resolved against <see cref="PluginDirectory"/> into <see cref="IcoPathAbsolute"/>)
/// - Absolute file paths (left as-is)
/// - HTTP/HTTPS URLs (left as-is)
/// - Data URIs (left as-is)
/// </remarks>
public string IcoPath public string IcoPath
{ {
get => _icoPath; get => _icoPath;
set set
{ {
_icoPath = value;
// As a standard this property will handle prepping and converting to absolute local path for icon image processing // As a standard this property will handle prepping and converting to absolute local path for icon image processing
if (!string.IsNullOrEmpty(value) if (!string.IsNullOrEmpty(value)
&& !string.IsNullOrEmpty(PluginDirectory) && !string.IsNullOrEmpty(PluginDirectory)
@ -81,15 +97,23 @@ namespace Flow.Launcher.Plugin
&& !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
&& !value.StartsWith("data:image", StringComparison.OrdinalIgnoreCase)) && !value.StartsWith("data:image", StringComparison.OrdinalIgnoreCase))
{ {
_icoPath = Path.Combine(PluginDirectory, value); _icoPathAbsolute = Path.Combine(PluginDirectory, value);
} }
else else
{ {
_icoPath = value; _icoPathAbsolute = value;
} }
} }
} }
/// <summary>
/// Absolute path or URI which is used to load and display the result icon for Flow.
/// This is populated by the <see cref="IcoPath"/> setter.
/// If a relative path was provided to <see cref="IcoPath"/>, this property will contain the resolved
/// absolute local path after combining with <see cref="PluginDirectory"/>.
/// </summary>
public string IcoPathAbsolute => _icoPathAbsolute;
/// <summary> /// <summary>
/// The image to be displayed for the badge of the result. /// The image to be displayed for the badge of the result.
/// </summary> /// </summary>
@ -131,17 +155,34 @@ namespace Flow.Launcher.Plugin
/// <summary> /// <summary>
/// Delegate to load an icon for this result. /// Delegate to load an icon for this result.
/// </summary> /// </summary>
[JsonIgnore]
public IconDelegate Icon = null; public IconDelegate Icon = null;
/// <summary> /// <summary>
/// Delegate to load an icon for the badge of this result. /// Delegate to load an icon for the badge of this result.
/// </summary> /// </summary>
[JsonIgnore]
public IconDelegate BadgeIcon = null; public IconDelegate BadgeIcon = null;
private GlyphInfo _glyph;
/// <summary> /// <summary>
/// Information for Glyph Icon (Prioritized than IcoPath/Icon if user enable Glyph Icons) /// Information for Glyph Icon (Prioritized than IcoPath/Icon if user enable Glyph Icons)
/// </summary> /// </summary>
public GlyphInfo Glyph { get; init; } public GlyphInfo Glyph
{
get => _glyph;
init => _glyph = value;
}
/// <summary>
/// Set the Glyph Icon after initialization
/// </summary>
/// <param name="glyph"></param>
public void SetGlyph(GlyphInfo glyph)
{
_glyph = glyph;
}
/// <summary> /// <summary>
/// An action to take in the form of a function call when the result has been selected. /// 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: /// 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. /// when true, the form will be hidden; when false, it will stay in focus.
/// </remarks> /// </remarks>
[JsonIgnore]
public Func<ActionContext, bool> Action { get; set; } public Func<ActionContext, bool> Action { get; set; }
/// <summary> /// <summary>
@ -161,6 +203,7 @@ namespace Flow.Launcher.Plugin
/// Its result determines what happens to Flow Launcher's query form: /// 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. /// when true, the form will be hidden; when false, it will stay in focus.
/// </remarks> /// </remarks>
[JsonIgnore]
public Func<ActionContext, ValueTask<bool>> AsyncAction { get; set; } public Func<ActionContext, ValueTask<bool>> AsyncAction { get; set; }
/// <summary> /// <summary>
@ -203,11 +246,13 @@ namespace Flow.Launcher.Plugin
/// <example> /// <example>
/// As external information for ContextMenu /// As external information for ContextMenu
/// </example> /// </example>
[JsonIgnore]
public object ContextData { get; set; } public object ContextData { get; set; }
/// <summary> /// <summary>
/// Plugin ID that generated this result /// Plugin ID that generated this result
/// </summary> /// </summary>
[JsonInclude]
public string PluginID { get; internal set; } public string PluginID { get; internal set; }
/// <summary> /// <summary>
@ -223,6 +268,7 @@ namespace Flow.Launcher.Plugin
/// <summary> /// <summary>
/// Customized Preview Panel /// Customized Preview Panel
/// </summary> /// </summary>
[JsonIgnore]
public Lazy<UserControl> PreviewPanel { get; set; } public Lazy<UserControl> PreviewPanel { get; set; }
/// <summary> /// <summary>
@ -352,6 +398,7 @@ namespace Flow.Launcher.Plugin
/// <summary> /// <summary>
/// Delegate to get the preview panel's image /// Delegate to get the preview panel's image
/// </summary> /// </summary>
[JsonIgnore]
public IconDelegate PreviewDelegate { get; set; } = null; public IconDelegate PreviewDelegate { get; set; } = null;
/// <summary> /// <summary>

View file

@ -259,6 +259,9 @@ namespace Flow.Launcher
await PluginManager.InitializePluginsAsync(_mainVM); 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 // 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 // And home page is created without full plugin list
if (_settings.ShowHomePage && _mainVM.QueryResultsSelected() && string.IsNullOrEmpty(_mainVM.QueryText)) if (_settings.ShowHomePage && _mainVM.QueryResultsSelected() && string.IsNullOrEmpty(_mainVM.QueryText))

View file

@ -11,7 +11,7 @@ namespace Flow.Launcher.Helper;
public static class ResultHelper public static class ResultHelper
{ {
public static async Task<Result?> PopulateResultsAsync(LastOpenedHistoryItem item) public static async Task<Result?> PopulateResultsAsync(LastOpenedHistoryResult item)
{ {
return await PopulateResultsAsync(item.PluginID, item.Query, item.Title, item.SubTitle, item.RecordKey); return await PopulateResultsAsync(item.PluginID, item.Query, item.Title, item.SubTitle, item.RecordKey);
} }
@ -24,7 +24,7 @@ public static class ResultHelper
if (query == null) return null; if (query == null) return null;
try try
{ {
var freshResults = await plugin.Plugin.QueryAsync(query, CancellationToken.None); 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 // Try to match by record key first if it is valid, otherwise fall back to title + subtitle match
if (string.IsNullOrEmpty(recordKey)) if (string.IsNullOrEmpty(recordKey))
{ {

View file

@ -333,7 +333,7 @@
Margin="18 24 0 0" Margin="18 24 0 0"
HorizontalAlignment="Left" HorizontalAlignment="Left"
RenderOptions.BitmapScalingMode="Fant" RenderOptions.BitmapScalingMode="Fant"
Source="{Binding IcoPath, IsAsync=True}" /> Source="{Binding IcoPathAbsolute, IsAsync=True}" />
<Border <Border
x:Name="LabelUpdate" x:Name="LabelUpdate"
Height="12" Height="12"

View file

@ -2,7 +2,7 @@
namespace Flow.Launcher.Storage namespace Flow.Launcher.Storage
{ {
[Obsolete("Use LastOpenedHistoryItem instead. This class will be removed in future versions.")] [Obsolete("Use LastOpenedHistoryResult instead. This class will be removed in future versions.")]
public class HistoryItem public class HistoryItem
{ {
public string Query { get; set; } public string Query { get; set; }

View file

@ -1,31 +0,0 @@
using System;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Storage;
public class LastOpenedHistoryItem
{
public string Title { get; set; } = string.Empty;
public string SubTitle { get; set; } = string.Empty;
public string PluginID { get; set; } = string.Empty;
public string Query { get; set; } = string.Empty;
public string RecordKey { get; set; } = string.Empty;
public DateTime ExecutedDateTime { get; set; }
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;
}
}
}

View file

@ -0,0 +1,146 @@
using System;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Storage;
/// <summary>
/// A serializable result used to record the last opened history for reopening results.
/// Inherits common result fields from <see cref="Result"/> and adds the original query and execution time.
/// </summary>
public class LastOpenedHistoryResult : Result
{
/// <summary>
/// The query string from Query.TrimmedQuery property, it is stored as a string instead of the entire Query class <see cref="Result"/>.
/// This is used so results can be reopened or re-run using the serialized query string.
/// </summary>
public string Query { get; set; } = string.Empty;
/// <summary>
/// The local date and time when this result was executed/opened.
/// </summary>
public DateTime ExecutedDateTime { get; set; }
/// <summary>
/// Initializes a new instance of <see cref="LastOpenedHistoryResult"/>.
/// </summary>
public LastOpenedHistoryResult()
{
}
/// <summary>
/// Creates a <see cref="LastOpenedHistoryResult"/> from an existing <see cref="Result"/>.
/// Copies required fields and sets up default reopening actions.
/// </summary>
/// <param name="result">The original result to create history from.</param>
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;
}
/// <summary>
/// Selectively creates a deep copy of the required properties for <see cref="LastOpenedHistoryResult"/>
/// based on the style of history- Last Opened or Query.
/// This copy should be independent of original and full isolated.
/// </summary>
/// <returns>A new <see cref="LastOpenedHistoryResult"/> containing the same required data.</returns>
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.
};
}
/// <summary>
/// Determines whether the specified <see cref="Result"/> is equivalent to this history result.
/// Comparison uses <see cref="Result.RecordKey"/> when available; otherwise falls back to title/subtitle/plugin id and query.
/// </summary>
/// <param name="r">The result to compare to.</param>
/// <returns><c>true</c> if the results are considered equal; otherwise <c>false</c>.</returns>
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;
}
}
}

View file

@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Plugin; using Flow.Launcher.Plugin;
namespace Flow.Launcher.Storage namespace Flow.Launcher.Storage
@ -14,28 +15,50 @@ namespace Flow.Launcher.Storage
#pragma warning restore CS0618 // Type or member is obsolete #pragma warning restore CS0618 // Type or member is obsolete
[JsonInclude] [JsonInclude]
public List<LastOpenedHistoryItem> LastOpenedHistoryItems { get; private set; } = []; public List<LastOpenedHistoryResult> LastOpenedHistoryItems { get; private set; } = [];
private readonly int _maxHistory = 300; private readonly int _maxHistory = 300;
/// <summary>
/// Migrate legacy history data (stored in <see cref="Items"/>) into the new
/// <see cref="LastOpenedHistoryResult"/> format and append them to
/// <see cref="LastOpenedHistoryItems"/>.
/// </summary>
[Obsolete("For backwards compatibility. Remove after release v2.3.0")]
public void PopulateHistoryFromLegacyHistory() public void PopulateHistoryFromLegacyHistory()
{ {
if (Items.Count == 0) return; if (Items.Count == 0) return;
// Migrate old history items to new LastOpenedHistoryItems // Migrate old history items to new LastOpenedHistoryItems
foreach (var item in Items) foreach (var item in Items)
{ {
LastOpenedHistoryItems.Add(new LastOpenedHistoryItem LastOpenedHistoryItems.Add(new LastOpenedHistoryResult
{ {
Title = Localize.executeQuery(item.Query),
OriginQuery = new Query { TrimmedQuery = item.Query },
Query = item.Query, Query = item.Query,
Action = _ =>
{
App.API.BackToQueryResults();
App.API.ChangeQuery(item.Query);
return false;
},
ExecutedDateTime = item.ExecutedDateTime ExecutedDateTime = item.ExecutedDateTime
}); });
} }
Items.Clear(); Items.Clear();
} }
/// <summary>
/// Records a result into the last-opened history list (<see cref="LastOpenedHistoryItems"/>).
/// This will also update the IcoPath if existing history item has one that is different.
/// </summary>
/// <param name="result">The result to add to history. Must have a non-empty <see cref="Result.OriginQuery"/>.<see cref="Query.TrimmedQuery"/>.</param>
public void Add(Result result) public void Add(Result result)
{ {
if (string.IsNullOrEmpty(result.OriginQuery.TrimmedQuery)) return; 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; if (string.IsNullOrEmpty(result.PluginID)) return;
// Maintain the max history limit // Maintain the max history limit
@ -44,23 +67,53 @@ namespace Flow.Launcher.Storage
LastOpenedHistoryItems.RemoveAt(0); LastOpenedHistoryItems.RemoveAt(0);
} }
// If the last item is the same as the current result, just update the timestamp // If the last item is the same as the current result, just update the timestamp and the icon path
if (LastOpenedHistoryItems.Count > 0 && if (LastOpenedHistoryItems.Count > 0 &&
LastOpenedHistoryItems.Last().Equals(result)) TryGetLastOpenedHistoryResult(result, out var existingHistoryItem))
{ {
LastOpenedHistoryItems.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 else
{ {
LastOpenedHistoryItems.Add(new LastOpenedHistoryItem LastOpenedHistoryItems.Add(new LastOpenedHistoryResult(result));
{ }
Title = result.Title, }
SubTitle = result.SubTitle,
PluginID = result.PluginID, /// <summary>
Query = result.OriginQuery.TrimmedQuery, /// Attempts to find an existing <see cref="LastOpenedHistoryResult"/> in <see cref="LastOpenedHistoryItems"/>
RecordKey = result.RecordKey, /// that is considered equal to the supplied <paramref name="result"/>.
ExecutedDateTime = DateTime.Now /// </summary>
}); private bool TryGetLastOpenedHistoryResult(Result result, out LastOpenedHistoryResult historyItem)
{
historyItem = LastOpenedHistoryItems.FirstOrDefault(x => x.Equals(result));
return historyItem is not null;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks> Call this after plugins are loaded/initialized.</remarks>
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;
} }
} }
} }

View file

@ -43,10 +43,10 @@ namespace Flow.Launcher.ViewModel
private string _ignoredQueryText; // Used to ignore query text change when switching between context menu and query results private string _ignoredQueryText; // Used to ignore query text change when switching between context menu and query results
private readonly FlowLauncherJsonStorage<History> _historyItemsStorage; private readonly FlowLauncherJsonStorage<History> _historyItemsStorage;
private readonly FlowLauncherJsonStorage<UserSelectedRecord> _userSelectedRecordStorage;
private readonly FlowLauncherJsonStorageTopMostRecord _topMostRecord;
private readonly History _history; private readonly History _history;
private int lastHistoryIndex = 1; private int lastHistoryIndex = 1;
private readonly FlowLauncherJsonStorage<UserSelectedRecord> _userSelectedRecordStorage;
private readonly FlowLauncherJsonStorageTopMostRecord _topMostRecord;
private readonly UserSelectedRecord _userSelectedRecord; private readonly UserSelectedRecord _userSelectedRecord;
private CancellationTokenSource _updateSource; // Used to cancel old query flows private CancellationTokenSource _updateSource; // Used to cancel old query flows
@ -152,11 +152,10 @@ namespace Flow.Launcher.ViewModel
}; };
_historyItemsStorage = new FlowLauncherJsonStorage<History>(); _historyItemsStorage = new FlowLauncherJsonStorage<History>();
_userSelectedRecordStorage = new FlowLauncherJsonStorage<UserSelectedRecord>();
_topMostRecord = new FlowLauncherJsonStorageTopMostRecord();
_history = _historyItemsStorage.Load(); _history = _historyItemsStorage.Load();
_history.PopulateHistoryFromLegacyHistory(); _userSelectedRecordStorage = new FlowLauncherJsonStorage<UserSelectedRecord>();
_userSelectedRecord = _userSelectedRecordStorage.Load(); _userSelectedRecord = _userSelectedRecordStorage.Load();
_topMostRecord = new FlowLauncherJsonStorageTopMostRecord();
ContextMenu = new ResultsViewModel(Settings, this) ContextMenu = new ResultsViewModel(Settings, this)
{ {
@ -355,11 +354,17 @@ namespace Flow.Launcher.ViewModel
if (QueryResultsSelected()) if (QueryResultsSelected())
{ {
SelectedResults = History; SelectedResults = History;
History.SelectedIndex = _history.LastOpenedHistoryItems.Count - 1; if (History.Results.Count > 0)
{
SelectedResults.SelectedIndex = 0;
SelectedResults.SelectedItem = History.Results[0];
}
} }
else else
{ {
SelectedResults = Results; SelectedResults = Results;
PreviewSelectedItem = Results.SelectedItem;
_ = UpdatePreviewAsync();
} }
} }
@ -431,7 +436,8 @@ namespace Flow.Launcher.ViewModel
{ {
// When switch to ContextMenu from QueryResults, but no item being chosen, should do nothing // 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 // 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; SelectedResults = ContextMenu;
} }
@ -439,6 +445,8 @@ namespace Flow.Launcher.ViewModel
else else
{ {
SelectedResults = Results; SelectedResults = Results;
PreviewSelectedItem = Results.SelectedItem;
_ = UpdatePreviewAsync();
} }
} }
@ -642,6 +650,8 @@ namespace Flow.Launcher.ViewModel
if (!QueryResultsSelected()) if (!QueryResultsSelected())
{ {
SelectedResults = Results; SelectedResults = Results;
PreviewSelectedItem = Results.SelectedItem;
_ = UpdatePreviewAsync();
} }
else else
{ {
@ -1252,22 +1262,12 @@ namespace Flow.Launcher.ViewModel
var selected = Results.SelectedItem?.Result; 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<Result> results; List<Result> results = PluginManager.GetContextMenusForPlugin(selected);
if (selected.PluginID == null) // SelectedItem from history in home page. results.Add(ContextMenuTopMost(selected));
{ results.Add(ContextMenuPluginInfo(selected));
results = new()
{
ContextMenuTopMost(selected)
};
}
else
{
results = PluginManager.GetContextMenusForPlugin(selected);
results.Add(ContextMenuTopMost(selected));
results.Add(ContextMenuPluginInfo(selected));
}
if (!string.IsNullOrEmpty(query)) if (!string.IsNullOrEmpty(query))
{ {
@ -1318,68 +1318,77 @@ namespace Flow.Launcher.ViewModel
} }
} }
private List<Result> GetHistoryItems(IEnumerable<LastOpenedHistoryItem> historyItems) private List<Result> GetHistoryItems(IEnumerable<LastOpenedHistoryResult> historyItems, int? maxResult = null)
{ {
var results = new List<Result>(); var results = new List<Result>();
if (Settings.HistoryStyle == HistoryStyle.Query)
{
foreach (var h in historyItems)
{
var result = new Result
{
Title = Localize.executeQuery(h.Query),
SubTitle = Localize.lastExecuteTime(h.ExecutedDateTime),
IcoPath = Constant.HistoryIcon,
OriginQuery = new Query { TrimmedQuery = 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);
}
}
else
{
foreach (var h in historyItems)
{
var result = new Result
{
Title = string.IsNullOrEmpty(h.Title) ? // Old migrated history items have no title
Localize.executeQuery(h.Query) :
h.Title,
SubTitle = Localize.lastExecuteTime(h.ExecutedDateTime),
IcoPath = Constant.HistoryIcon,
OriginQuery = new Query { TrimmedQuery = h.Query },
AsyncAction = async c =>
{
var reflectResult = await ResultHelper.PopulateResultsAsync(h);
if (reflectResult != null)
{
// Record the user selected record for result ranking
_userSelectedRecord.Add(reflectResult);
// Since some actions may need to hide the Flow window to execute // Order by executed time descending: Latest -> Oldest
// So let us populate the results of them historyItems = historyItems.OrderByDescending(x => x.ExecutedDateTime);
return await reflectResult.ExecuteAsync(c);
}
// If we cannot get the result, fallback to re-query if (Settings.HistoryStyle == HistoryStyle.LastOpened)
App.API.BackToQueryResults(); {
App.API.ChangeQuery(h.Query); // Items saved to disk are differentiated by Query also, but LastOpened style only cares about unique results
return false; historyItems = historyItems
}, .GroupBy(r => new { r.Title, r.SubTitle, r.PluginID, r.RecordKey })
Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE81C") .Select(g => g.First());
};
results.Add(result);
}
} }
// 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; return results;
} }
/// <summary>
/// Refreshes the last-opened history storage by migrating legacy entries and
/// updating stored icon paths to their resolved (absolute) locations.
/// </summary>
/// <remarks>
/// Calls <see cref="History.UpdateIcoPathAbsolute"/> to refresh absolute icon
/// paths on the migrated/saved history entries by updating each item's
/// <c>PluginDirectory</c> (which in turn resolves <c>IcoPathAbsolute</c>).
///
/// Important:
/// - Plugins must be initialized (their metadata and <c>PluginDirectory</c> set)
/// before calling this method; otherwise icon resolution cannot be performed.
/// </remarks>
internal void RefreshLastOpenedHistoryResults()
{
_history.PopulateHistoryFromLegacyHistory();
_history.UpdateIcoPathAbsolute();
}
private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, bool reSelect = true) private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, bool reSelect = true)
{ {
_updateSource?.Cancel(); _updateSource?.Cancel();
@ -1617,10 +1626,8 @@ namespace Flow.Launcher.ViewModel
void QueryHistoryTask(CancellationToken token) void QueryHistoryTask(CancellationToken token)
{ {
// Select last history results and revert its order to make sure last history results are on top // Select last history results
var historyItems = _history.LastOpenedHistoryItems.TakeLast(Settings.MaxHistoryResultsToShowForHomePage).Reverse(); var results = GetHistoryItems(_history.LastOpenedHistoryItems, Settings.MaxHistoryResultsToShowForHomePage);
var results = GetHistoryItems(historyItems);
if (token.IsCancellationRequested) return; if (token.IsCancellationRequested) return;

View file

@ -141,7 +141,7 @@ namespace Flow.Launcher.ViewModel
private bool GlyphAvailable => Glyph is not null; 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; private bool BadgeIconAvailable => !string.IsNullOrEmpty(Result.BadgeIcoPath) || Result.BadgeIcon is not null;
@ -236,7 +236,7 @@ namespace Flow.Launcher.ViewModel
private async Task LoadImageAsync() private async Task LoadImageAsync()
{ {
var imagePath = Result.IcoPath; var imagePath = Result.IcoPathAbsolute;
var iconDelegate = Result.Icon; var iconDelegate = Result.Icon;
if (ImageLoader.TryGetValue(imagePath, false, out var img)) if (ImageLoader.TryGetValue(imagePath, false, out var img))
{ {
@ -266,7 +266,7 @@ namespace Flow.Launcher.ViewModel
private async Task LoadPreviewImageAsync() 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; var iconDelegate = Result.Preview.PreviewDelegate ?? Result.Icon;
if (ImageLoader.TryGetValue(imagePath, true, out var img)) if (ImageLoader.TryGetValue(imagePath, true, out var img))
{ {