merge back v2.1.1 from master to dev

This commit is contained in:
Jeremy 2026-03-09 14:49:16 +11:00
commit 79f3bfab96
21 changed files with 699 additions and 218 deletions

View file

@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<!-- Work around https://github.com/dotnet/runtime/issues/109682 -->
<!-- Workaround https://github.com/dotnet/runtime/issues/109682 -->
<CETCompat>false</CETCompat>
</PropertyGroup>
</Project>

View file

@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Flow.Launcher.Plugin;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Core.Plugin;
namespace Flow.Launcher.Core.ExternalPlugins
{
@ -41,11 +41,10 @@ namespace Flow.Launcher.Core.ExternalPlugins
return false;
var updatedPluginResults = new List<UserPlugin>();
var appVersion = SemanticVersioning.Version.Parse(Constant.Version);
for (int i = 0; i < results.Count; i++)
{
if (IsMinimumAppVersionSatisfied(results[i], appVersion))
if (PluginManager.IsMinimumAppVersionSatisfied(results[i].Name, results[i].MinimumAppVersion))
updatedPluginResults.Add(results[i]);
}
@ -72,28 +71,5 @@ namespace Flow.Launcher.Core.ExternalPlugins
return false;
}
private static bool IsMinimumAppVersionSatisfied(UserPlugin plugin, SemanticVersioning.Version appVersion)
{
if (string.IsNullOrEmpty(plugin.MinimumAppVersion))
return true;
try
{
if (appVersion >= SemanticVersioning.Version.Parse(plugin.MinimumAppVersion))
return true;
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to parse the minimum app version {plugin.MinimumAppVersion} for plugin {plugin.Name}. "
+ "Plugin excluded from manifest", e);
return false;
}
PublicApi.Instance.LogInfo(ClassName, $"Plugin {plugin.Name} requires minimum Flow Launcher version {plugin.MinimumAppVersion}, "
+ $"but current version is {Constant.Version}. Plugin excluded from manifest.");
return false;
}
}
}

View file

@ -6,6 +6,7 @@ using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using Flow.Launcher.Core.ExternalPlugins;
using Flow.Launcher.Core.Resource;
using Flow.Launcher.Infrastructure;
@ -813,15 +814,13 @@ namespace Flow.Launcher.Core.Plugin
return string.Empty;
}
private static bool SameOrLesserPluginVersionExists(string metadataPath)
private static bool SameOrLesserPluginVersionExists(PluginMetadata metadata)
{
var newMetadata = JsonSerializer.Deserialize<PluginMetadata>(File.ReadAllText(metadataPath));
if (!Version.TryParse(newMetadata.Version, out var newVersion))
if (!Version.TryParse(metadata.Version, out var newVersion))
return true; // If version is not valid, we assume it is lesser than any existing version
// Get all plugins even if initialization failed so that we can check if the plugin with the same ID exists
return GetAllInitializedPlugins(includeFailed: true).Any(x => x.Metadata.ID == newMetadata.ID
return GetAllInitializedPlugins(includeFailed: true).Any(x => x.Metadata.ID == metadata.ID
&& Version.TryParse(x.Metadata.Version, out var version)
&& newVersion <= version);
}
@ -881,84 +880,116 @@ namespace Flow.Launcher.Core.Plugin
var tempFolderPluginPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, tempFolderPluginPath);
if(!plugin.IsFromLocalInstallPath)
File.Delete(zipFilePath);
var pluginFolderPath = GetContainingFolderPathAfterUnzip(tempFolderPluginPath);
var metadataJsonFilePath = string.Empty;
if (File.Exists(Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName)))
metadataJsonFilePath = Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName);
if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath))
try
{
PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name),
Localize.fileNotFoundMessage(pluginFolderPath));
return false;
}
if (!plugin.IsFromLocalInstallPath)
File.Delete(zipFilePath);
if (SameOrLesserPluginVersionExists(metadataJsonFilePath))
{
PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name),
Localize.pluginExistAlreadyMessage());
return false;
}
var pluginFolderPath = GetContainingFolderPathAfterUnzip(tempFolderPluginPath);
var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}";
var metadataJsonFilePath = string.Empty;
if (File.Exists(Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName)))
metadataJsonFilePath = Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName);
var defaultPluginIDs = new List<string>
if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath))
{
"0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark
"CEA0FDFC6D3B4085823D60DC76F28855", // Calculator
"572be03c74c642baae319fc283e561a8", // Explorer
"6A122269676E40EB86EB543B945932B9", // PluginIndicator
"9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager
"b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller
"791FC278BA414111B8D1886DFE447410", // Program
"D409510CD0D2481F853690A07E6DC426", // Shell
"CEA08895D2544B019B2E9C5009600DF4", // Sys
"0308FD86DE0A4DEE8D62B9B535370992", // URL
"565B73353DBF4806919830B9202EE3BF", // WebSearch
"5043CETYU6A748679OPA02D27D99677A" // WindowsSettings
};
PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name),
Localize.fileNotFoundMessage(pluginFolderPath));
return false;
}
// Treat default plugin differently, it needs to be removable along with each flow release
var installDirectory = !defaultPluginIDs.Any(x => x == plugin.ID)
? DataLocation.PluginsDirectory
: Constant.PreinstalledDirectory;
PluginMetadata newMetadata;
try
{
newMetadata = JsonSerializer.Deserialize<PluginMetadata>(File.ReadAllText(metadataJsonFilePath)) ??
throw new JsonException("Deserialized metadata is null");
}
catch (Exception ex)
{
PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name),
Localize.pluginJsonInvalidOrCorrupted());
PublicApi.Instance.LogException(ClassName,
$"Failed to deserialize plugin metadata for plugin {plugin.Name} from file {metadataJsonFilePath}", ex);
return false;
}
var newPluginPath = Path.Combine(installDirectory, folderName);
if (SameOrLesserPluginVersionExists(newMetadata))
{
PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name),
Localize.pluginExistAlreadyMessage());
return false;
}
FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => PublicApi.Instance.ShowMsgBox(s));
if (!IsMinimumAppVersionSatisfied(newMetadata.Name, newMetadata.MinimumAppVersion))
{
// Ask users if they want to install the plugin that doesn't satisfy the minimum app version requirement
if (PublicApi.Instance.ShowMsgBox(
Localize.pluginMinimumAppVersionUnsatisfiedMessage(newMetadata.Name, Environment.NewLine),
Localize.pluginMinimumAppVersionUnsatisfiedTitle(newMetadata.Name, newMetadata.MinimumAppVersion),
MessageBoxButton.YesNo) == MessageBoxResult.No)
{
return false;
}
}
// Check if marker file exists and delete it
try
{
var markerFilePath = Path.Combine(newPluginPath, DataLocation.PluginDeleteFile);
if (File.Exists(markerFilePath))
File.Delete(markerFilePath);
var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}";
var defaultPluginIDs = new List<string>
{
"0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark
"CEA0FDFC6D3B4085823D60DC76F28855", // Calculator
"572be03c74c642baae319fc283e561a8", // Explorer
"6A122269676E40EB86EB543B945932B9", // PluginIndicator
"9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager
"b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller
"791FC278BA414111B8D1886DFE447410", // Program
"D409510CD0D2481F853690A07E6DC426", // Shell
"CEA08895D2544B019B2E9C5009600DF4", // Sys
"0308FD86DE0A4DEE8D62B9B535370992", // URL
"565B73353DBF4806919830B9202EE3BF", // WebSearch
"5043CETYU6A748679OPA02D27D99677A" // WindowsSettings
};
// Treat default plugin differently, it needs to be removable along with each flow release
var installDirectory = !defaultPluginIDs.Any(x => x == plugin.ID)
? DataLocation.PluginsDirectory
: Constant.PreinstalledDirectory;
var newPluginPath = Path.Combine(installDirectory, folderName);
FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => PublicApi.Instance.ShowMsgBox(s));
// Check if marker file exists and delete it
try
{
var markerFilePath = Path.Combine(newPluginPath, DataLocation.PluginDeleteFile);
if (File.Exists(markerFilePath))
File.Delete(markerFilePath);
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to delete plugin marker file in {newPluginPath}", e);
}
if (checkModified)
{
ModifiedPlugins.Add(plugin.ID);
}
return true;
}
catch (Exception e)
finally
{
PublicApi.Instance.LogException(ClassName, $"Failed to delete plugin marker file in {newPluginPath}", e);
try
{
if (Directory.Exists(tempFolderPluginPath))
Directory.Delete(tempFolderPluginPath, true);
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to delete temp folder {tempFolderPluginPath}", e);
}
}
try
{
if (Directory.Exists(tempFolderPluginPath))
Directory.Delete(tempFolderPluginPath, true);
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to delete temp folder {tempFolderPluginPath}", e);
}
if (checkModified)
{
ModifiedPlugins.Add(plugin.ID);
}
return true;
}
internal static async Task<bool> UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified)
@ -1050,6 +1081,27 @@ namespace Flow.Launcher.Core.Plugin
return true;
}
internal static bool IsMinimumAppVersionSatisfied(string pluginName, string minimumAppVersion)
{
// If the minimum app version is not specified in plugin.json, this plugin is compatible with all app versions
if (string.IsNullOrEmpty(minimumAppVersion))
return true;
var appVersion = Version.Parse(Constant.Version);
if (!Version.TryParse(minimumAppVersion, out var minimumVersion))
{
PublicApi.Instance.LogError(ClassName,
$"Failed to parse the minimum app version {minimumAppVersion} for plugin {pluginName}.");
return false; // If the minimum app version specified in plugin.json is invalid, we assume it is not satisfied
}
if (appVersion >= minimumVersion)
return true;
return false;
}
#endregion
#endregion

View file

@ -673,13 +673,13 @@ namespace Flow.Launcher.Core.Resource
// If the BackdropType is Mica or MicaAlt, set the windowborderstyle's background to transparent
if (backdropType == BackdropTypes.Mica || backdropType == BackdropTypes.MicaAlt)
{
windowBorderStyle.Setters.Remove(windowBorderStyle.Setters.OfType<Setter>().FirstOrDefault(x => x.Property.Name == "Background"));
windowBorderStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(Color.FromArgb(1, 0, 0, 0))));
windowBorderStyle.Setters.Remove(windowBorderStyle.Setters.OfType<Setter>().FirstOrDefault(x => x.Property == Control.BackgroundProperty));
windowBorderStyle.Setters.Add(new Setter(Border.BackgroundProperty, ThemeHelper.GetFrozenSolidColorBrush(Color.FromArgb(1, 0, 0, 0))));
}
else if (backdropType == BackdropTypes.Acrylic)
{
windowBorderStyle.Setters.Remove(windowBorderStyle.Setters.OfType<Setter>().FirstOrDefault(x => x.Property.Name == "Background"));
windowBorderStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(Colors.Transparent)));
windowBorderStyle.Setters.Remove(windowBorderStyle.Setters.OfType<Setter>().FirstOrDefault(x => x.Property == Control.BackgroundProperty));
windowBorderStyle.Setters.Add(new Setter(Border.BackgroundProperty, Brushes.Transparent));
}
// For themes with blur enabled, the window border is rendered by the system, so it's treated as a simple rectangle regardless of thickness.
@ -798,12 +798,12 @@ namespace Flow.Launcher.Core.Resource
Application.Current.Resources["WindowBorderStyle"] is Style originalStyle)
{
// Copy the original style, including the base style if it exists
CopyStyle(originalStyle, previewStyle);
ThemeHelper.CopyStyle(originalStyle, previewStyle);
}
// Apply background color (remove transparency in color)
Color backgroundColor = Color.FromRgb(bgColor.Value.R, bgColor.Value.G, bgColor.Value.B);
previewStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(backgroundColor)));
var backgroundColor = Color.FromRgb(bgColor.Value.R, bgColor.Value.G, bgColor.Value.B);
previewStyle.Setters.Add(new Setter(Border.BackgroundProperty, ThemeHelper.GetFrozenSolidColorBrush(backgroundColor)));
// The blur theme keeps the corner round fixed (applying DWM code to modify it causes rendering issues).
// The non-blur theme retains the previously set WindowBorderStyle.
@ -817,21 +817,6 @@ namespace Flow.Launcher.Core.Resource
Application.Current.Resources["PreviewWindowBorderStyle"] = previewStyle;
}
private void CopyStyle(Style originalStyle, Style targetStyle)
{
// If the style is based on another style, copy the base style first
if (originalStyle.BasedOn != null)
{
CopyStyle(originalStyle.BasedOn, targetStyle);
}
// Copy the setters from the original style
foreach (var setter in originalStyle.Setters.OfType<Setter>())
{
targetStyle.Setters.Add(new Setter(setter.Property, setter.Value));
}
}
private void ColorizeWindow(string theme, BackdropTypes backdropType)
{
var dict = GetThemeResourceDictionary(theme);
@ -917,11 +902,11 @@ namespace Flow.Launcher.Core.Resource
// Only set the background to transparent if the theme supports blur
if (backdropType == BackdropTypes.Mica || backdropType == BackdropTypes.MicaAlt)
{
mainWindow.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0));
mainWindow.Background = ThemeHelper.GetFrozenSolidColorBrush(Color.FromArgb(1, 0, 0, 0));
}
else
{
mainWindow.Background = new SolidColorBrush(selectedBG);
mainWindow.Background = ThemeHelper.GetFrozenSolidColorBrush(selectedBG);
}
}
}

View file

@ -0,0 +1,30 @@
using System.Linq;
using System.Windows;
using System.Windows.Media;
namespace Flow.Launcher.Core.Resource;
public static class ThemeHelper
{
public static void CopyStyle(Style originalStyle, Style targetStyle)
{
// If the style is based on another style, copy the base style first
if (originalStyle.BasedOn != null)
{
CopyStyle(originalStyle.BasedOn, targetStyle);
}
// Copy the setters from the original style
foreach (var setter in originalStyle.Setters.OfType<Setter>())
{
targetStyle.Setters.Add(new Setter(setter.Property, setter.Value));
}
}
public static SolidColorBrush GetFrozenSolidColorBrush(Color color)
{
var brush = new SolidColorBrush(color);
brush.Freeze();
return brush;
}
}

View file

@ -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
}
}

View file

@ -137,6 +137,11 @@ namespace Flow.Launcher.Plugin
[JsonIgnore]
public int QueryCount { get; set; }
/// <summary>
/// The minimum Flow Launcher version required for this plugin. Default is "".
/// </summary>
public string MinimumAppVersion { get; set; } = string.Empty;
/// <summary>
/// The path to the plugin settings directory which is not validated.
/// It is used to store plugin settings files and data files.

View file

@ -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
{
/// <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>
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; }
/// <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>
/// <value>Can be a local file path or a URL.</value>
/// <remarks>GlyphInfo is prioritized if not null</remarks>
/// <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
{
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;
}
}
}
/// <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>
/// The image to be displayed for the badge of the result.
/// </summary>
@ -131,17 +155,34 @@ namespace Flow.Launcher.Plugin
/// <summary>
/// Delegate to load an icon for this result.
/// </summary>
[JsonIgnore]
public IconDelegate Icon = null;
/// <summary>
/// Delegate to load an icon for the badge of this result.
/// </summary>
[JsonIgnore]
public IconDelegate BadgeIcon = null;
private GlyphInfo _glyph;
/// <summary>
/// Information for Glyph Icon (Prioritized than IcoPath/Icon if user enable Glyph Icons)
/// </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>
/// 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.
/// </remarks>
[JsonIgnore]
public Func<ActionContext, bool> Action { get; set; }
/// <summary>
@ -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.
/// </remarks>
[JsonIgnore]
public Func<ActionContext, ValueTask<bool>> AsyncAction { get; set; }
/// <summary>
@ -203,11 +246,13 @@ namespace Flow.Launcher.Plugin
/// <example>
/// As external information for ContextMenu
/// </example>
[JsonIgnore]
public object ContextData { get; set; }
/// <summary>
/// Plugin ID that generated this result
/// </summary>
[JsonInclude]
public string PluginID { get; internal set; }
/// <summary>
@ -223,6 +268,7 @@ namespace Flow.Launcher.Plugin
/// <summary>
/// Customized Preview Panel
/// </summary>
[JsonIgnore]
public Lazy<UserControl> PreviewPanel { get; set; }
/// <summary>
@ -352,6 +398,7 @@ namespace Flow.Launcher.Plugin
/// <summary>
/// Delegate to get the preview panel's image
/// </summary>
[JsonIgnore]
public IconDelegate PreviewDelegate { get; set; } = null;
/// <summary>

View file

@ -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))

View file

@ -185,7 +185,7 @@
</Target>
<Target Name="RemoveDuplicateAnalyzers" BeforeTargets="CoreCompile">
<!-- Work around https://github.com/dotnet/wpf/issues/6792 -->
<!-- Workaround https://github.com/dotnet/wpf/issues/6792 -->
<ItemGroup>
<FilteredAnalyzer Include="@(Analyzer-&gt;Distinct())" />
<Analyzer Remove="@(Analyzer)" />

View file

@ -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<Result?> PopulateResultsAsync(LastOpenedHistoryResult item)
{
return await PopulateResultsAsync(item.PluginID, item.Query, item.Title, item.SubTitle, item.RecordKey);
}
public static async Task<Result?> 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;
}
}
}

View file

@ -172,6 +172,10 @@
<system:String x:Key="homePageToolTip">Show home page results when query text is empty.</system:String>
<system:String x:Key="historyResultsForHomePage">Show History Results in Home Page</system:String>
<system:String x:Key="historyResultsCountForHomePage">Maximum History Results Shown in Home Page</system:String>
<system:String x:Key="historyStyle">History Style</system:String>
<system:String x:Key="historyStyleTooltip">Choose the type of history to show in the History and Home Page</system:String>
<system:String x:Key="queryHistory">Query history</system:String>
<system:String x:Key="executedHistory">Last opened history</system:String>
<system:String x:Key="homeToggleBoxToolTip">This can only be edited if plugin supports Home feature and Home Page is enabled.</system:String>
<system:String x:Key="showAtTopmost">Show Search Window at Foremost</system:String>
<system:String x:Key="showAtTopmostToolTip">Overrides other programs' 'Always on Top' setting and displays Flow in the foremost position.</system:String>
@ -228,6 +232,9 @@
<system:String x:Key="fileNotFoundMessage">Unable to find plugin.json from the extracted zip file, or this path {0} does not exist</system:String>
<system:String x:Key="pluginExistAlreadyMessage">A plugin with the same ID and version already exists, or the version is greater than this downloaded plugin</system:String>
<system:String x:Key="errorCreatingSettingPanel">Error creating setting panel for plugin {0}:{1}{2}</system:String>
<system:String x:Key="pluginMinimumAppVersionUnsatisfiedTitle">{0} requires Flow version {1} to run</system:String>
<system:String x:Key="pluginMinimumAppVersionUnsatisfiedMessage">Flow does not meet the minimum version requirements for {0} to run. Do you want to continue installing it?{1}{1}We recommend updating Flow to the latest version to ensure that {0} works without issues.</system:String>
<system:String x:Key="pluginJsonInvalidOrCorrupted">Failed to install plugin because plugin.json is invalid or corrupted</system:String>
<!-- Setting Plugin Store -->
<system:String x:Key="pluginStore">Plugin Store</system:String>

View file

@ -319,6 +319,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();
@ -861,7 +862,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();
@ -885,7 +886,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;

View file

@ -147,6 +147,8 @@ public partial class SettingsPaneGeneralViewModel : BaseModel
public List<LastQueryModeData> LastQueryModes { get; } =
DropdownDataGeneric<LastQueryMode>.GetValues<LastQueryModeData>("LastQuery");
public List<HistoryStyleLocalized> HistoryStyles { get; } = HistoryStyleLocalized.GetValues();
public bool EnableDialogJump
{
get => Settings.EnableDialogJump;
@ -213,6 +215,7 @@ public partial class SettingsPaneGeneralViewModel : BaseModel
DropdownDataGeneric<SearchWindowAligns>.UpdateLabels(SearchWindowAligns);
DropdownDataGeneric<SearchPrecisionScore>.UpdateLabels(SearchPrecisionScores);
DropdownDataGeneric<LastQueryMode>.UpdateLabels(LastQueryModes);
HistoryStyleLocalized.UpdateLabels(HistoryStyles);
DropdownDataGeneric<DoublePinyinSchemas>.UpdateLabels(DoublePinyinSchemas);
DropdownDataGeneric<DialogJumpWindowPositions>.UpdateLabels(DialogJumpWindowPositions);
DropdownDataGeneric<DialogJumpResultBehaviours>.UpdateLabels(DialogJumpResultBehaviours);

View file

@ -255,6 +255,22 @@
SelectedValuePath="Value" />
</ui:SettingsCard>
<ui:SettingsCard
Margin="0 14 0 0"
Description="{DynamicResource historyStyleTooltip}"
Header="{DynamicResource historyStyle}">
<ui:SettingsCard.HeaderIcon>
<ui:FontIcon Glyph="&#xE81C;" />
</ui:SettingsCard.HeaderIcon>
<ComboBox
MaxWidth="200"
DisplayMemberPath="Display"
ItemsSource="{Binding HistoryStyles}"
SelectedValue="{Binding Settings.HistoryStyle}"
SelectedValuePath="Value" />
</ui:SettingsCard>
<ui:SettingsCard
Margin="0 14 0 0"
Description="{DynamicResource autoRestartAfterChangingToolTip}"

View file

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

View file

@ -1,7 +1,8 @@
using System;
using System;
namespace Flow.Launcher.Storage
{
[Obsolete("Use LastOpenedHistoryResult instead. This class will be removed in future versions.")]
public class HistoryItem
{
public string Query { get; set; }
@ -42,4 +43,4 @@ namespace Flow.Launcher.Storage
return string.Empty;
}
}
}
}

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,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<HistoryItem> Items { get; private set; } = new List<HistoryItem>();
#pragma warning disable CS0618 // Type or member is obsolete
public List<HistoryItem> Items { get; private set; } = [];
#pragma warning restore CS0618 // Type or member is obsolete
private int _maxHistory = 300;
[JsonInclude]
public List<LastOpenedHistoryResult> LastOpenedHistoryItems { get; private set; } = [];
public void Add(string query)
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()
{
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();
}
/// <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)
{
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));
}
}
/// <summary>
/// Attempts to find an existing <see cref="LastOpenedHistoryResult"/> in <see cref="LastOpenedHistoryItems"/>
/// that is considered equal to the supplied <paramref name="result"/>.
/// </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

@ -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<History> _historyItemsStorage;
private readonly FlowLauncherJsonStorage<UserSelectedRecord> _userSelectedRecordStorage;
private readonly FlowLauncherJsonStorageTopMostRecord _topMostRecord;
private readonly History _history;
private int lastHistoryIndex = 1;
private readonly FlowLauncherJsonStorage<UserSelectedRecord> _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<History>();
_userSelectedRecordStorage = new FlowLauncherJsonStorage<UserSelectedRecord>();
_topMostRecord = new FlowLauncherJsonStorageTopMostRecord();
_history = _historyItemsStorage.Load();
_userSelectedRecordStorage = new FlowLauncherJsonStorage<UserSelectedRecord>();
_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<Result> 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<Result> 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<Result> GetHistoryItems(IEnumerable<HistoryItem> historyItems)
private List<Result> GetHistoryItems(IEnumerable<LastOpenedHistoryResult> historyItems, int? maxResult = null)
{
var results = new List<Result>();
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;
}
/// <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)
{
_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;

View file

@ -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))
{