diff --git a/Flow.Launcher.Avalonia/App.axaml.cs b/Flow.Launcher.Avalonia/App.axaml.cs index 0b8d0c827..73c274bca 100644 --- a/Flow.Launcher.Avalonia/App.axaml.cs +++ b/Flow.Launcher.Avalonia/App.axaml.cs @@ -1,52 +1,93 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Threading; using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; using Microsoft.Extensions.DependencyInjection; using System; +using System.Threading.Tasks; namespace Flow.Launcher.Avalonia; public partial class App : Application { - public override void Initialize() - { - AvaloniaXamlLoader.Load(this); - } + private static readonly string ClassName = nameof(App); + private Settings? _settings; + private MainViewModel? _mainVM; + + public static IPublicAPI? API { get; private set; } + + public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() { - // Set up dependency injection - var services = new ServiceCollection(); - ConfigureServices(services); - var serviceProvider = services.BuildServiceProvider(); - Ioc.Default.ConfigureServices(serviceProvider); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - desktop.MainWindow = new MainWindow(); - } + LoadSettings(); + ConfigureDI(); + API = Ioc.Default.GetRequiredService(); + _mainVM = Ioc.Default.GetRequiredService(); + + desktop.MainWindow = new MainWindow(); + + Dispatcher.UIThread.Post(async () => await InitializePluginsAsync(), DispatcherPriority.Background); + } base.OnFrameworkInitializationCompleted(); } - private void ConfigureServices(IServiceCollection services) + private void LoadSettings() { - // Register settings - for now create a default instance - // In production, this would load from the existing settings file - services.AddSingleton(_ => + try { - var settings = new Settings(); - // Set some defaults for the Avalonia version - settings.WindowSize = 580; - settings.WindowHeightSize = 42; - settings.QueryBoxFontSize = 24; - settings.ItemHeightSize = 50; - settings.ResultItemFontSize = 14; - settings.ResultSubItemFontSize = 12; - settings.MaxResultsToShow = 6; - return settings; - }); + var storage = new FlowLauncherJsonStorage(); + _settings = storage.Load(); + _settings.SetStorage(storage); + } + catch (Exception e) + { + Log.Exception(ClassName, "Settings load failed", e); + _settings = new Settings + { + WindowSize = 580, WindowHeightSize = 42, QueryBoxFontSize = 24, + ItemHeightSize = 50, ResultItemFontSize = 14, ResultSubItemFontSize = 12, MaxResultsToShow = 6 + }; + } + } + + private void ConfigureDI() + { + var services = new ServiceCollection(); + services.AddSingleton(_settings!); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new AvaloniaPublicAPI( + sp.GetRequiredService(), + () => sp.GetRequiredService())); + Ioc.Default.ConfigureServices(services.BuildServiceProvider()); + } + + private async Task InitializePluginsAsync() + { + try + { + Log.Info(ClassName, "Loading plugins..."); + PluginManager.LoadPlugins(_settings!.PluginSettings); + Log.Info(ClassName, $"Loaded {PluginManager.AllPlugins.Count} plugins"); + + await PluginManager.InitializePluginsAsync(); + Log.Info(ClassName, "Plugins initialized"); + + _mainVM?.OnPluginsReady(); + } + catch (Exception e) { Log.Exception(ClassName, "Plugin init failed", e); } } } diff --git a/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs new file mode 100644 index 000000000..6b984dc69 --- /dev/null +++ b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedModels; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Avalonia.ViewModel; +using CommunityToolkit.Mvvm.DependencyInjection; + +namespace Flow.Launcher.Avalonia; + +/// +/// Minimal IPublicAPI for Avalonia - just enough for plugin queries to work. +/// +public class AvaloniaPublicAPI : IPublicAPI +{ + private readonly Settings _settings; + private readonly Func _getMainViewModel; + + public AvaloniaPublicAPI(Settings settings, Func getMainViewModel) + { + _settings = settings; + _getMainViewModel = getMainViewModel; + } + +#pragma warning disable CS0067 + public event VisibilityChangedEventHandler? VisibilityChanged; + public event ActualApplicationThemeChangedEventHandler? ActualApplicationThemeChanged; +#pragma warning restore CS0067 + + // Essential for plugins + public void ChangeQuery(string query, bool requery = false) => _getMainViewModel().QueryText = query; + public string GetTranslation(string key) => key; + public List GetAllPlugins() => PluginManager.AllPlugins; + public MatchResult FuzzySearch(string query, string stringToCompare) => + Ioc.Default.GetRequiredService().FuzzyMatch(query, stringToCompare); + + // Logging + public void LogDebug(string className, string message, [CallerMemberName] string methodName = "") => Log.Debug(className, message, methodName); + public void LogInfo(string className, string message, [CallerMemberName] string methodName = "") => Log.Info(className, message, methodName); + public void LogWarn(string className, string message, [CallerMemberName] string methodName = "") => Log.Warn(className, message, methodName); + public void LogError(string className, string message, [CallerMemberName] string methodName = "") => Log.Error(className, message, methodName); + public void LogException(string className, string message, Exception e, [CallerMemberName] string methodName = "") => Log.Exception(className, message, e, methodName); + + // Shell/URL operations + public void ShellRun(string cmd, string filename = "cmd.exe") => + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = filename, Arguments = $"/c {cmd}", UseShellExecute = true }); + public void OpenUrl(string url, bool? inPrivate = null) => + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = url, UseShellExecute = true }); + public void OpenUrl(Uri url, bool? inPrivate = null) => OpenUrl(url.ToString(), inPrivate); + public void OpenWebUrl(string url, bool? inPrivate = null) => OpenUrl(url, inPrivate); + public void OpenWebUrl(Uri url, bool? inPrivate = null) => OpenUrl(url.ToString(), inPrivate); + public void OpenAppUri(Uri appUri) => OpenUrl(appUri); + public void OpenAppUri(string appUri) => OpenUrl(appUri); + public void OpenDirectory(string DirectoryPath, string? FileNameOrFilePath = null) => + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = DirectoryPath, UseShellExecute = true }); + + // Clipboard + public void CopyToClipboard(string text, bool directCopy = false, bool showDefaultNotification = true) + { + if (global::Avalonia.Application.Current?.ApplicationLifetime is global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow?.Clipboard?.SetTextAsync(text); + } + + // HTTP (delegate to Infrastructure) + public Task HttpGetStringAsync(string url, CancellationToken token = default) => Infrastructure.Http.Http.GetAsync(url, token); + public Task HttpGetStreamAsync(string url, CancellationToken token = default) => Infrastructure.Http.Http.GetStreamAsync(url, token); + public Task HttpDownloadAsync(string url, string filePath, Action? reportProgress = null, CancellationToken token = default) => + Infrastructure.Http.Http.DownloadAsync(url, filePath, reportProgress, token); + + // Plugin management + public void AddActionKeyword(string pluginId, string newActionKeyword) => PluginManager.AddActionKeyword(pluginId, newActionKeyword); + public void RemoveActionKeyword(string pluginId, string oldActionKeyword) => PluginManager.RemoveActionKeyword(pluginId, oldActionKeyword); + public bool ActionKeywordAssigned(string actionKeyword) => PluginManager.ActionKeywordRegistered(actionKeyword); + public bool PluginModified(string id) => PluginManager.PluginModified(id); + + // Paths + public string GetDataDirectory() => DataLocation.DataDirectory(); + public string GetLogDirectory() => Log.CurrentLogDirectory; + + // Stubs - not critical for basic queries + public void RestartApp() { } + public void SaveAppAllSettings() { } + public void SavePluginSettings() { } + public Task ReloadAllPluginData() => Task.CompletedTask; + public void CheckForNewUpdate() { } + public void ShowMsgError(string title, string subTitle = "") => LogError("API", $"{title}: {subTitle}"); + public void ShowMsgErrorWithButton(string title, string buttonText, Action buttonAction, string subTitle = "") { } + public void ShowMainWindow() { } + public void FocusQueryTextBox() { } + public void HideMainWindow() => _getMainViewModel()?.RequestHide(); + public bool IsMainWindowVisible() => true; + public void ShowMsg(string title, string subTitle = "", string iconPath = "") { } + public void ShowMsg(string title, string subTitle, string iconPath, bool useMainWindowAsOwner = true) { } + public void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle = "", string iconPath = "") { } + public void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle, string iconPath, bool useMainWindowAsOwner = true) { } + public void OpenSettingDialog() { } + public void RegisterGlobalKeyboardCallback(Func callback) { } + public void RemoveGlobalKeyboardCallback(Func callback) { } + public T LoadSettingJsonStorage() where T : new() => new T(); + public void SaveSettingJsonStorage() where T : new() { } + public void ToggleGameMode() { } + public void SetGameMode(bool value) { } + public bool IsGameModeOn() => false; + public void ReQuery(bool reselect = true) { var q = _getMainViewModel().QueryText; _getMainViewModel().QueryText = ""; _getMainViewModel().QueryText = q; } + public void BackToQueryResults() { } + public MessageBoxResult ShowMsgBox(string messageBoxText, string caption = "", MessageBoxButton button = MessageBoxButton.OK, MessageBoxImage icon = MessageBoxImage.None, MessageBoxResult defaultResult = MessageBoxResult.OK) => defaultResult; + public Task ShowProgressBoxAsync(string caption, Func, Task> reportProgressAsync, Action? cancelProgress = null) => reportProgressAsync(_ => { }); + public void StartLoadingBar() => _getMainViewModel().IsQueryRunning = true; + public void StopLoadingBar() => _getMainViewModel().IsQueryRunning = false; + public List GetAvailableThemes() => new(); + public ThemeData? GetCurrentTheme() => null; + public bool SetCurrentTheme(ThemeData theme) => false; + public void SavePluginCaches() { } + public Task LoadCacheBinaryStorageAsync(string cacheName, string cacheDirectory, T defaultData) where T : new() => Task.FromResult(defaultData); + public Task SaveCacheBinaryStorageAsync(string cacheName, string cacheDirectory) where T : new() => Task.CompletedTask; + public ValueTask LoadImageAsync(string path, bool loadFullImage = false, bool cacheImage = true) => new((ImageSource)null!); + public Task UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default) => Task.FromResult(true); + public IReadOnlyList GetPluginManifest() => new List(); + public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath) => Task.FromResult(false); + public bool InstallPlugin(UserPlugin plugin, string zipFilePath) => false; + public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false) => Task.FromResult(false); + public bool IsApplicationDarkTheme() => true; + + public long StopwatchLogDebug(string className, string message, Action action, [CallerMemberName] string methodName = "") + { var sw = System.Diagnostics.Stopwatch.StartNew(); action(); sw.Stop(); LogDebug(className, $"{message}: {sw.ElapsedMilliseconds}ms", methodName); return sw.ElapsedMilliseconds; } + public async Task StopwatchLogDebugAsync(string className, string message, Func action, [CallerMemberName] string methodName = "") + { var sw = System.Diagnostics.Stopwatch.StartNew(); await action(); sw.Stop(); LogDebug(className, $"{message}: {sw.ElapsedMilliseconds}ms", methodName); return sw.ElapsedMilliseconds; } + public long StopwatchLogInfo(string className, string message, Action action, [CallerMemberName] string methodName = "") + { var sw = System.Diagnostics.Stopwatch.StartNew(); action(); sw.Stop(); LogInfo(className, $"{message}: {sw.ElapsedMilliseconds}ms", methodName); return sw.ElapsedMilliseconds; } + public async Task StopwatchLogInfoAsync(string className, string message, Func action, [CallerMemberName] string methodName = "") + { var sw = System.Diagnostics.Stopwatch.StartNew(); await action(); sw.Stop(); LogInfo(className, $"{message}: {sw.ElapsedMilliseconds}ms", methodName); return sw.ElapsedMilliseconds; } +} diff --git a/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj index 99b50426f..5972fd030 100644 --- a/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj +++ b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj @@ -49,6 +49,11 @@ + + + + + diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml b/Flow.Launcher.Avalonia/MainWindow.axaml index 8904a0238..42e18db4d 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml +++ b/Flow.Launcher.Avalonia/MainWindow.axaml @@ -31,16 +31,9 @@ - - - - - - - @@ -52,14 +45,6 @@ - - - - - diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml.cs b/Flow.Launcher.Avalonia/MainWindow.axaml.cs index 8723174c3..b2c0700d9 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml.cs +++ b/Flow.Launcher.Avalonia/MainWindow.axaml.cs @@ -22,6 +22,7 @@ public partial class MainWindow : Window // Create and set the ViewModel var settings = Ioc.Default.GetRequiredService(); _viewModel = new MainViewModel(settings); + _viewModel.HideRequested += () => Hide(); DataContext = _viewModel; // Get reference to the query text box diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs index 94c52cc4b..24a15209b 100644 --- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -1,27 +1,32 @@ using System; -using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; -using System.Windows.Input; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Avalonia.ViewModel; /// -/// Simplified MainViewModel for the Avalonia version. -/// This will eventually be unified with the WPF MainViewModel. +/// MainViewModel for Avalonia - minimal implementation for plugin queries. /// public partial class MainViewModel : ObservableObject { + private static readonly string ClassName = nameof(MainViewModel); private readonly Settings _settings; + private CancellationTokenSource? _queryTokenSource; + private bool _pluginsReady; + + public event Action? HideRequested; [ObservableProperty] private string _queryText = string.Empty; - [ObservableProperty] - private string _querySuggestionText = string.Empty; - [ObservableProperty] private bool _isQueryRunning; @@ -37,115 +42,107 @@ public partial class MainViewModel : ObservableObject { _settings = settings; _results = new ResultsViewModel(settings); - - // Add some demo results for testing - AddDemoResults(); } - partial void OnQueryTextChanged(string value) + public void OnPluginsReady() { - // Simulate query execution - if (!string.IsNullOrWhiteSpace(value)) - { - IsQueryRunning = true; - HasResults = true; - - // Simulate search - Task.Delay(100).ContinueWith(_ => - { - IsQueryRunning = false; - }, TaskScheduler.FromCurrentSynchronizationContext()); - } - else + _pluginsReady = true; + Log.Info(ClassName, "Plugins ready"); + if (!string.IsNullOrWhiteSpace(QueryText)) + _ = QueryAsync(); + } + + public void RequestHide() => HideRequested?.Invoke(); + + partial void OnQueryTextChanged(string value) => _ = QueryAsync(); + + private async Task QueryAsync() + { + _queryTokenSource?.Cancel(); + _queryTokenSource = new CancellationTokenSource(); + var token = _queryTokenSource.Token; + var queryText = QueryText.Trim(); + + if (string.IsNullOrWhiteSpace(queryText) || !_pluginsReady) { + Results.Clear(); HasResults = false; IsQueryRunning = false; + return; } - } - private void AddDemoResults() - { - // Add demo results for UI testing - Results.AddResult(new ResultViewModel - { - Title = "Welcome to Flow Launcher (Avalonia)", - SubTitle = "This is a demo result - Avalonia migration in progress", - IconPath = "Images/app.png" - }); - - Results.AddResult(new ResultViewModel - { - Title = "Settings", - SubTitle = "Open Flow Launcher settings", - IconPath = "Images/app.png" - }); - - Results.AddResult(new ResultViewModel - { - Title = "Notepad", - SubTitle = "C:\\Windows\\System32\\notepad.exe", - IconPath = "Images/app.png" - }); + IsQueryRunning = true; - Results.AddResult(new ResultViewModel + try { - Title = "Calculator", - SubTitle = "Microsoft Calculator", - IconPath = "Images/app.png" - }); + var query = QueryBuilder.Build(queryText, PluginManager.NonGlobalPlugins); + if (query == null) { Results.Clear(); HasResults = false; return; } - HasResults = true; - } + var plugins = PluginManager.ValidPluginsForQuery(query, dialogJump: false) + .Where(p => !p.Metadata.Disabled).ToList(); - [RelayCommand] - private void Esc() - { - QueryText = string.Empty; - } + if (plugins.Count == 0) { Results.Clear(); HasResults = false; return; } - [RelayCommand] - private void OpenResult(object? parameter) - { - var selectedResult = Results.SelectedItem; - if (selectedResult != null) - { - // Execute the result action - System.Diagnostics.Debug.WriteLine($"Opening result: {selectedResult.Title}"); + Results.Clear(); + + var tasks = plugins.Select(p => QueryPluginAsync(p, query, token)); + await Task.WhenAll(tasks); + + if (!token.IsCancellationRequested) + HasResults = Results.Results.Count > 0; } + catch (OperationCanceledException) { } + catch (Exception e) { Log.Exception(ClassName, "Query error", e); } + finally { if (!token.IsCancellationRequested) IsQueryRunning = false; } } - [RelayCommand] - private void SelectNextItem() + private async Task QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token) { - Results.SelectNextItem(); - } - - [RelayCommand] - private void SelectPrevItem() - { - Results.SelectPrevItem(); - } - - [RelayCommand] - private void AutocompleteQuery() - { - if (Results.SelectedItem != null) + try { - QueryText = Results.SelectedItem.Title; + var delay = plugin.Metadata.SearchDelayTime ?? _settings.SearchDelayTime; + if (delay > 0) await Task.Delay(delay, token); + if (token.IsCancellationRequested) return; + + var results = await PluginManager.QueryForPluginAsync(plugin, query, token); + if (token.IsCancellationRequested || results == null || results.Count == 0) return; + + await global::Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + foreach (var r in results.OrderByDescending(r => r.Score).Take(_settings.MaxResultsToShow)) + { + if (token.IsCancellationRequested) return; + Results.AddResult(new ResultViewModel + { + Title = r.Title ?? "", + SubTitle = r.SubTitle ?? "", + IconPath = r.IcoPath ?? plugin.Metadata.IcoPath ?? "", + PluginResult = r + }); + } + HasResults = Results.Results.Count > 0; + }); } + catch (OperationCanceledException) { } + catch (Exception e) { Log.Exception(ClassName, $"Plugin {plugin.Metadata.Name} error", e); } } [RelayCommand] - private void ReloadPluginData() - { - // Placeholder for plugin data reload - System.Diagnostics.Debug.WriteLine("Reloading plugin data..."); - } + private void Esc() { QueryText = ""; HideRequested?.Invoke(); } [RelayCommand] - private void ReQuery() + private async Task OpenResultAsync() { - // Placeholder for re-query - System.Diagnostics.Debug.WriteLine("Re-querying..."); + var result = Results.SelectedItem?.PluginResult; + if (result == null) return; + try + { + if (await result.ExecuteAsync(new ActionContext { SpecialKeyState = SpecialKeyState.Default })) + HideRequested?.Invoke(); + } + catch (Exception e) { Log.Exception(ClassName, "Execute error", e); } } + + [RelayCommand] private void SelectNextItem() => Results.SelectNextItem(); + [RelayCommand] private void SelectPrevItem() => Results.SelectPrevItem(); } diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs index a34368308..324f317a1 100644 --- a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Avalonia.ViewModel; @@ -23,8 +24,18 @@ public partial class ResultViewModel : ObservableObject [ObservableProperty] private Settings? _settings; + /// + /// The underlying plugin result. Used for executing actions and accessing additional properties. + /// + public Result? PluginResult { get; set; } + // Computed properties for display public bool ShowIcon => !string.IsNullOrEmpty(IconPath); public bool ShowSubTitle => !string.IsNullOrEmpty(SubTitle); + + /// + /// Gets the query suggestion text for autocomplete, if available. + /// + public string? QuerySuggestionText => PluginResult?.AutoCompleteText; }