diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 72303c8b7..a3e80a73f 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -25,6 +25,7 @@ namespace Flow.Launcher.Core.Plugin private static readonly string ClassName = nameof(PluginManager); private static IEnumerable _contextMenuPlugins; + private static IEnumerable _homePlugins; public static List AllPlugins { get; private set; } public static readonly HashSet GlobalPlugins = new(); @@ -220,6 +221,7 @@ namespace Flow.Launcher.Core.Plugin { API.LogException(ClassName, $"Fail to Init plugin: {pair.Metadata.Name}", e); pair.Metadata.Disabled = true; + pair.Metadata.HomeDisabled = true; failedPlugins.Enqueue(pair); } })); @@ -227,6 +229,8 @@ namespace Flow.Launcher.Core.Plugin await Task.WhenAll(InitTasks); _contextMenuPlugins = GetPluginsForInterface(); + _homePlugins = GetPluginsForInterface(); + foreach (var plugin in AllPlugins) { // set distinct on each plugin's action keywords helps only firing global(*) and action keywords once where a plugin @@ -274,6 +278,11 @@ namespace Flow.Launcher.Core.Plugin }; } + public static ICollection ValidPluginsForHomeQuery() + { + return _homePlugins.ToList(); + } + public static async Task> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token) { var results = new List(); @@ -318,6 +327,36 @@ namespace Flow.Launcher.Core.Plugin return results; } + public static async Task> QueryHomeForPluginAsync(PluginPair pair, Query query, CancellationToken token) + { + var results = new List(); + var metadata = pair.Metadata; + + try + { + var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", + async () => results = await ((IAsyncHomeQuery)pair.Plugin).HomeQueryAsync(token).ConfigureAwait(false)); + + token.ThrowIfCancellationRequested(); + if (results == null) + return null; + UpdatePluginMetadata(results, metadata, query); + + token.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) + { + // null will be fine since the results will only be added into queue if the token hasn't been cancelled + return null; + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to query home for plugin: {metadata.Name}", e); + return null; + } + return results; + } + public static void UpdatePluginMetadata(IReadOnlyList results, PluginMetadata metadata, Query query) { foreach (var r in results) @@ -378,6 +417,11 @@ namespace Flow.Launcher.Core.Plugin return results; } + public static bool IsHomePlugin(string id) + { + return _homePlugins.Any(p => p.Metadata.ID == id); + } + public static bool ActionKeywordRegistered(string actionKeyword) { // this method is only checking for action keywords (defined as not '*') registration diff --git a/Flow.Launcher.Core/Plugin/QueryBuilder.cs b/Flow.Launcher.Core/Plugin/QueryBuilder.cs index 3dc7877ac..fae821736 100644 --- a/Flow.Launcher.Core/Plugin/QueryBuilder.cs +++ b/Flow.Launcher.Core/Plugin/QueryBuilder.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Flow.Launcher.Plugin; @@ -8,10 +8,23 @@ namespace Flow.Launcher.Core.Plugin { public static Query Build(string text, Dictionary nonGlobalPlugins) { + // home query + if (string.IsNullOrEmpty(text)) + { + return new Query() + { + Search = string.Empty, + RawQuery = string.Empty, + SearchTerms = Array.Empty(), + ActionKeyword = string.Empty + }; + } + // replace multiple white spaces with one white space var terms = text.Split(Query.TermSeparator, StringSplitOptions.RemoveEmptyEntries); if (terms.Length == 0) - { // nothing was typed + { + // nothing was typed return null; } @@ -21,19 +34,21 @@ namespace Flow.Launcher.Core.Plugin string[] searchTerms; if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPair) && !pluginPair.Metadata.Disabled) - { // use non global plugin for query + { + // use non global plugin for query actionKeyword = possibleActionKeyword; search = terms.Length > 1 ? rawQuery[(actionKeyword.Length + 1)..].TrimStart() : string.Empty; searchTerms = terms[1..]; } else - { // non action keyword + { + // non action keyword actionKeyword = string.Empty; search = rawQuery.TrimStart(); searchTerms = terms; } - return new Query () + return new Query() { Search = search, RawQuery = rawQuery, @@ -42,4 +57,4 @@ namespace Flow.Launcher.Core.Plugin }; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Core/Resource/Theme.cs b/Flow.Launcher.Core/Resource/Theme.cs index a46932c6a..059359694 100644 --- a/Flow.Launcher.Core/Resource/Theme.cs +++ b/Flow.Launcher.Core/Resource/Theme.cs @@ -76,7 +76,7 @@ namespace Flow.Launcher.Core.Resource { _api.LogError(ClassName, "Current theme resource not found. Initializing with default theme."); _oldTheme = Constant.DefaultTheme; - }; + } } #endregion @@ -126,7 +126,7 @@ namespace Flow.Launcher.Core.Resource // Load a ResourceDictionary for the specified theme. var themeName = _settings.Theme; var dict = GetThemeResourceDictionary(themeName); - + // Apply font settings to the theme resource. ApplyFontSettings(dict); UpdateResourceDictionary(dict); @@ -152,11 +152,11 @@ namespace Flow.Launcher.Core.Resource var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.QueryBoxFontStyle); var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.QueryBoxFontWeight); var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.QueryBoxFontStretch); - + SetFontProperties(queryBoxStyle, fontFamily, fontStyle, fontWeight, fontStretch, true); SetFontProperties(querySuggestionBoxStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); } - + if (dict["ItemTitleStyle"] is Style resultItemStyle && dict["ItemTitleSelectedStyle"] is Style resultItemSelectedStyle && dict["ItemHotkeyStyle"] is Style resultHotkeyItemStyle && @@ -172,7 +172,7 @@ namespace Flow.Launcher.Core.Resource SetFontProperties(resultHotkeyItemStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); SetFontProperties(resultHotkeyItemSelectedStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); } - + if (dict["ItemSubTitleStyle"] is Style resultSubItemStyle && dict["ItemSubTitleSelectedStyle"] is Style resultSubItemSelectedStyle) { @@ -197,7 +197,7 @@ namespace Flow.Launcher.Core.Resource // First, find the setters to remove and store them in a list var settersToRemove = style.Setters .OfType() - .Where(setter => + .Where(setter => setter.Property == Control.FontFamilyProperty || setter.Property == Control.FontStyleProperty || setter.Property == Control.FontWeightProperty || @@ -227,18 +227,18 @@ namespace Flow.Launcher.Core.Resource { var settersToRemove = style.Setters .OfType() - .Where(setter => + .Where(setter => setter.Property == TextBlock.FontFamilyProperty || setter.Property == TextBlock.FontStyleProperty || setter.Property == TextBlock.FontWeightProperty || setter.Property == TextBlock.FontStretchProperty) .ToList(); - + foreach (var setter in settersToRemove) { style.Setters.Remove(setter); } - + style.Setters.Add(new Setter(TextBlock.FontFamilyProperty, fontFamily)); style.Setters.Add(new Setter(TextBlock.FontStyleProperty, fontStyle)); style.Setters.Add(new Setter(TextBlock.FontWeightProperty, fontWeight)); @@ -421,7 +421,7 @@ namespace Flow.Launcher.Core.Resource // Retrieve theme resource – always use the resource with font settings applied. var resourceDict = GetResourceDictionary(theme); - + UpdateResourceDictionary(resourceDict); _settings.Theme = theme; diff --git a/Flow.Launcher.Infrastructure/NativeMethods.txt b/Flow.Launcher.Infrastructure/NativeMethods.txt index 18b206022..0e50420b0 100644 --- a/Flow.Launcher.Infrastructure/NativeMethods.txt +++ b/Flow.Launcher.Infrastructure/NativeMethods.txt @@ -43,6 +43,9 @@ MONITORINFOEXW WM_ENTERSIZEMOVE WM_EXITSIZEMOVE +OleInitialize +OleUninitialize + GetKeyboardLayout GetWindowThreadProcessId ActivateKeyboardLayout @@ -53,4 +56,4 @@ INPUTLANGCHANGE_FORWARD LOCALE_TRANSIENT_KEYBOARD1 LOCALE_TRANSIENT_KEYBOARD2 LOCALE_TRANSIENT_KEYBOARD3 -LOCALE_TRANSIENT_KEYBOARD4 \ No newline at end of file +LOCALE_TRANSIENT_KEYBOARD4 diff --git a/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs b/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs index 71020369a..2d15b54c5 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs @@ -1,15 +1,15 @@ using System; using System.Text.Json.Serialization; +using System.Threading.Tasks; namespace Flow.Launcher.Infrastructure.UserSettings { + #region Base + public abstract class ShortcutBaseModel { public string Key { get; set; } - [JsonIgnore] - public Func Expand { get; set; } = () => { return ""; }; - public override bool Equals(object obj) { return obj is ShortcutBaseModel other && @@ -22,16 +22,14 @@ namespace Flow.Launcher.Infrastructure.UserSettings } } - public class CustomShortcutModel : ShortcutBaseModel + public class BaseCustomShortcutModel : ShortcutBaseModel { public string Value { get; set; } - [JsonConstructorAttribute] - public CustomShortcutModel(string key, string value) + public BaseCustomShortcutModel(string key, string value) { Key = key; Value = value; - Expand = () => { return Value; }; } public void Deconstruct(out string key, out string value) @@ -40,26 +38,69 @@ namespace Flow.Launcher.Infrastructure.UserSettings value = Value; } - public static implicit operator (string Key, string Value)(CustomShortcutModel shortcut) + public static implicit operator (string Key, string Value)(BaseCustomShortcutModel shortcut) { return (shortcut.Key, shortcut.Value); } - public static implicit operator CustomShortcutModel((string Key, string Value) shortcut) + public static implicit operator BaseCustomShortcutModel((string Key, string Value) shortcut) { - return new CustomShortcutModel(shortcut.Key, shortcut.Value); + return new BaseCustomShortcutModel(shortcut.Key, shortcut.Value); } } - public class BuiltinShortcutModel : ShortcutBaseModel + public class BaseBuiltinShortcutModel : ShortcutBaseModel { public string Description { get; set; } - public BuiltinShortcutModel(string key, string description, Func expand) + public BaseBuiltinShortcutModel(string key, string description) { Key = key; Description = description; - Expand = expand ?? (() => { return ""; }); } } + + #endregion + + #region Custom Shortcut + + public class CustomShortcutModel : BaseCustomShortcutModel + { + [JsonIgnore] + public Func Expand { get; set; } = () => { return string.Empty; }; + + [JsonConstructor] + public CustomShortcutModel(string key, string value) : base(key, value) + { + Expand = () => { return Value; }; + } + } + + #endregion + + #region Builtin Shortcut + + public class BuiltinShortcutModel : BaseBuiltinShortcutModel + { + [JsonIgnore] + public Func Expand { get; set; } = () => { return string.Empty; }; + + public BuiltinShortcutModel(string key, string description, Func expand) : base(key, description) + { + Expand = expand ?? (() => { return string.Empty; }); + } + } + + public class AsyncBuiltinShortcutModel : BaseBuiltinShortcutModel + { + [JsonIgnore] + public Func> ExpandAsync { get; set; } = () => { return Task.FromResult(string.Empty); }; + + public AsyncBuiltinShortcutModel(string key, string description, Func> expandAsync) : base(key, description) + { + ExpandAsync = expandAsync ?? (() => { return Task.FromResult(string.Empty); }); + } + } + + #endregion } diff --git a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs index 7fb9b895a..920abc284 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs @@ -67,6 +67,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings metadata.Disabled = settings.Disabled; metadata.Priority = settings.Priority; metadata.SearchDelayTime = settings.SearchDelayTime; + metadata.HomeDisabled = settings.HomeDisabled; } else { @@ -79,6 +80,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings DefaultActionKeywords = metadata.ActionKeywords, // metadata provides default values ActionKeywords = metadata.ActionKeywords, // use default value Disabled = metadata.Disabled, + HomeDisabled = metadata.HomeDisabled, Priority = metadata.Priority, DefaultSearchDelayTime = metadata.SearchDelayTime, // metadata provides default values SearchDelayTime = metadata.SearchDelayTime, // use default value @@ -128,5 +130,6 @@ namespace Flow.Launcher.Infrastructure.UserSettings /// Used only to save the state of the plugin in settings /// public bool Disabled { get; set; } + public bool HomeDisabled { get; set; } } } diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index 1cccc38d9..34bf4f90e 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -158,6 +158,24 @@ namespace Flow.Launcher.Infrastructure.UserSettings } } } + + private bool _showHomePage { get; set; } = true; + public bool ShowHomePage + { + get => _showHomePage; + set + { + if (_showHomePage != value) + { + _showHomePage = value; + OnPropertyChanged(); + } + } + } + + public bool ShowHistoryResultsForHomePage { get; set; } = false; + public int MaxHistoryResultsToShowForHomePage { get; set; } = 5; + public int CustomExplorerIndex { get; set; } = 0; [JsonIgnore] @@ -312,9 +330,9 @@ namespace Flow.Launcher.Infrastructure.UserSettings public ObservableCollection CustomShortcuts { get; set; } = new ObservableCollection(); [JsonIgnore] - public ObservableCollection BuiltinShortcuts { get; set; } = new() + public ObservableCollection BuiltinShortcuts { get; set; } = new() { - new BuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", Clipboard.GetText), + new AsyncBuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", () => Win32Helper.StartSTATaskAsync(Clipboard.GetText)), new BuiltinShortcutModel("{active_explorer_path}", "shortcut_active_explorer_path", FileExplorerHelper.GetActiveExplorerPath) }; diff --git a/Flow.Launcher.Infrastructure/Win32Helper.cs b/Flow.Launcher.Infrastructure/Win32Helper.cs index 2788060eb..783ade14e 100644 --- a/Flow.Launcher.Infrastructure/Win32Helper.cs +++ b/Flow.Launcher.Infrastructure/Win32Helper.cs @@ -5,6 +5,8 @@ using System.Diagnostics; using System.Globalization; using System.Linq; using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Interop; using System.Windows.Markup; @@ -337,6 +339,78 @@ namespace Flow.Launcher.Infrastructure #endregion + #region STA Thread + + /* + Inspired by https://github.com/files-community/Files code on STA Thread handling. + */ + + public static Task StartSTATaskAsync(Action action) + { + var taskCompletionSource = new TaskCompletionSource(); + Thread thread = new(() => + { + PInvoke.OleInitialize(); + + try + { + action(); + taskCompletionSource.SetResult(); + } + catch (System.Exception ex) + { + taskCompletionSource.SetException(ex); + } + finally + { + PInvoke.OleUninitialize(); + } + }) + { + IsBackground = true, + Priority = ThreadPriority.Normal + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + return taskCompletionSource.Task; + } + + public static Task StartSTATaskAsync(Func func) + { + var taskCompletionSource = new TaskCompletionSource(); + + Thread thread = new(() => + { + PInvoke.OleInitialize(); + + try + { + taskCompletionSource.SetResult(func()); + } + catch (System.Exception ex) + { + taskCompletionSource.SetException(ex); + } + finally + { + PInvoke.OleUninitialize(); + } + }) + { + IsBackground = true, + Priority = ThreadPriority.Normal + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + return taskCompletionSource.Task; + } + + #endregion + #region Keyboard Layout private const string UserProfileRegistryPath = @"Control Panel\International\User Profile"; diff --git a/Flow.Launcher.Plugin/Interfaces/IAsyncHomeQuery.cs b/Flow.Launcher.Plugin/Interfaces/IAsyncHomeQuery.cs new file mode 100644 index 000000000..78d6454ae --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IAsyncHomeQuery.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin +{ + /// + /// Asynchronous Query Model for Flow Launcher When Query Text is Empty + /// + public interface IAsyncHomeQuery : IFeatures + { + /// + /// Asynchronous Querying When Query Text is Empty + /// + /// + /// If the Querying method requires high IO transmission + /// or performing CPU intense jobs (performing better with cancellation), please use this IAsyncHomeQuery interface + /// + /// Cancel when querying job is obsolete + /// + Task> HomeQueryAsync(CancellationToken token); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IHomeQuery.cs b/Flow.Launcher.Plugin/Interfaces/IHomeQuery.cs new file mode 100644 index 000000000..81186fca2 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IHomeQuery.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin +{ + /// + /// Synchronous Query Model for Flow Launcher When Query Text is Empty + /// + /// If the Querying method requires high IO transmission + /// or performing CPU intense jobs (performing better with cancellation), please try the IAsyncHomeQuery interface + /// + /// + public interface IHomeQuery : IAsyncHomeQuery + { + /// + /// Querying When Query Text is Empty + /// + /// This method will be called within a Task.Run, + /// so please avoid synchronously wait for long. + /// + /// + /// + List HomeQuery(); + + Task> IAsyncHomeQuery.HomeQueryAsync(CancellationToken token) => Task.Run(HomeQuery); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index 31580fbe8..d4eb02a90 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -471,8 +471,11 @@ namespace Flow.Launcher.Plugin public Task UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default); /// - /// Get the plugin manifest + /// Get the plugin manifest. /// + /// + /// If Flow cannot get manifest data, this could be null + /// /// public IReadOnlyList GetPluginManifest(); diff --git a/Flow.Launcher.Plugin/PluginMetadata.cs b/Flow.Launcher.Plugin/PluginMetadata.cs index da10bc6a5..09803cbd7 100644 --- a/Flow.Launcher.Plugin/PluginMetadata.cs +++ b/Flow.Launcher.Plugin/PluginMetadata.cs @@ -50,6 +50,11 @@ namespace Flow.Launcher.Plugin /// public bool Disabled { get; set; } + /// + /// Whether plugin is disabled in home query. + /// + public bool HomeDisabled { get; set; } + /// /// Plugin execute file path. /// diff --git a/Flow.Launcher.Plugin/Query.cs b/Flow.Launcher.Plugin/Query.cs index 913dc31ae..c3eede4c6 100644 --- a/Flow.Launcher.Plugin/Query.cs +++ b/Flow.Launcher.Plugin/Query.cs @@ -8,7 +8,8 @@ namespace Flow.Launcher.Plugin public class Query { /// - /// Raw query, this includes action keyword if it has + /// Raw query, this includes action keyword if it has. + /// It has handled buildin custom query shortkeys and build-in shortcuts, and it trims the whitespace. /// We didn't recommend use this property directly. You should always use Search property. /// public string RawQuery { get; internal init; } @@ -63,10 +64,10 @@ namespace Flow.Launcher.Plugin /// [JsonIgnore] public string FirstSearch => SplitSearch(0); - + [JsonIgnore] private string _secondToEndSearch; - + /// /// strings from second search (including) to last search /// diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs index 1b57d5cbe..942e94470 100644 --- a/Flow.Launcher/App.xaml.cs +++ b/Flow.Launcher/App.xaml.cs @@ -23,6 +23,7 @@ using Flow.Launcher.SettingPages.ViewModels; using Flow.Launcher.ViewModel; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.VisualStudio.Threading; namespace Flow.Launcher { @@ -198,7 +199,11 @@ namespace Flow.Launcher Current.MainWindow = _mainWindow; Current.MainWindow.Title = Constant.FlowLauncher; - // Main windows needs initialized before theme change because of blur settings + // Initialize hotkey mapper instantly after main window is created because + // it will steal focus from main window which causes window hide + HotKeyMapper.Initialize(); + + // Initialize theme for main window Ioc.Default.GetRequiredService().ChangeTheme(); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); @@ -239,6 +244,7 @@ namespace Flow.Launcher } } + [Conditional("RELEASE")] private void AutoUpdates() { _ = Task.Run(async () => @@ -296,13 +302,12 @@ namespace Flow.Launcher [Conditional("RELEASE")] private static void RegisterAppDomainExceptions() { - AppDomain.CurrentDomain.UnhandledException += ErrorReporting.UnhandledExceptionHandle; + AppDomain.CurrentDomain.UnhandledException += ErrorReporting.UnhandledException; } /// /// Let exception throw as normal is better for Debug /// - [Conditional("RELEASE")] private static void RegisterTaskSchedulerUnhandledException() { TaskScheduler.UnobservedTaskException += ErrorReporting.TaskSchedulerUnobservedTaskException; diff --git a/Flow.Launcher/Helper/DataWebRequestFactory.cs b/Flow.Launcher/Helper/DataWebRequestFactory.cs index 2e72ee240..db198ede4 100644 --- a/Flow.Launcher/Helper/DataWebRequestFactory.cs +++ b/Flow.Launcher/Helper/DataWebRequestFactory.cs @@ -28,18 +28,18 @@ public class DataWebRequestFactory : IWebRequestCreate public DataWebResponse(Uri uri) { - string uriString = uri.AbsoluteUri; + var uriString = uri.AbsoluteUri; - int commaIndex = uriString.IndexOf(','); - var headers = uriString.Substring(0, commaIndex).Split(';'); + var commaIndex = uriString.IndexOf(','); + var headers = uriString[..commaIndex].Split(';'); _contentType = headers[0]; - string dataString = uriString.Substring(commaIndex + 1); + var dataString = uriString[(commaIndex + 1)..]; _data = Convert.FromBase64String(dataString); } public override string ContentType { - get { return _contentType; } + get => _contentType; set { throw new NotSupportedException(); @@ -48,7 +48,7 @@ public class DataWebRequestFactory : IWebRequestCreate public override long ContentLength { - get { return _data.Length; } + get => _data.Length; set { throw new NotSupportedException(); diff --git a/Flow.Launcher/Helper/ErrorReporting.cs b/Flow.Launcher/Helper/ErrorReporting.cs index b1ddba717..aa810ba65 100644 --- a/Flow.Launcher/Helper/ErrorReporting.cs +++ b/Flow.Launcher/Helper/ErrorReporting.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using System.Windows; using System.Windows.Threading; @@ -10,33 +11,34 @@ namespace Flow.Launcher.Helper; public static class ErrorReporting { - private static void Report(Exception e) + private static void Report(Exception e, [CallerMemberName] string methodName = "UnHandledException") { - var logger = LogManager.GetLogger("UnHandledException"); + var logger = LogManager.GetLogger(methodName); logger.Fatal(ExceptionFormatter.FormatExcpetion(e)); var reportWindow = new ReportWindow(e); reportWindow.Show(); } - public static void UnhandledExceptionHandle(object sender, UnhandledExceptionEventArgs e) + public static void UnhandledException(object sender, UnhandledExceptionEventArgs e) { - //handle non-ui thread exceptions + // handle non-ui thread exceptions Report((Exception)e.ExceptionObject); } public static void DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { - //handle ui thread exceptions + // handle ui thread exceptions Report(e.Exception); - //prevent application exist, so the user can copy prompted error info + // prevent application exist, so the user can copy prompted error info e.Handled = true; } public static void TaskSchedulerUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e) { - //handle unobserved task exceptions + // handle unobserved task exceptions on UI thread Application.Current.Dispatcher.Invoke(() => Report(e.Exception)); - //prevent application exit, so the user can copy the prompted error info + // prevent application exit, so the user can copy the prompted error info + e.SetObserved(); } public static string RuntimeInfo() diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index d3252cb31..22ab2016c 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -126,6 +126,11 @@ Open Use Previous Korean IME You can change the Previous Korean IME settings directly from here + Home Page + Show home page results when query text is empty. + Show History Results in Home Page + Maximum History Results Shown in Home Page + This can only be edited if plugin supports Home feature and Home Page is enabled. Search Plugin @@ -148,6 +153,7 @@ Enabled Priority Search Delay + Home Page Current Priority New Priority Priority @@ -394,12 +400,17 @@ This new Action Keyword is the same as old, please choose a different one Success Completed successfully + Failed to copy Enter the action keywords you like to use to start the plugin and use whitespace to divide them. Use * if you don't want to specify any, and the plugin will be triggered without any action keywords. Search Delay Time Setting Input the search delay time in ms you like to use for the plugin. Input empty if you don't want to specify any, and the plugin will use default search delay time. + + Home Page + Enable the plugin home page state if you like to show the plugin results when query is empty. + Custom Query Hotkey Press a custom hotkey to open Flow Launcher and input the specified query automatically. diff --git a/Flow.Launcher/MainWindow.xaml.cs b/Flow.Launcher/MainWindow.xaml.cs index 10aff050e..e243549e3 100644 --- a/Flow.Launcher/MainWindow.xaml.cs +++ b/Flow.Launcher/MainWindow.xaml.cs @@ -16,7 +16,6 @@ using System.Windows.Threading; using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Core.Resource; -using Flow.Launcher.Helper; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Hotkey; using Flow.Launcher.Infrastructure.Image; @@ -167,9 +166,6 @@ namespace Flow.Launcher // Force update position UpdatePosition(); - // Refresh frame - await _theme.RefreshFrameAsync(); - // Initialize resize mode after refreshing frame SetupResizeMode(); @@ -182,9 +178,6 @@ namespace Flow.Launcher // Set the initial state of the QueryTextBoxCursorMovedToEnd property // Without this part, when shown for the first time, switching the context menu does not move the cursor to the end. _viewModel.QueryTextCursorMovedToEnd = false; - - // Initialize hotkey mapper after window is loaded - HotKeyMapper.Initialize(); // View model property changed event _viewModel.PropertyChanged += (o, e) => @@ -281,6 +274,12 @@ namespace Flow.Launcher case nameof(Settings.SettingWindowFont): InitializeContextMenu(); break; + case nameof(Settings.ShowHomePage): + if (_viewModel.QueryResultsSelected() && string.IsNullOrEmpty(_viewModel.QueryText)) + { + _viewModel.QueryResults(); + } + break; } }; @@ -296,6 +295,12 @@ namespace Flow.Launcher DependencyPropertyDescriptor .FromProperty(VisibilityProperty, typeof(StackPanel)) .AddValueChanged(History, (s, e) => UpdateClockPanelVisibility()); + + // Initialize query state + if (_settings.ShowHomePage && string.IsNullOrEmpty(_viewModel.QueryText)) + { + _viewModel.QueryResults(); + } } private async void OnClosing(object sender, CancelEventArgs e) diff --git a/Flow.Launcher/PublicAPIInstance.cs b/Flow.Launcher/PublicAPIInstance.cs index 3abc57b8a..5b8e8c9af 100644 --- a/Flow.Launcher/PublicAPIInstance.cs +++ b/Flow.Launcher/PublicAPIInstance.cs @@ -40,7 +40,7 @@ namespace Flow.Launcher private readonly Settings _settings; private readonly MainViewModel _mainVM; - // Must use getter to access Application.Current.Resources.MergedDictionaries so earlier + // Must use getter to avoid accessing Application.Current.Resources.MergedDictionaries so earlier in theme constructor private Theme _theme; private Theme Theme => _theme ??= Ioc.Default.GetRequiredService(); @@ -69,8 +69,7 @@ namespace Flow.Launcher _mainVM.ChangeQueryText(query, requery); } -#pragma warning disable VSTHRD100 // Avoid async void methods - + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "")] public async void RestartApp() { _mainVM.Hide(); @@ -89,8 +88,6 @@ namespace Flow.Launcher UpdateManager.RestartApp(Constant.ApplicationFileName); } -#pragma warning restore VSTHRD100 // Avoid async void methods - public void ShowMainWindow() => _mainVM.Show(); public void HideMainWindow() => _mainVM.Hide(); @@ -145,37 +142,92 @@ namespace Flow.Launcher ShellCommand.Execute(startInfo); } - public void CopyToClipboard(string stringToCopy, bool directCopy = false, bool showDefaultNotification = true) + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "")] + public async void CopyToClipboard(string stringToCopy, bool directCopy = false, bool showDefaultNotification = true) { if (string.IsNullOrEmpty(stringToCopy)) + { return; + } var isFile = File.Exists(stringToCopy); if (directCopy && (isFile || Directory.Exists(stringToCopy))) { - var paths = new StringCollection + // Sometimes the clipboard is locked and cannot be accessed, + // we need to retry a few times before giving up + var exception = await RetryActionOnSTAThreadAsync(() => + { + var paths = new StringCollection { stringToCopy }; - Clipboard.SetFileDropList(paths); - - if (showDefaultNotification) - ShowMsg( - $"{GetTranslation("copy")} {(isFile ? GetTranslation("fileTitle") : GetTranslation("folderTitle"))}", - GetTranslation("completedSuccessfully")); + Clipboard.SetFileDropList(paths); + }); + + if (exception == null) + { + if (showDefaultNotification) + { + ShowMsg( + $"{GetTranslation("copy")} {(isFile ? GetTranslation("fileTitle") : GetTranslation("folderTitle"))}", + GetTranslation("completedSuccessfully")); + } + } + else + { + LogException(nameof(PublicAPIInstance), "Failed to copy file/folder to clipboard", exception); + ShowMsgError(GetTranslation("failedToCopy")); + } } else { - Clipboard.SetDataObject(stringToCopy); + // Sometimes the clipboard is locked and cannot be accessed, + // we need to retry a few times before giving up + var exception = await RetryActionOnSTAThreadAsync(() => + { + // We should use SetText instead of SetDataObject to avoid the clipboard being locked by other applications + Clipboard.SetText(stringToCopy); + }); - if (showDefaultNotification) - ShowMsg( - $"{GetTranslation("copy")} {GetTranslation("textTitle")}", - GetTranslation("completedSuccessfully")); + if (exception == null) + { + if (showDefaultNotification) + { + ShowMsg( + $"{GetTranslation("copy")} {GetTranslation("textTitle")}", + GetTranslation("completedSuccessfully")); + } + } + else + { + LogException(nameof(PublicAPIInstance), "Failed to copy text to clipboard", exception); + ShowMsgError(GetTranslation("failedToCopy")); + } } } + private static async Task RetryActionOnSTAThreadAsync(Action action, int retryCount = 6, int retryDelay = 150) + { + for (var i = 0; i < retryCount; i++) + { + try + { + await Win32Helper.StartSTATaskAsync(action).ConfigureAwait(false); + break; + } + catch (Exception e) + { + if (i == retryCount - 1) + { + return e; + } + await Task.Delay(retryDelay); + } + } + return null; + } + public void StartLoadingBar() => _mainVM.ProgressBarVisibility = Visibility.Visible; public void StopLoadingBar() => _mainVM.ProgressBarVisibility = Visibility.Collapsed; diff --git a/Flow.Launcher/Resources/Controls/InstalledPluginDisplay.xaml b/Flow.Launcher/Resources/Controls/InstalledPluginDisplay.xaml index 619da22dc..0842a64f3 100644 --- a/Flow.Launcher/Resources/Controls/InstalledPluginDisplay.xaml +++ b/Flow.Launcher/Resources/Controls/InstalledPluginDisplay.xaml @@ -100,10 +100,19 @@ ToolTipService.InitialShowDelay="0" ToolTipService.ShowOnDisabled="True" Value="{Binding PluginSearchDelayTime, Mode=TwoWay}" /> - + + $"{SearchDelayTimeValue}ms"; + + public int MaxHistoryResultsToShowValue + { + get => Settings.MaxHistoryResultsToShowForHomePage; + set + { + if (Settings.MaxHistoryResultsToShowForHomePage != value) + { + Settings.MaxHistoryResultsToShowForHomePage = value; + OnPropertyChanged(); + } + } + } private void UpdateEnumDropdownLocalizations() { diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs index 249e4dd42..07df0682d 100644 --- a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs +++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs @@ -79,8 +79,8 @@ public partial class SettingsPanePluginStoreViewModel : BaseModel } } - public IList ExternalPlugins => - App.API.GetPluginManifest()?.Select(p => new PluginStoreItemViewModel(p)) + public IList ExternalPlugins => App.API.GetPluginManifest()? + .Select(p => new PluginStoreItemViewModel(p)) .OrderByDescending(p => p.Category == PluginStoreItemViewModel.NewRelease) .ThenByDescending(p => p.Category == PluginStoreItemViewModel.RecentlyUpdated) .ThenByDescending(p => p.Category == PluginStoreItemViewModel.None) diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs index de7cf15c3..3e1294bc2 100644 --- a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs +++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs @@ -80,18 +80,42 @@ public partial class SettingsPanePluginsViewModel : BaseModel } } + private bool _isHomeOnOffSelected; + public bool IsHomeOnOffSelected + { + get => _isHomeOnOffSelected; + set + { + if (_isHomeOnOffSelected != value) + { + _isHomeOnOffSelected = value; + OnPropertyChanged(); + } + } + } + public SettingsPanePluginsViewModel(Settings settings) { _settings = settings; UpdateEnumDropdownLocalizations(); } - public string FilterText { get; set; } = string.Empty; + private string filterText = string.Empty; + public string FilterText + { + get => filterText; + set + { + if (filterText != value) + { + filterText = value; + OnPropertyChanged(); + } + } + } - public PluginViewModel? SelectedPlugin { get; set; } - - private IEnumerable? _pluginViewModels; - private IEnumerable PluginViewModels => _pluginViewModels ??= PluginManager.AllPlugins + private IList? _pluginViewModels; + public IList PluginViewModels => _pluginViewModels ??= PluginManager.AllPlugins .OrderBy(plugin => plugin.Metadata.Disabled) .ThenBy(plugin => plugin.Metadata.Name) .Select(plugin => new PluginViewModel @@ -102,13 +126,12 @@ public partial class SettingsPanePluginsViewModel : BaseModel .Where(plugin => plugin.PluginSettingsObject != null) .ToList(); - public List FilteredPluginViewModels => PluginViewModels - .Where(v => - string.IsNullOrEmpty(FilterText) || - App.API.FuzzySearch(FilterText, v.PluginPair.Metadata.Name).IsSearchPrecisionScoreMet() || - App.API.FuzzySearch(FilterText, v.PluginPair.Metadata.Description).IsSearchPrecisionScoreMet() - ) - .ToList(); + public bool SatisfiesFilter(PluginViewModel plugin) + { + return string.IsNullOrEmpty(FilterText) || + App.API.FuzzySearch(FilterText, plugin.PluginPair.Metadata.Name).IsSearchPrecisionScoreMet() || + App.API.FuzzySearch(FilterText, plugin.PluginPair.Metadata.Description).IsSearchPrecisionScoreMet(); + } [RelayCommand] private async Task OpenHelperAsync(Button button) @@ -143,6 +166,18 @@ public partial class SettingsPanePluginsViewModel : BaseModel { Text = (string)Application.Current.Resources["searchDelayTimeTips"], TextWrapping = TextWrapping.Wrap + }, + new TextBlock + { + Text = (string)Application.Current.Resources["homeTitle"], + FontSize = 18, + Margin = new Thickness(0, 24, 0, 10), + TextWrapping = TextWrapping.Wrap + }, + new TextBlock + { + Text = (string)Application.Current.Resources["homeTips"], + TextWrapping = TextWrapping.Wrap } } }, @@ -167,16 +202,25 @@ public partial class SettingsPanePluginsViewModel : BaseModel IsOnOffSelected = false; IsPrioritySelected = true; IsSearchDelaySelected = false; + IsHomeOnOffSelected = false; break; case DisplayMode.SearchDelay: IsOnOffSelected = false; IsPrioritySelected = false; IsSearchDelaySelected = true; + IsHomeOnOffSelected = false; + break; + case DisplayMode.HomeOnOff: + IsOnOffSelected = false; + IsPrioritySelected = false; + IsSearchDelaySelected = false; + IsHomeOnOffSelected = true; break; default: IsOnOffSelected = true; IsPrioritySelected = false; IsSearchDelaySelected = false; + IsHomeOnOffSelected = false; break; } } @@ -186,5 +230,6 @@ public enum DisplayMode { OnOff, Priority, - SearchDelay + SearchDelay, + HomeOnOff } diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml index d45d28d8b..c0c5613de 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml @@ -217,17 +217,46 @@ Title="{DynamicResource searchDelayTime}" Sub="{DynamicResource searchDelayTimeToolTip}" Type="InsideFit"> - - - + + + + + + + + + + + + + + diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml index bb742f800..52d77f914 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml @@ -15,6 +15,12 @@ FocusManager.FocusedElement="{Binding ElementName=PluginFilterTextbox}" KeyDown="SettingsPanePlugins_OnKeyDown" mc:Ignorable="d"> + + + @@ -115,10 +121,9 @@ Background="{DynamicResource Color01B}" FontSize="14" ItemContainerStyle="{StaticResource PluginList}" - ItemsSource="{Binding FilteredPluginViewModels}" + ItemsSource="{Binding Source={StaticResource PluginCollectionView}}" ScrollViewer.CanContentScroll="False" ScrollViewer.HorizontalScrollBarVisibility="Disabled" - SelectedItem="{Binding SelectedPlugin}" SnapsToDevicePixels="True" Style="{DynamicResource PluginListStyle}" VirtualizingPanel.ScrollUnit="Pixel" diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml.cs b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml.cs index 97574b3ce..e9490804a 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml.cs +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml.cs @@ -1,7 +1,10 @@ -using System.Windows.Input; +using System.ComponentModel; +using System.Windows.Data; +using System.Windows.Input; using System.Windows.Navigation; using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.SettingPages.ViewModels; +using Flow.Launcher.ViewModel; namespace Flow.Launcher.SettingPages.Views; @@ -17,12 +20,38 @@ public partial class SettingsPanePlugins DataContext = _viewModel; InitializeComponent(); } + _viewModel.PropertyChanged += ViewModel_PropertyChanged; base.OnNavigatedTo(e); } + private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SettingsPanePluginsViewModel.FilterText)) + { + ((CollectionViewSource)FindResource("PluginCollectionView")).View.Refresh(); + } + } + + protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) + { + _viewModel.PropertyChanged -= ViewModel_PropertyChanged; + base.OnNavigatingFrom(e); + } + private void SettingsPanePlugins_OnKeyDown(object sender, KeyEventArgs e) { if (e.Key is not Key.F || Keyboard.Modifiers is not ModifierKeys.Control) return; PluginFilterTextbox.Focus(); } + + private void PluginCollectionView_OnFilter(object sender, FilterEventArgs e) + { + if (e.Item is not PluginViewModel plugin) + { + e.Accepted = false; + return; + } + + e.Accepted = _viewModel.SatisfiesFilter(plugin); + } } diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index 96e039ebe..f92a8ed0a 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -33,7 +33,9 @@ namespace Flow.Launcher.ViewModel private bool _isQueryRunning; private Query _lastQuery; + private bool _lastIsHomeQuery; private string _queryTextBeforeLeaveResults; + 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; @@ -42,14 +44,20 @@ namespace Flow.Launcher.ViewModel private int lastHistoryIndex = 1; private readonly UserSelectedRecord _userSelectedRecord; - private CancellationTokenSource _updateSource; - private CancellationToken _updateToken; + private CancellationTokenSource _updateSource; // Used to cancel old query flows + private CancellationToken _updateToken; // Used to avoid ObjectDisposedException of _updateSource.Token private ChannelWriter _resultsUpdateChannelWriter; private Task _resultsViewUpdateTask; private readonly IReadOnlyList _emptyResult = new List(); + private readonly PluginMetadata _historyMetadata = new() + { + ID = "298303A65D128A845D28A7B83B3968C2", // ID is for identifying the update plugin in UpdateActionAsync + Priority = 0 // Priority is for calculating scores in UpdateResultView + }; + #endregion #region Constructor @@ -59,6 +67,7 @@ namespace Flow.Launcher.ViewModel _queryTextBeforeLeaveResults = ""; _queryText = ""; _lastQuery = new Query(); + _ignoredQueryText = null; // null as invalid value Settings = Ioc.Default.GetRequiredService(); Settings.PropertyChanged += (_, args) => @@ -253,8 +262,11 @@ namespace Flow.Launcher.ViewModel } PluginManager.UpdatePluginMetadata(resultsCopy, pair.Metadata, e.Query); - if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, pair.Metadata, e.Query, - token))) + + if (token.IsCancellationRequested) return; + + if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, pair.Metadata, e.Query, + token))) { App.API.LogError(ClassName, "Unable to add item to Result Update Queue"); } @@ -638,7 +650,31 @@ namespace Flow.Launcher.ViewModel /// Force query even when Query Text doesn't change public void ChangeQueryText(string queryText, bool isReQuery = false) { - _ = ChangeQueryTextAsync(queryText, isReQuery); + // Must check access so that we will not block the UI thread which causes window visibility issue + if (!Application.Current.Dispatcher.CheckAccess()) + { + Application.Current.Dispatcher.Invoke(() => ChangeQueryText(queryText, isReQuery)); + return; + } + + if (QueryText != queryText) + { + // Change query text first + QueryText = queryText; + // When we are changing query from codes, we should not delay the query + Query(false, isReQuery: false); + + // set to false so the subsequent set true triggers + // PropertyChanged and MoveQueryTextToEnd is called + QueryTextCursorMovedToEnd = false; + } + else if (isReQuery) + { + // When we are re-querying, we should not delay the query + Query(false, isReQuery: true); + } + + QueryTextCursorMovedToEnd = true; } /// @@ -646,10 +682,10 @@ namespace Flow.Launcher.ViewModel /// private async Task ChangeQueryTextAsync(string queryText, bool isReQuery = false) { - // Must check access so that we will not block the UI thread which cause window visibility issue + // Must check access so that we will not block the UI thread which causes window visibility issue if (!Application.Current.Dispatcher.CheckAccess()) { - await Application.Current.Dispatcher.InvokeAsync(() => ChangeQueryText(queryText, isReQuery)); + await Application.Current.Dispatcher.InvokeAsync(() => ChangeQueryTextAsync(queryText, isReQuery)); return; } @@ -702,6 +738,9 @@ namespace Flow.Launcher.ViewModel if (isReturningFromContextMenu) { _queryText = _queryTextBeforeLeaveResults; + // When executing OnPropertyChanged, QueryTextBox_TextChanged1 and Query will be called + // So we need to ignore it so that we will not call Query again + _ignoredQueryText = _queryText; OnPropertyChanged(nameof(QueryText)); QueryTextCursorMovedToEnd = true; } @@ -751,8 +790,6 @@ namespace Flow.Launcher.ViewModel } } } - - _selectedResults.Visibility = Visibility.Visible; } } @@ -1046,9 +1083,39 @@ namespace Flow.Launcher.ViewModel #region Query + public void QueryResults() + { + _ = QueryResultsAsync(false); + } + public void Query(bool searchDelay, bool isReQuery = false) { - _ = QueryAsync(searchDelay, isReQuery); + if (_ignoredQueryText != null) + { + if (_ignoredQueryText == QueryText) + { + _ignoredQueryText = null; + return; + } + else + { + // If _ignoredQueryText does not match current QueryText, we should still execute Query + _ignoredQueryText = null; + } + } + + if (QueryResultsSelected()) + { + _ = QueryResultsAsync(searchDelay, isReQuery); + } + else if (ContextMenuSelected()) + { + QueryContextMenu(); + } + else if (HistorySelected()) + { + QueryHistory(); + } } private async Task QueryAsync(bool searchDelay, bool isReQuery = false) @@ -1077,9 +1144,20 @@ namespace Flow.Launcher.ViewModel if (selected != null) // SelectedItem returns null if selection is empty. { - var results = PluginManager.GetContextMenusForPlugin(selected); - results.Add(ContextMenuTopMost(selected)); - results.Add(ContextMenuPluginInfo(selected.PluginID)); + 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.PluginID)); + } if (!string.IsNullOrEmpty(query)) { @@ -1113,31 +1191,7 @@ namespace Flow.Launcher.ViewModel var query = QueryText.ToLower().Trim(); History.Clear(); - var results = new List(); - foreach (var h in _history.Items) - { - var title = App.API.GetTranslation("executeQuery"); - var time = App.API.GetTranslation("lastExecuteTime"); - var result = new Result - { - Title = string.Format(title, h.Query), - SubTitle = string.Format(time, h.ExecutedDateTime), - IcoPath = "Images\\history.png", - Preview = new Result.PreviewInfo - { - PreviewImagePath = Constant.HistoryIcon, - Description = string.Format(time, h.ExecutedDateTime) - }, - OriginQuery = new Query { RawQuery = h.Query }, - Action = _ => - { - SelectedResults = Results; - App.API.ChangeQuery(h.Query); - return false; - } - }; - results.Add(result); - } + var results = GetHistoryItems(_history.Items); if (!string.IsNullOrEmpty(query)) { @@ -1154,41 +1208,68 @@ namespace Flow.Launcher.ViewModel } } + private static List GetHistoryItems(IEnumerable historyItems) + { + var results = new List(); + foreach (var h in historyItems) + { + var title = App.API.GetTranslation("executeQuery"); + var time = App.API.GetTranslation("lastExecuteTime"); + var result = new Result + { + Title = string.Format(title, h.Query), + SubTitle = string.Format(time, 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); + } + return results; + } + private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, bool reSelect = true) { _updateSource?.Cancel(); - var query = ConstructQuery(QueryText, Settings.CustomShortcuts, Settings.BuiltinShortcuts); + App.API.LogDebug(ClassName, $"Start query with text: <{QueryText}>"); - var plugins = PluginManager.ValidPluginsForQuery(query); + var query = await ConstructQueryAsync(QueryText, Settings.CustomShortcuts, Settings.BuiltinShortcuts); - if (query == null || plugins.Count == 0) // shortcut expanded + if (query == null) // shortcut expanded { - Results.Clear(); + App.API.LogDebug(ClassName, $"Clear query results"); + + // Hide and clear results again because running query may show and add some results Results.Visibility = Visibility.Collapsed; + Results.Clear(); + + // Reset plugin icon PluginIconPath = null; PluginIconSource = null; SearchIconVisibility = Visibility.Visible; + + // Hide progress bar again because running query may set this to visible + ProgressBarVisibility = Visibility.Hidden; return; } - else if (plugins.Count == 1) - { - PluginIconPath = plugins.Single().Metadata.IcoPath; - PluginIconSource = await App.API.LoadImageAsync(PluginIconPath); - SearchIconVisibility = Visibility.Hidden; - } - else - { - PluginIconPath = null; - PluginIconSource = null; - SearchIconVisibility = Visibility.Visible; - } + + App.API.LogDebug(ClassName, $"Start query with ActionKeyword <{query.ActionKeyword}> and RawQuery <{query.RawQuery}>"); + + var isHomeQuery = query.RawQuery == string.Empty; _updateSource?.Dispose(); var currentUpdateSource = new CancellationTokenSource(); _updateSource = currentUpdateSource; - _updateToken = _updateSource.Token; + var currentCancellationToken = _updateSource.Token; + _updateToken = currentCancellationToken; ProgressBarVisibility = Visibility.Hidden; _isQueryRunning = true; @@ -1196,45 +1277,96 @@ namespace Flow.Launcher.ViewModel // Switch to ThreadPool thread await TaskScheduler.Default; - if (_updateSource.Token.IsCancellationRequested) - return; + if (currentCancellationToken.IsCancellationRequested) return; // Update the query's IsReQuery property to true if this is a re-query query.IsReQuery = isReQuery; // handle the exclusiveness of plugin using action keyword - RemoveOldQueryResults(query); + RemoveOldQueryResults(query, isHomeQuery); _lastQuery = query; + _lastIsHomeQuery = isHomeQuery; - if (string.IsNullOrEmpty(query.ActionKeyword)) + ICollection plugins = Array.Empty(); + if (isHomeQuery) + { + if (Settings.ShowHomePage) + { + plugins = PluginManager.ValidPluginsForHomeQuery(); + } + + PluginIconPath = null; + PluginIconSource = null; + SearchIconVisibility = Visibility.Visible; + } + else + { + plugins = PluginManager.ValidPluginsForQuery(query); + + if (plugins.Count == 1) + { + PluginIconPath = plugins.Single().Metadata.IcoPath; + PluginIconSource = await App.API.LoadImageAsync(PluginIconPath); + SearchIconVisibility = Visibility.Hidden; + } + else + { + PluginIconPath = null; + PluginIconSource = null; + SearchIconVisibility = Visibility.Visible; + } + } + + var validPluginNames = plugins.Select(x => $"<{x.Metadata.Name}>"); + App.API.LogDebug(ClassName, $"Valid <{plugins.Count}> plugins: {string.Join(" ", validPluginNames)}"); + + // Do not wait for performance improvement + /*if (string.IsNullOrEmpty(query.ActionKeyword)) { // Wait 15 millisecond for query change in global query // if query changes, return so that it won't be calculated - await Task.Delay(15, _updateSource.Token); - if (_updateSource.Token.IsCancellationRequested) - return; - } + await Task.Delay(15, currentCancellationToken); + if (currentCancellationToken.IsCancellationRequested) return; + }*/ - _ = Task.Delay(200, _updateSource.Token).ContinueWith(_ => + _ = Task.Delay(200, currentCancellationToken).ContinueWith(_ => { // start the progress bar if query takes more than 200 ms and this is the current running query and it didn't finish yet - if (!_updateSource.Token.IsCancellationRequested && _isQueryRunning) + if (_isQueryRunning) { ProgressBarVisibility = Visibility.Visible; } }, - _updateSource.Token, + currentCancellationToken, TaskContinuationOptions.NotOnCanceled, TaskScheduler.Default); // plugins are ICollection, meaning LINQ will get the Count and preallocate Array - var tasks = plugins.Select(plugin => plugin.Metadata.Disabled switch + Task[] tasks; + if (isHomeQuery) { - false => QueryTaskAsync(plugin, _updateSource.Token), - true => Task.CompletedTask - }).ToArray(); + tasks = plugins.Select(plugin => plugin.Metadata.HomeDisabled switch + { + false => QueryTaskAsync(plugin, currentCancellationToken), + true => Task.CompletedTask + }).ToArray(); + + // Query history results for home page firstly so it will be put on top of the results + if (Settings.ShowHistoryResultsForHomePage) + { + QueryHistoryTask(currentCancellationToken); + } + } + else + { + tasks = plugins.Select(plugin => plugin.Metadata.Disabled switch + { + false => QueryTaskAsync(plugin, currentCancellationToken), + true => Task.CompletedTask + }).ToArray(); + } try { @@ -1246,13 +1378,13 @@ namespace Flow.Launcher.ViewModel // nothing to do here } - if (_updateSource.Token.IsCancellationRequested) - return; + if (currentCancellationToken.IsCancellationRequested) return; // this should happen once after all queries are done so progress bar should continue // until the end of all querying _isQueryRunning = false; - if (!_updateSource.Token.IsCancellationRequested) + + if (!currentCancellationToken.IsCancellationRequested) { // update to hidden if this is still the current query ProgressBarVisibility = Visibility.Hidden; @@ -1261,24 +1393,26 @@ namespace Flow.Launcher.ViewModel // Local function async Task QueryTaskAsync(PluginPair plugin, CancellationToken token) { - if (searchDelay) + App.API.LogDebug(ClassName, $"Wait for querying plugin <{plugin.Metadata.Name}>"); + + if (searchDelay && !isHomeQuery) // Do not delay for home query { var searchDelayTime = plugin.Metadata.SearchDelayTime ?? Settings.SearchDelayTime; await Task.Delay(searchDelayTime, token); - if (token.IsCancellationRequested) - return; + if (token.IsCancellationRequested) return; } // Since it is wrapped within a ThreadPool Thread, the synchronous context is null // Task.Yield will force it to run in ThreadPool await Task.Yield(); - var results = await PluginManager.QueryForPluginAsync(plugin, query, token); + var results = isHomeQuery ? + await PluginManager.QueryHomeForPluginAsync(plugin, query, token) : + await PluginManager.QueryForPluginAsync(plugin, query, token); - if (token.IsCancellationRequested) - return; + if (token.IsCancellationRequested) return; IReadOnlyList resultsCopy; if (results == null) @@ -1299,24 +1433,46 @@ namespace Flow.Launcher.ViewModel } } + if (token.IsCancellationRequested) return; + + App.API.LogDebug(ClassName, $"Update results for plugin <{plugin.Metadata.Name}>"); + if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, plugin.Metadata, query, token, reSelect))) { App.API.LogError(ClassName, "Unable to add item to Result Update Queue"); } } + + 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); + + if (token.IsCancellationRequested) return; + + App.API.LogDebug(ClassName, $"Update results for history"); + + if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(results, _historyMetadata, query, + token))) + { + App.API.LogError(ClassName, "Unable to add item to Result Update Queue"); + } + } } - private Query ConstructQuery(string queryText, IEnumerable customShortcuts, - IEnumerable builtInShortcuts) + private async Task ConstructQueryAsync(string queryText, IEnumerable customShortcuts, + IEnumerable builtInShortcuts) { if (string.IsNullOrWhiteSpace(queryText)) { - return null; + return QueryBuilder.Build(string.Empty, PluginManager.NonGlobalPlugins); } - StringBuilder queryBuilder = new(queryText); - StringBuilder queryBuilderTmp = new(queryText); + var queryBuilder = new StringBuilder(queryText); + var queryBuilderTmp = new StringBuilder(queryText); // Sorting order is important here, the reason is for matching longest shortcut by default foreach (var shortcut in customShortcuts.OrderByDescending(x => x.Key.Length)) @@ -1329,42 +1485,78 @@ namespace Flow.Launcher.ViewModel queryBuilder.Replace('@' + shortcut.Key, shortcut.Expand()); } - string customExpanded = queryBuilder.ToString(); + // Applying builtin shortcuts + await BuildQueryAsync(builtInShortcuts, queryBuilder, queryBuilderTmp); - Application.Current.Dispatcher.Invoke(() => - { - foreach (var shortcut in builtInShortcuts) - { - try - { - if (customExpanded.Contains(shortcut.Key)) - { - var expansion = shortcut.Expand(); - queryBuilder.Replace(shortcut.Key, expansion); - queryBuilderTmp.Replace(shortcut.Key, expansion); - } - } - catch (Exception e) - { - App.API.LogException(ClassName, - $"Error when expanding shortcut {shortcut.Key}", - e); - } - } - }); - - // show expanded builtin shortcuts - // use private field to avoid infinite recursion - _queryText = queryBuilderTmp.ToString(); - - var query = QueryBuilder.Build(queryBuilder.ToString().Trim(), PluginManager.NonGlobalPlugins); - return query; + return QueryBuilder.Build(queryBuilder.ToString().Trim(), PluginManager.NonGlobalPlugins); } - private void RemoveOldQueryResults(Query query) + private async Task BuildQueryAsync(IEnumerable builtInShortcuts, + StringBuilder queryBuilder, StringBuilder queryBuilderTmp) { - if (_lastQuery?.ActionKeyword != query?.ActionKeyword) + var customExpanded = queryBuilder.ToString(); + + var queryChanged = false; + + foreach (var shortcut in builtInShortcuts) { + try + { + if (customExpanded.Contains(shortcut.Key)) + { + string expansion; + if (shortcut is BuiltinShortcutModel syncShortcut) + { + expansion = syncShortcut.Expand(); + } + else if (shortcut is AsyncBuiltinShortcutModel asyncShortcut) + { + expansion = await asyncShortcut.ExpandAsync(); + } + else + { + continue; + } + queryBuilder.Replace(shortcut.Key, expansion); + queryBuilderTmp.Replace(shortcut.Key, expansion); + queryChanged = true; + } + } + catch (Exception e) + { + App.API.LogException(ClassName, $"Error when expanding shortcut {shortcut.Key}", e); + } + } + + // Show expanded builtin shortcuts + if (queryChanged) + { + // Use private field to avoid infinite recursion + _queryText = queryBuilderTmp.ToString(); + // When executing OnPropertyChanged, QueryTextBox_TextChanged1 and Query will be called + // So we need to ignore it so that we will not call Query again + _ignoredQueryText = _queryText; + OnPropertyChanged(nameof(QueryText)); + } + } + + private void RemoveOldQueryResults(Query query, bool isHomeQuery) + { + // If last and current query are home query, we don't need to clear the results + if (_lastIsHomeQuery && isHomeQuery) + { + return; + } + // If last or current query is home query, we need to clear the results + else if (_lastIsHomeQuery || isHomeQuery) + { + App.API.LogDebug(ClassName, $"Remove old results"); + Results.Clear(); + } + // If last and current query are not home query, we need to check action keyword + else if (_lastQuery?.ActionKeyword != query?.ActionKeyword) + { + App.API.LogDebug(ClassName, $"Remove old results"); Results.Clear(); } } @@ -1385,7 +1577,8 @@ namespace Flow.Launcher.ViewModel App.API.ShowMsg(App.API.GetTranslation("success")); App.API.ReQuery(); return false; - } + }, + Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE74B") }; } else @@ -1394,7 +1587,6 @@ namespace Flow.Launcher.ViewModel { Title = App.API.GetTranslation("setAsTopMostInThisQuery"), IcoPath = "Images\\up.png", - Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\xeac2"), PluginDirectory = Constant.ProgramDirectory, Action = _ => { @@ -1402,7 +1594,8 @@ namespace Flow.Launcher.ViewModel App.API.ShowMsg(App.API.GetTranslation("success")); App.API.ReQuery(); return false; - } + }, + Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE74A") }; } @@ -1487,6 +1680,9 @@ namespace Flow.Launcher.ViewModel public void Show() { + // When application is exiting, we should not show the main window + if (App.Exiting) return; + // When application is exiting, the Application.Current will be null Application.Current?.Dispatcher.Invoke(() => { @@ -1535,10 +1731,7 @@ namespace Flow.Launcher.ViewModel await CloseExternalPreviewAsync(); } - if (!QueryResultsSelected()) - { - SelectedResults = Results; - } + BackToQueryResults(); switch (Settings.LastQueryMode) { @@ -1551,7 +1744,7 @@ namespace Flow.Launcher.ViewModel break; case LastQueryMode.ActionKeywordPreserved: case LastQueryMode.ActionKeywordSelected: - var newQuery = _lastQuery.ActionKeyword; + var newQuery = _lastQuery?.ActionKeyword; if (!string.IsNullOrEmpty(newQuery)) newQuery += " "; diff --git a/Flow.Launcher/ViewModel/PluginViewModel.cs b/Flow.Launcher/ViewModel/PluginViewModel.cs index 64a7f3db8..01fa3d203 100644 --- a/Flow.Launcher/ViewModel/PluginViewModel.cs +++ b/Flow.Launcher/ViewModel/PluginViewModel.cs @@ -75,6 +75,16 @@ namespace Flow.Launcher.ViewModel } } + public bool PluginHomeState + { + get => !PluginPair.Metadata.HomeDisabled; + set + { + PluginPair.Metadata.HomeDisabled = !value; + PluginSettingsObject.HomeDisabled = !value; + } + } + public bool IsExpanded { get => _isExpanded; @@ -154,6 +164,7 @@ namespace Flow.Launcher.ViewModel public Infrastructure.UserSettings.Plugin PluginSettingsObject{ get; init; } public bool SearchDelayEnabled => Settings.SearchQueryResultsWithDelay; public string DefaultSearchDelay => Settings.SearchDelayTime.ToString(); + public bool HomeEnabled => Settings.ShowHomePage && PluginManager.IsHomePlugin(PluginPair.Metadata.ID); public void OnActionKeywordsTextChanged() { diff --git a/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs b/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs index 05e8d960f..48717816b 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs @@ -3,21 +3,33 @@ using System.Linq; namespace Flow.Launcher.Plugin.PluginIndicator { - public class Main : IPlugin, IPluginI18n + public class Main : IPlugin, IPluginI18n, IHomeQuery { internal PluginInitContext Context { get; private set; } public List Query(Query query) + { + return QueryResults(query); + } + + public List HomeQuery() + { + return QueryResults(); + } + + private List QueryResults(Query query = null) { var nonGlobalPlugins = GetNonGlobalPlugins(); + var querySearch = query?.Search ?? string.Empty; + var results = from keyword in nonGlobalPlugins.Keys let plugin = nonGlobalPlugins[keyword].Metadata - let keywordSearchResult = Context.API.FuzzySearch(query.Search, keyword) - let searchResult = keywordSearchResult.IsSearchPrecisionScoreMet() ? keywordSearchResult : Context.API.FuzzySearch(query.Search, plugin.Name) + let keywordSearchResult = Context.API.FuzzySearch(querySearch, keyword) + let searchResult = keywordSearchResult.IsSearchPrecisionScoreMet() ? keywordSearchResult : Context.API.FuzzySearch(querySearch, plugin.Name) let score = searchResult.Score where (searchResult.IsSearchPrecisionScoreMet() - || string.IsNullOrEmpty(query.Search)) // To list all available action keywords + || string.IsNullOrEmpty(querySearch)) // To list all available action keywords && !plugin.Disabled select new Result {