Fix copy to clipboard STA thread issue & Support retry for copy & Async build-in shortcut model & Fix build shortcuts text replace issue & Fix startup window hide issue (#3314)

This commit is contained in:
Jack Ye 2025-05-02 12:26:14 +08:00 committed by GitHub
parent 950a7f5f2b
commit 8bb96d7f58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 444 additions and 160 deletions

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using Flow.Launcher.Plugin;
@ -33,7 +33,7 @@ namespace Flow.Launcher.Core.Plugin
searchTerms = terms;
}
return new Query ()
return new Query()
{
Search = search,
RawQuery = rawQuery,
@ -42,4 +42,4 @@ namespace Flow.Launcher.Core.Plugin
};
}
}
}
}

View file

@ -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<Setter>()
.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<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 @@ 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;

View file

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

View file

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

View file

@ -312,9 +312,9 @@ namespace Flow.Launcher.Infrastructure.UserSettings
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)
};

View file

@ -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<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";

View file

@ -471,8 +471,11 @@ namespace Flow.Launcher.Plugin
public Task<bool> UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default);
/// <summary>
/// Get the plugin manifest
/// Get the plugin manifest.
/// </summary>
/// <remarks>
/// If Flow cannot get manifest data, this could be null
/// </remarks>
/// <returns></returns>
public IReadOnlyList<UserPlugin> GetPluginManifest();

View file

@ -8,7 +8,8 @@ namespace Flow.Launcher.Plugin
public class Query
{
/// <summary>
/// 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.
/// </summary>
public string RawQuery { get; internal init; }
@ -63,10 +64,10 @@ namespace Flow.Launcher.Plugin
/// </remarks>
[JsonIgnore]
public string FirstSearch => SplitSearch(0);
[JsonIgnore]
private string _secondToEndSearch;
/// <summary>
/// strings from second search (including) to last search
/// </summary>

View file

@ -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,6 +199,10 @@ namespace Flow.Launcher
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 which causes window hide
HotKeyMapper.Initialize();
// Main windows needs initialized before theme change because of blur settings
Ioc.Default.GetRequiredService<Theme>().ChangeTheme();
@ -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;
}
/// <summary>
/// Let exception throw as normal is better for Debug
/// </summary>
[Conditional("RELEASE")]
private static void RegisterTaskSchedulerUnhandledException()
{
TaskScheduler.UnobservedTaskException += ErrorReporting.TaskSchedulerUnobservedTaskException;

View file

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

View file

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

View file

@ -394,6 +394,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 -->

View file

@ -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;
@ -182,9 +181,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) =>

View file

@ -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<Theme>();
@ -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 = "<Pending>")]
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 = "<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 (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<Exception> 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;

View file

@ -79,8 +79,8 @@ public partial class SettingsPanePluginStoreViewModel : BaseModel
}
}
public IList<PluginStoreItemViewModel> ExternalPlugins =>
App.API.GetPluginManifest()?.Select(p => new PluginStoreItemViewModel(p))
public IList<PluginStoreItemViewModel> 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)

View file

@ -86,12 +86,22 @@ public partial class SettingsPanePluginsViewModel : BaseModel
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<PluginViewModel>? _pluginViewModels;
private IEnumerable<PluginViewModel> PluginViewModels => _pluginViewModels ??= PluginManager.AllPlugins
private IList<PluginViewModel>? _pluginViewModels;
public IList<PluginViewModel> PluginViewModels => _pluginViewModels ??= PluginManager.AllPlugins
.OrderBy(plugin => plugin.Metadata.Disabled)
.ThenBy(plugin => plugin.Metadata.Name)
.Select(plugin => new PluginViewModel
@ -102,13 +112,12 @@ public partial class SettingsPanePluginsViewModel : BaseModel
.Where(plugin => plugin.PluginSettingsObject != null)
.ToList();
public List<PluginViewModel> 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)

View file

@ -15,6 +15,12 @@
FocusManager.FocusedElement="{Binding ElementName=PluginFilterTextbox}"
KeyDown="SettingsPanePlugins_OnKeyDown"
mc:Ignorable="d">
<ui:Page.Resources>
<CollectionViewSource
x:Key="PluginCollectionView"
Filter="PluginCollectionView_OnFilter"
Source="{Binding PluginViewModels}" />
</ui:Page.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="73" />
@ -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"

View file

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

View file

@ -43,8 +43,7 @@ namespace Flow.Launcher.ViewModel
private readonly UserSelectedRecord _userSelectedRecord;
private readonly TopMostRecord _topMostRecord;
private CancellationTokenSource _updateSource;
private CancellationToken _updateToken;
private CancellationTokenSource _updateSource; // Used to cancel old query flows
private ChannelWriter<ResultsForUpdate> _resultsUpdateChannelWriter;
private Task _resultsViewUpdateTask;
@ -241,7 +240,7 @@ namespace Flow.Launcher.ViewModel
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,8 +254,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");
}
@ -640,7 +642,31 @@ namespace Flow.Launcher.ViewModel
/// <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 +674,10 @@ namespace Flow.Launcher.ViewModel
/// </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,7 +1076,18 @@ namespace Flow.Launcher.ViewModel
public void Query(bool searchDelay, bool isReQuery = false)
{
_ = QueryAsync(searchDelay, isReQuery);
if (QueryResultsSelected())
{
_ = QueryResultsAsync(searchDelay, isReQuery);
}
else if (ContextMenuSelected())
{
QueryContextMenu();
}
else if (HistorySelected())
{
QueryHistory();
}
}
private async Task QueryAsync(bool searchDelay, bool isReQuery = false)
@ -1160,20 +1197,45 @@ namespace Flow.Launcher.ViewModel
{
_updateSource?.Cancel();
var query = ConstructQuery(QueryText, Settings.CustomShortcuts, Settings.BuiltinShortcuts);
var query = await ConstructQueryAsync(QueryText, Settings.CustomShortcuts, Settings.BuiltinShortcuts);
var plugins = PluginManager.ValidPluginsForQuery(query);
if (query == null || plugins.Count == 0) // shortcut expanded
if (query == null) // shortcut expanded
{
Results.Clear();
// 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)
_updateSource = new CancellationTokenSource();
ProgressBarVisibility = Visibility.Hidden;
_isQueryRunning = true;
// Switch to ThreadPool thread
await TaskScheduler.Default;
if (_updateSource.Token.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);
_lastQuery = query;
var plugins = PluginManager.ValidPluginsForQuery(query);
if (plugins.Count == 1)
{
PluginIconPath = plugins.Single().Metadata.IcoPath;
PluginIconSource = await App.API.LoadImageAsync(PluginIconPath);
@ -1186,42 +1248,20 @@ namespace Flow.Launcher.ViewModel
SearchIconVisibility = Visibility.Visible;
}
_updateSource?.Dispose();
var currentUpdateSource = new CancellationTokenSource();
_updateSource = currentUpdateSource;
_updateToken = _updateSource.Token;
ProgressBarVisibility = Visibility.Hidden;
_isQueryRunning = true;
// Switch to ThreadPool thread
await TaskScheduler.Default;
if (_updateSource.Token.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);
_lastQuery = query;
if (string.IsNullOrEmpty(query.ActionKeyword))
// 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(_ =>
{
// 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;
}
@ -1248,12 +1288,12 @@ namespace Flow.Launcher.ViewModel
// nothing to do here
}
if (_updateSource.Token.IsCancellationRequested)
return;
if (_updateSource.Token.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)
{
// update to hidden if this is still the current query
@ -1269,8 +1309,7 @@ namespace Flow.Launcher.ViewModel
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
@ -1279,8 +1318,7 @@ namespace Flow.Launcher.ViewModel
var results = await PluginManager.QueryForPluginAsync(plugin, query, token);
if (token.IsCancellationRequested)
return;
if (token.IsCancellationRequested) return;
IReadOnlyList<Result> resultsCopy;
if (results == null)
@ -1301,6 +1339,8 @@ namespace Flow.Launcher.ViewModel
}
}
if (token.IsCancellationRequested) return;
if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, plugin.Metadata, query,
token, reSelect)))
{
@ -1309,16 +1349,16 @@ namespace Flow.Launcher.ViewModel
}
}
private Query ConstructQuery(string queryText, IEnumerable<CustomShortcutModel> customShortcuts,
IEnumerable<BuiltinShortcutModel> builtInShortcuts)
private async Task<Query> ConstructQueryAsync(string queryText, IEnumerable<CustomShortcutModel> customShortcuts,
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 +1371,56 @@ 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(() =>
return QueryBuilder.Build(queryBuilder.ToString().Trim(), PluginManager.NonGlobalPlugins);
}
private async Task BuildQueryAsync(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 = 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
// use private field to avoid infinite recursion
_queryText = queryBuilderTmp.ToString();
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 +1549,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(() =>
{