diff --git a/Flow.Launcher.Core/Plugin/QueryBuilder.cs b/Flow.Launcher.Core/Plugin/QueryBuilder.cs index 3dc7877acc2..2d9acc45b33 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; @@ -6,12 +6,13 @@ namespace Flow.Launcher.Core.Plugin { public static class QueryBuilder { - public static Query Build(string text, Dictionary<string, PluginPair> nonGlobalPlugins) + public static Query Build(string input, string text, Dictionary<string, PluginPair> nonGlobalPlugins) { // 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,13 +22,15 @@ public static Query Build(string text, Dictionary<string, PluginPair> nonGlobalP 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; @@ -36,10 +39,11 @@ public static Query Build(string text, Dictionary<string, PluginPair> nonGlobalP return new Query () { Search = search, + Input = input, RawQuery = rawQuery, SearchTerms = searchTerms, ActionKeyword = actionKeyword }; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Core/Resource/Theme.cs b/Flow.Launcher.Core/Resource/Theme.cs index a46932c6af4..059359694b4 100644 --- a/Flow.Launcher.Core/Resource/Theme.cs +++ b/Flow.Launcher.Core/Resource/Theme.cs @@ -76,7 +76,7 @@ public Theme(IPublicAPI publicAPI, Settings settings) { _api.LogError(ClassName, "Current theme resource not found. Initializing with default theme."); _oldTheme = Constant.DefaultTheme; - }; + } } #endregion @@ -126,7 +126,7 @@ public void UpdateFonts() // 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 @@ private void ApplyFontSettings(ResourceDictionary dict) 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 @@ private void ApplyFontSettings(ResourceDictionary dict) 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 @@ private static void SetFontProperties(Style style, FontFamily fontFamily, FontSt // First, find the setters to remove and store them in a list var settersToRemove = style.Setters .OfType<Setter>() - .Where(setter => + .Where(setter => setter.Property == Control.FontFamilyProperty || setter.Property == Control.FontStyleProperty || setter.Property == Control.FontWeightProperty || @@ -227,18 +227,18 @@ private static void SetFontProperties(Style style, FontFamily fontFamily, FontSt { var settersToRemove = style.Setters .OfType<Setter>() - .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 @@ public bool ChangeTheme(string theme = null) // 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 18b20602213..0e50420b0e0 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 71020369a60..2d15b54c5be 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<string> Expand { get; set; } = () => { return ""; }; - public override bool Equals(object obj) { return obj is ShortcutBaseModel other && @@ -22,16 +22,14 @@ public override int GetHashCode() } } - 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 @@ public void Deconstruct(out string key, out string value) 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<string> 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<string> 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<string> Expand { get; set; } = () => { return string.Empty; }; + + public BuiltinShortcutModel(string key, string description, Func<string> expand) : base(key, description) + { + Expand = expand ?? (() => { return string.Empty; }); + } + } + + public class AsyncBuiltinShortcutModel : BaseBuiltinShortcutModel + { + [JsonIgnore] + public Func<Task<string>> ExpandAsync { get; set; } = () => { return Task.FromResult(string.Empty); }; + + public AsyncBuiltinShortcutModel(string key, string description, Func<Task<string>> expandAsync) : base(key, description) + { + ExpandAsync = expandAsync ?? (() => { return Task.FromResult(string.Empty); }); + } + } + + #endregion } diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index 8500e7aa444..fdc3333b020 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -295,9 +295,9 @@ public bool KeepMaxResults public ObservableCollection<CustomShortcutModel> CustomShortcuts { get; set; } = new ObservableCollection<CustomShortcutModel>(); [JsonIgnore] - public ObservableCollection<BuiltinShortcutModel> BuiltinShortcuts { get; set; } = new() + public ObservableCollection<BaseBuiltinShortcutModel> 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 6a5af41df28..0ece7d63a30 100644 --- a/Flow.Launcher.Infrastructure/Win32Helper.cs +++ b/Flow.Launcher.Infrastructure/Win32Helper.cs @@ -5,6 +5,8 @@ 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 @@ internal static HWND GetWindowHandle(Window window, bool ensure = false) #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<T> StartSTATaskAsync<T>(Func<T> func) + { + var taskCompletionSource = new TaskCompletionSource<T>(); + + 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/Query.cs b/Flow.Launcher.Plugin/Query.cs index 913dc31ae65..24f4b597c02 100644 --- a/Flow.Launcher.Plugin/Query.cs +++ b/Flow.Launcher.Plugin/Query.cs @@ -8,7 +8,14 @@ namespace Flow.Launcher.Plugin public class Query { /// <summary> - /// Raw query, this includes action keyword if it has + /// Input text in query box. + /// We didn't recommend use this property directly. You should always use Search property. + /// </summary> + public string Input { get; internal init; } + + /// <summary> + /// 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. /// </summary> public string RawQuery { get; internal init; } diff --git a/Flow.Launcher.Test/QueryBuilderTest.cs b/Flow.Launcher.Test/QueryBuilderTest.cs index c8ac17748da..3912f26a7d3 100644 --- a/Flow.Launcher.Test/QueryBuilderTest.cs +++ b/Flow.Launcher.Test/QueryBuilderTest.cs @@ -16,7 +16,7 @@ public void ExclusivePluginQueryTest() {">", new PluginPair {Metadata = new PluginMetadata {ActionKeywords = new List<string> {">"}}}} }; - Query q = QueryBuilder.Build("> ping google.com -n 20 -6", nonGlobalPlugins); + Query q = QueryBuilder.Build("> ping google.com -n 20 -6", "> ping google.com -n 20 -6", nonGlobalPlugins); ClassicAssert.AreEqual("> ping google.com -n 20 -6", q.RawQuery); ClassicAssert.AreEqual("ping google.com -n 20 -6", q.Search, "Search should not start with the ActionKeyword."); @@ -39,7 +39,7 @@ public void ExclusivePluginQueryIgnoreDisabledTest() {">", new PluginPair {Metadata = new PluginMetadata {ActionKeywords = new List<string> {">"}, Disabled = true}}} }; - Query q = QueryBuilder.Build("> ping google.com -n 20 -6", nonGlobalPlugins); + Query q = QueryBuilder.Build("> ping google.com -n 20 -6", "> ping google.com -n 20 -6", nonGlobalPlugins); ClassicAssert.AreEqual("> ping google.com -n 20 -6", q.Search); ClassicAssert.AreEqual(q.Search, q.RawQuery, "RawQuery should be equal to Search."); @@ -51,7 +51,7 @@ public void ExclusivePluginQueryIgnoreDisabledTest() [Test] public void GenericPluginQueryTest() { - Query q = QueryBuilder.Build("file.txt file2 file3", new Dictionary<string, PluginPair>()); + Query q = QueryBuilder.Build("file.txt file2 file3", "file.txt file2 file3", new Dictionary<string, PluginPair>()); ClassicAssert.AreEqual("file.txt file2 file3", q.Search); ClassicAssert.AreEqual("", q.ActionKeyword); diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs index 87698a54571..cb15c9d1cb3 100644 --- a/Flow.Launcher/App.xaml.cs +++ b/Flow.Launcher/App.xaml.cs @@ -21,6 +21,7 @@ using Flow.Launcher.ViewModel; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.VisualStudio.Threading; namespace Flow.Launcher { @@ -29,6 +30,8 @@ public partial class App : IDisposable, ISingleInstanceApp #region Public Properties public static IPublicAPI API { get; private set; } + public static JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext()); + public static bool Exitting => _mainWindow.CanClose; #endregion @@ -37,7 +40,7 @@ public partial class App : IDisposable, ISingleInstanceApp private static readonly string ClassName = nameof(App); private static bool _disposed; - private MainWindow _mainWindow; + private static MainWindow _mainWindow; private readonly MainViewModel _mainVM; private readonly Settings _settings; @@ -149,7 +152,7 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => Ioc.Default.GetRequiredService<Portable>().PreStartCleanUpAfterPortabilityUpdate(); API.LogInfo(ClassName, "Begin Flow Launcher startup ----------------------------------------------------"); - API.LogInfo(ClassName, "Runtime info:{ErrorReporting.RuntimeInfo()}"); + API.LogInfo(ClassName, $"Runtime info:{ErrorReporting.RuntimeInfo()}"); RegisterAppDomainExceptions(); RegisterDispatcherUnhandledException(); @@ -176,11 +179,12 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => _mainWindow = new MainWindow(); - API.LogInfo(ClassName, "Dependencies Info:{ErrorReporting.DependenciesInfo()}"); - Current.MainWindow = _mainWindow; Current.MainWindow.Title = Constant.FlowLauncher; + // Initialize hotkey mapper instantly after main window is created because it will steal focus from main window + HotKeyMapper.Initialize(); + // main windows needs initialized before theme change because of blur settings Ioc.Default.GetRequiredService<Theme>().ChangeTheme(); @@ -218,6 +222,7 @@ private void AutoStartup() } } + [Conditional("RELEASE")] private void AutoUpdates() { _ = Task.Run(async () => @@ -275,13 +280,12 @@ private void RegisterDispatcherUnhandledException() [Conditional("RELEASE")] private static void RegisterAppDomainExceptions() { - AppDomain.CurrentDomain.UnhandledException += ErrorReporting.UnhandledExceptionHandle; + AppDomain.CurrentDomain.UnhandledException += ErrorReporting.UnhandledException; } /// <summary> - /// let exception throw as normal is better for Debug + /// let exception throw as normal for Debug and Release /// </summary> - [Conditional("RELEASE")] private static void RegisterTaskSchedulerUnhandledException() { TaskScheduler.UnobservedTaskException += ErrorReporting.TaskSchedulerUnobservedTaskException; diff --git a/Flow.Launcher/Helper/ErrorReporting.cs b/Flow.Launcher/Helper/ErrorReporting.cs index b1ddba7179a..aa810ba651a 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 f1b41eb3c9b..889679d3c4c 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -392,6 +392,7 @@ <system:String x:Key="newActionKeywordsSameAsOld">This new Action Keyword is the same as old, please choose a different one</system:String> <system:String x:Key="success">Success</system:String> <system:String x:Key="completedSuccessfully">Completed successfully</system:String> + <system:String x:Key="failedToCopy">Failed to copy</system:String> <system:String x:Key="actionkeyword_tips">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.</system:String> <!-- Search Delay Settings Dialog --> diff --git a/Flow.Launcher/MainWindow.xaml.cs b/Flow.Launcher/MainWindow.xaml.cs index bf7a45b1d25..1c9b73a5475 100644 --- a/Flow.Launcher/MainWindow.xaml.cs +++ b/Flow.Launcher/MainWindow.xaml.cs @@ -16,7 +16,6 @@ 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; @@ -174,9 +173,6 @@ private async void OnLoaded(object sender, RoutedEventArgs _) // 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) => @@ -401,7 +397,7 @@ private void OnKeyDown(object sender, KeyEventArgs e) && QueryTextBox.CaretIndex == QueryTextBox.Text.Length) { var queryWithoutActionKeyword = - QueryBuilder.Build(QueryTextBox.Text.Trim(), PluginManager.NonGlobalPlugins)?.Search; + QueryBuilder.Build(QueryTextBox.Text, QueryTextBox.Text.Trim(), PluginManager.NonGlobalPlugins)?.Search; if (FilesFolders.IsLocationPathString(queryWithoutActionKeyword)) { diff --git a/Flow.Launcher/PublicAPIInstance.cs b/Flow.Launcher/PublicAPIInstance.cs index 3abc57b8a81..d58e9cf5320 100644 --- a/Flow.Launcher/PublicAPIInstance.cs +++ b/Flow.Launcher/PublicAPIInstance.cs @@ -69,8 +69,7 @@ public void ChangeQuery(string query, bool requery = false) _mainVM.ChangeQueryText(query, requery); } -#pragma warning disable VSTHRD100 // Avoid async void methods - + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "<Pending>")] public async void RestartApp() { _mainVM.Hide(); @@ -89,8 +88,6 @@ public async void RestartApp() UpdateManager.RestartApp(Constant.ApplicationFileName); } -#pragma warning restore VSTHRD100 // Avoid async void methods - public void ShowMainWindow() => _mainVM.Show(); public void HideMainWindow() => _mainVM.Hide(); @@ -145,35 +142,90 @@ public void ShellRun(string cmd, string filename = "cmd.exe") 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 = "<Pending>")] + 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 (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")); + } + } + } - if (showDefaultNotification) - ShowMsg( - $"{GetTranslation("copy")} {GetTranslation("textTitle")}", - GetTranslation("completedSuccessfully")); + private static async Task<Exception> RetryActionOnSTAThreadAsync(Action action, int retryCount = 6, int retryDelay = 150) + { + for (var i = 0; i < retryCount; i++) + { + try + { + await Win32Helper.StartSTATaskAsync(action); + break; + } + catch (Exception e) + { + if (i == retryCount - 1) + { + return e; + } + await Task.Delay(retryDelay); + } } + return null; } public void StartLoadingBar() => _mainVM.ProgressBarVisibility = Visibility.Visible; diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index 00675149b41..cfeec0b99de 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -31,8 +31,8 @@ public partial class MainViewModel : BaseModel, ISavable, IDisposable private static readonly string ClassName = nameof(MainViewModel); - private bool _isQueryRunning; private Query _lastQuery; + private Query _runningQuery; private string _queryTextBeforeLeaveResults; private readonly FlowLauncherJsonStorage<History> _historyItemsStorage; @@ -43,8 +43,8 @@ public partial class MainViewModel : BaseModel, ISavable, IDisposable private readonly UserSelectedRecord _userSelectedRecord; private readonly TopMostRecord _topMostRecord; - private CancellationTokenSource _updateSource; - private CancellationToken _updateToken; + private CancellationTokenSource _updateSource; // Used to cancel old query flows + private readonly SemaphoreSlim _updateLock = new(1, 1); // Used to ensure one updating flow private ChannelWriter<ResultsForUpdate> _resultsUpdateChannelWriter; private Task _resultsViewUpdateTask; @@ -236,12 +236,15 @@ public void RegisterResultsUpdatedEvent() var plugin = (IResultUpdated)pair.Plugin; plugin.ResultsUpdated += (s, e) => { - if (e.Query.RawQuery != QueryText || e.Token.IsCancellationRequested) + Infrastructure.Logger.Log.Debug(ClassName, $"Call IResultsUpdated for QueryText: {e.Query.RawQuery}"); + + if (_runningQuery == null || e.Query.RawQuery != _runningQuery.RawQuery || e.Token.IsCancellationRequested) { + Infrastructure.Logger.Log.Debug(ClassName, $"Cancel for QueryText 6: {e.Query.RawQuery}"); return; } - var token = e.Token == default ? _updateToken : e.Token; + var token = e.Token == default ? _updateSource.Token : e.Token; // make a clone to avoid possible issue that plugin will also change the list and items when updating view model var resultsCopy = DeepCloneResults(e.Results, token); @@ -255,11 +258,22 @@ public void RegisterResultsUpdatedEvent() } PluginManager.UpdatePluginMetadata(resultsCopy, pair.Metadata, e.Query); - if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, pair.Metadata, e.Query, - token))) + + if (_runningQuery == null || e.Query.RawQuery != _runningQuery.RawQuery || token.IsCancellationRequested) + { + Infrastructure.Logger.Log.Debug(ClassName, $"Cancel for QueryText 7: {e.Query.RawQuery}"); + return; + } + + if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, pair.Metadata, e.Query, + token))) { App.API.LogError(ClassName, "Unable to add item to Result Update Queue"); } + else + { + Infrastructure.Logger.Log.Debug(ClassName, $"Write updates for QueryText 1: {e.Query.RawQuery}"); + } }; } } @@ -306,6 +320,7 @@ public void ReQuery() { if (QueryResultsSelected()) { + Infrastructure.Logger.Log.Debug(ClassName, $"Search Delay: {false}, Is Requery: {true}, Reselect: {true}"); // When we are re-querying, we should not delay the query _ = QueryResultsAsync(false, isReQuery: true); } @@ -314,6 +329,7 @@ public void ReQuery() public void ReQuery(bool reselect) { BackToQueryResults(); + Infrastructure.Logger.Log.Debug(ClassName, $"Search Delay: {false}, Is Requery: {true}, Reselect: {reselect}"); // When we are re-querying, we should not delay the query _ = QueryResultsAsync(false, isReQuery: true, reSelect: reselect); } @@ -363,7 +379,7 @@ private void LoadContextMenu() [RelayCommand] private void Backspace(object index) { - var query = QueryBuilder.Build(QueryText.Trim(), PluginManager.NonGlobalPlugins); + var query = QueryBuilder.Build(QueryText, QueryText.Trim(), PluginManager.NonGlobalPlugins); // GetPreviousExistingDirectory does not require trailing '\', otherwise will return empty string var path = FilesFolders.GetPreviousExistingDirectory((_) => true, query.Search.TrimEnd('\\')); @@ -640,7 +656,31 @@ private void DecreaseMaxResult() /// <param name="isReQuery">Force query even when Query Text doesn't change</param> 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; } /// <summary> @@ -648,10 +688,10 @@ public void ChangeQueryText(string queryText, bool isReQuery = false) /// </summary> 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; } @@ -1050,13 +1090,26 @@ private bool QueryResultsPreviewed() public void Query(bool searchDelay, bool isReQuery = false) { - _ = QueryAsync(searchDelay, isReQuery); + if (QueryResultsSelected()) + { + Infrastructure.Logger.Log.Debug(ClassName, $"Search Delay: {searchDelay}, Is Requery: {isReQuery}, Reselect: {true}"); + _ = QueryResultsAsync(searchDelay, isReQuery); + } + else if (ContextMenuSelected()) + { + QueryContextMenu(); + } + else if (HistorySelected()) + { + QueryHistory(); + } } private async Task QueryAsync(bool searchDelay, bool isReQuery = false) { if (QueryResultsSelected()) { + Infrastructure.Logger.Log.Debug(ClassName, $"Search Delay: {searchDelay}, Is Requery: {isReQuery}, Reselect: {true}"); await QueryResultsAsync(searchDelay, isReQuery); } else if (ContextMenuSelected()) @@ -1159,105 +1212,176 @@ private void QueryHistory() private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, bool reSelect = true) { _updateSource?.Cancel(); + _runningQuery = null; - var query = ConstructQuery(QueryText, Settings.CustomShortcuts, Settings.BuiltinShortcuts); + Infrastructure.Logger.Log.Debug(ClassName, $"Query construct for QueryText: {QueryText}"); - var plugins = PluginManager.ValidPluginsForQuery(query); + var query = ConstructQuery(QueryText, Settings.CustomShortcuts, Settings.BuiltinShortcuts); - if (query == null || plugins.Count == 0) // shortcut expanded + if (query == null) // shortcut expanded { - Results.Clear(); + Infrastructure.Logger.Log.Debug(ClassName, $"Query null for QueryText"); + + // Hide and clear results fast because results are already invalid although query is still running Results.Visibility = Visibility.Collapsed; - PluginIconPath = null; - PluginIconSource = null; - SearchIconVisibility = Visibility.Visible; - 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; - } + Results.Clear(); - _updateSource?.Dispose(); + // Hide progress bar because running query is already invalid + ProgressBarVisibility = Visibility.Hidden; - var currentUpdateSource = new CancellationTokenSource(); - _updateSource = currentUpdateSource; - _updateToken = _updateSource.Token; + // Wait last query to be canceled and then reset UI elements + await _updateLock.WaitAsync(CancellationToken.None); + try + { + Infrastructure.Logger.Log.Debug(ClassName, $"Clear for QueryText"); - ProgressBarVisibility = Visibility.Hidden; - _isQueryRunning = true; + // Hide and clear results again because running query may show and add some results + Results.Visibility = Visibility.Collapsed; + Results.Clear(); - // Switch to ThreadPool thread - await TaskScheduler.Default; + // Reset plugin icon + PluginIconPath = null; + PluginIconSource = null; + SearchIconVisibility = Visibility.Visible; - if (_updateSource.Token.IsCancellationRequested) + // Hide progress bar again because running query may set this to visible + ProgressBarVisibility = Visibility.Hidden; + } + finally + { + _updateLock.Release(); + } return; + } - // Update the query's IsReQuery property to true if this is a re-query - query.IsReQuery = isReQuery; + Infrastructure.Logger.Log.Debug(ClassName, $"Wait for QueryText: {query.RawQuery}"); - // handle the exclusiveness of plugin using action keyword - RemoveOldQueryResults(query); + await _updateLock.WaitAsync(CancellationToken.None); + try + { + // Check if the query has changed because query can be changed so fast that + // token of the query between two queries has not been created yet + if (query.Input != QueryText && query.RawQuery != QueryText.Trim()) + { + Infrastructure.Logger.Log.Debug(ClassName, $"Cancel for QueryText 0: {query.RawQuery}"); + return; + } - _lastQuery = query; + _updateSource = new CancellationTokenSource(); + + ProgressBarVisibility = Visibility.Hidden; + + Infrastructure.Logger.Log.Debug(ClassName, $"Start for QueryText: {query.RawQuery}"); + Infrastructure.Logger.Log.Debug(ClassName, $"ProgressBar: {Visibility.Hidden}"); + + _runningQuery = query; + + // Switch to ThreadPool thread + await TaskScheduler.Default; - 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) + { + Infrastructure.Logger.Log.Debug(ClassName, $"Cancel for QueryText 1: {query.RawQuery}"); return; - } + } + + // Update the query's IsReQuery property to true if this is a re-query + query.IsReQuery = isReQuery; - _ = Task.Delay(200, _updateSource.Token).ContinueWith(_ => + // handle the exclusiveness of plugin using action keyword + RemoveOldQueryResults(query); + + Infrastructure.Logger.Log.Debug(ClassName, $"Remove old for QueryText: {query.RawQuery}"); + + _lastQuery = query; + + var plugins = PluginManager.ValidPluginsForQuery(query); + + Infrastructure.Logger.Log.Debug(ClassName, $"Valid {plugins.Count} plugins QueryText: {query.RawQuery}"); + + if (plugins.Count == 1) { - // 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) + PluginIconPath = plugins.Single().Metadata.IcoPath; + PluginIconSource = await App.API.LoadImageAsync(PluginIconPath); + SearchIconVisibility = Visibility.Hidden; + } + else + { + PluginIconPath = null; + PluginIconSource = null; + SearchIconVisibility = Visibility.Visible; + } + + // 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; + }*/ + + _ = Task.Delay(200, _updateSource.Token).ContinueWith(_ => { - ProgressBarVisibility = Visibility.Visible; - } - }, - _updateSource.Token, - TaskContinuationOptions.NotOnCanceled, - TaskScheduler.Default); + Infrastructure.Logger.Log.Debug(ClassName, $"Check ProgressBar for QueryText: running: {_runningQuery?.RawQuery ?? "null"} query: {query.RawQuery}"); - // plugins are ICollection, meaning LINQ will get the Count and preallocate Array + // 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 (_runningQuery != null && _runningQuery == query) + { + ProgressBarVisibility = Visibility.Visible; - var tasks = plugins.Select(plugin => plugin.Metadata.Disabled switch - { - false => QueryTaskAsync(plugin, _updateSource.Token), - true => Task.CompletedTask - }).ToArray(); + Infrastructure.Logger.Log.Debug(ClassName, $"ProgressBar: {Visibility.Visible}"); + } + }, + _updateSource.Token, + TaskContinuationOptions.NotOnCanceled, + TaskScheduler.Default); - try - { - // Check the code, WhenAll will translate all type of IEnumerable or Collection to Array, so make an array at first - await Task.WhenAll(tasks); - } - catch (OperationCanceledException) - { - // nothing to do here - } + // plugins are ICollection, meaning LINQ will get the Count and preallocate Array - if (_updateSource.Token.IsCancellationRequested) - return; + var tasks = plugins.Select(plugin => plugin.Metadata.Disabled switch + { + false => QueryTaskAsync(plugin, _updateSource.Token), + true => Task.CompletedTask + }).ToArray(); + + try + { + // Check the code, WhenAll will translate all type of IEnumerable or Collection to Array, so make an array at first + await Task.WhenAll(tasks); + } + catch (OperationCanceledException) + { + // nothing to do here + } + + if (_updateSource.Token.IsCancellationRequested) + { + Infrastructure.Logger.Log.Debug(ClassName, $"Cancel for QueryText 2: {query.RawQuery}"); + return; + } + + // this should happen once after all queries are done so progress bar should continue + // until the end of all querying + _runningQuery = null; - // 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 (!_updateSource.Token.IsCancellationRequested) + { + // update to hidden if this is still the current query + ProgressBarVisibility = Visibility.Hidden; + + Infrastructure.Logger.Log.Debug(ClassName, $"ProgressBar: {Visibility.Hidden}"); + } + } + finally { - // update to hidden if this is still the current query - ProgressBarVisibility = Visibility.Hidden; + Infrastructure.Logger.Log.Debug(ClassName, $"Query return for QueryText: {query.RawQuery}"); + // this make sures running query is null even if the query is canceled + _runningQuery = null; + + // release the lock so that other query can be executed + _updateLock.Release(); } // Local function @@ -1270,7 +1394,10 @@ async Task QueryTaskAsync(PluginPair plugin, CancellationToken token) await Task.Delay(searchDelayTime, token); if (token.IsCancellationRequested) + { + Infrastructure.Logger.Log.Debug(ClassName, $"Cancel for QueryText 3: {QueryText}"); return; + } } // Since it is wrapped within a ThreadPool Thread, the synchronous context is null @@ -1280,7 +1407,10 @@ async Task QueryTaskAsync(PluginPair plugin, CancellationToken token) var results = await PluginManager.QueryForPluginAsync(plugin, query, token); if (token.IsCancellationRequested) + { + Infrastructure.Logger.Log.Debug(ClassName, $"Cancel for QueryText 4: {query.RawQuery}"); return; + } IReadOnlyList<Result> resultsCopy; if (results == null) @@ -1301,24 +1431,34 @@ async Task QueryTaskAsync(PluginPair plugin, CancellationToken token) } } + if (token.IsCancellationRequested) + { + Infrastructure.Logger.Log.Debug(ClassName, $"Cancel for QueryText 5: {query.RawQuery}"); + return; + } + if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, plugin.Metadata, query, token, reSelect))) { App.API.LogError(ClassName, "Unable to add item to Result Update Queue"); } + else + { + Infrastructure.Logger.Log.Debug(ClassName, $"Write updates for QueryText: {query.RawQuery}"); + } } } private Query ConstructQuery(string queryText, IEnumerable<CustomShortcutModel> customShortcuts, - IEnumerable<BuiltinShortcutModel> builtInShortcuts) + IEnumerable<BaseBuiltinShortcutModel> builtInShortcuts) { if (string.IsNullOrWhiteSpace(queryText)) { return null; } - 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)) @@ -1331,36 +1471,56 @@ private Query ConstructQuery(string queryText, IEnumerable<CustomShortcutModel> queryBuilder.Replace('@' + shortcut.Key, shortcut.Expand()); } - string customExpanded = queryBuilder.ToString(); + // Applying builtin shortcuts + BuildQuery(builtInShortcuts, queryBuilder, queryBuilderTmp); - Application.Current.Dispatcher.Invoke(() => + return QueryBuilder.Build(queryText, queryBuilder.ToString().Trim(), PluginManager.NonGlobalPlugins); + } + + private void BuildQuery(IEnumerable<BaseBuiltinShortcutModel> builtInShortcuts, + StringBuilder queryBuilder, StringBuilder queryBuilderTmp) + { + var customExpanded = queryBuilder.ToString(); + + var queryChanged = false; + + foreach (var shortcut in builtInShortcuts) { - foreach (var shortcut in builtInShortcuts) + try { - try + if (customExpanded.Contains(shortcut.Key)) { - if (customExpanded.Contains(shortcut.Key)) + string expansion; + if (shortcut is BuiltinShortcutModel syncShortcut) { - var expansion = shortcut.Expand(); - queryBuilder.Replace(shortcut.Key, expansion); - queryBuilderTmp.Replace(shortcut.Key, expansion); + expansion = syncShortcut.Expand(); } - } - catch (Exception e) - { - App.API.LogException(ClassName, - $"Error when expanding shortcut {shortcut.Key}", - e); + else if (shortcut is AsyncBuiltinShortcutModel asyncShortcut) + { + expansion = App.JTF.Run(() => asyncShortcut.ExpandAsync()); + } + else + { + continue; + } + queryBuilder.Replace(shortcut.Key, expansion); + queryBuilderTmp.Replace(shortcut.Key, expansion); + queryChanged = true; } } - }); - - // show expanded builtin shortcuts - // use private field to avoid infinite recursion - _queryText = queryBuilderTmp.ToString(); + catch (Exception e) + { + App.API.LogException(ClassName, $"Error when expanding shortcut {shortcut.Key}", e); + } + } - var query = QueryBuilder.Build(queryBuilder.ToString().Trim(), PluginManager.NonGlobalPlugins); - return query; + if (queryChanged) + { + // show expanded builtin shortcuts + // use private field to avoid infinite recursion + _queryText = queryBuilderTmp.ToString(); + OnPropertyChanged(nameof(QueryText)); + } } private void RemoveOldQueryResults(Query query) @@ -1489,6 +1649,9 @@ public bool ShouldIgnoreHotkeys() public void Show() { + // When application is exiting, we should not show the main window + if (App.Exitting) return; + // When application is exiting, the Application.Current will be null Application.Current?.Dispatcher.Invoke(() => { @@ -1702,6 +1865,7 @@ protected virtual void Dispose(bool disposing) { _resultsViewUpdateTask.Dispose(); } + _updateLock?.Dispose(); _disposed = true; } }