diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 78464ddc9ba..56abfb251e1 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -30,7 +30,7 @@ public static class PluginManager private static readonly ConcurrentDictionary _allInitializedPlugins = []; private static readonly ConcurrentDictionary _initFailedPlugins = []; private static readonly ConcurrentDictionary _globalPlugins = []; - private static readonly ConcurrentDictionary _nonGlobalPlugins = []; + private static readonly ConcurrentDictionary> _nonGlobalPlugins = []; private static PluginsSettings Settings; private static readonly ConcurrentBag ModifiedPlugins = []; @@ -333,7 +333,19 @@ private static void RegisterPluginActionKeywords(PluginPair pair) _globalPlugins.TryAdd(pair.Metadata.ID, pair); break; default: - _nonGlobalPlugins.TryAdd(actionKeyword, pair); + _nonGlobalPlugins.AddOrUpdate(actionKeyword, + _ => [pair], + (_, existing) => + { + lock (existing) + { + if (!existing.Contains(pair)) + { + existing.Add(pair); + } + } + return existing; + }); break; } } @@ -369,7 +381,7 @@ public static ICollection ValidPluginsForQuery(Query query, bool dia if (query is null) return Array.Empty(); - if (!_nonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin)) + if (!TryGetNonGlobalPlugins(query.ActionKeyword, out var plugins)) { if (dialogJump) return [.. GetGlobalPlugins().Where(p => p.Plugin is IAsyncDialogJump && !PluginModified(p.Metadata.ID))]; @@ -377,13 +389,25 @@ public static ICollection ValidPluginsForQuery(Query query, bool dia return [.. GetGlobalPlugins().Where(p => !PluginModified(p.Metadata.ID))]; } - if (dialogJump && plugin.Plugin is not IAsyncDialogJump) - return Array.Empty(); + var validPlugins = plugins.Where(p => !p.Metadata.Disabled && !PluginModified(p.Metadata.ID)); + if (dialogJump) + validPlugins = validPlugins.Where(p => p.Plugin is IAsyncDialogJump); - if (PluginModified(plugin.Metadata.ID)) - return Array.Empty(); + return [.. validPlugins]; + } - return [plugin]; + private static bool TryGetNonGlobalPlugins(string actionKeyword, out List plugins) + { + if (_nonGlobalPlugins.TryGetValue(actionKeyword, out var list)) + { + lock (list) + { + plugins = [.. list]; + } + return true; + } + plugins = []; + return false; } public static ICollection ValidPluginsForHomeQuery() @@ -577,9 +601,17 @@ private static List GetGlobalPlugins() return [.. _globalPlugins.Values]; } - public static Dictionary GetNonGlobalPlugins() + public static Dictionary> GetNonGlobalPlugins() { - return _nonGlobalPlugins.ToDictionary(); + var nonGlobalPlugins = new Dictionary>(); + foreach (var kvp in _nonGlobalPlugins) + { + lock (kvp.Value) + { + nonGlobalPlugins.Add(kvp.Key, [.. kvp.Value]); + } + } + return nonGlobalPlugins; } public static List GetTranslationPlugins() @@ -722,12 +754,12 @@ public static bool IsInitializationFailed(string id) #region Plugin Action Keyword + [Obsolete("This method is only used for old Flow compatibility.")] public static bool ActionKeywordRegistered(string actionKeyword) { - // this method is only checking for action keywords (defined as not '*') registration - // hence the actionKeyword != Query.GlobalPluginWildcardSign logic - return actionKeyword != Query.GlobalPluginWildcardSign - && _nonGlobalPlugins.ContainsKey(actionKeyword); + // Since now we support to assign one action keyword to multiple plugins, + // this check is unnecessary, so we will just return false here to ensure compatibility for old plugins. + return false; } /// @@ -737,17 +769,34 @@ public static bool ActionKeywordRegistered(string actionKeyword) public static void AddActionKeyword(string id, string newActionKeyword) { var plugin = GetPluginForId(id); + if (plugin == null) return; + if (newActionKeyword == Query.GlobalPluginWildcardSign) { _globalPlugins.TryAdd(id, plugin); } else { - _nonGlobalPlugins.AddOrUpdate(newActionKeyword, plugin, (key, oldValue) => plugin); + _nonGlobalPlugins.AddOrUpdate(newActionKeyword, + _ => [plugin], + (_, existing) => + { + lock (existing) + { + if (!existing.Contains(plugin)) + { + existing.Add(plugin); + } + } + return existing; + }); } // Update action keywords and action keyword in plugin metadata - plugin.Metadata.ActionKeywords.Add(newActionKeyword); + if (!plugin.Metadata.ActionKeywords.Contains(newActionKeyword)) + { + plugin.Metadata.ActionKeywords.Add(newActionKeyword); + } if (plugin.Metadata.ActionKeywords.Count > 0) { plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0]; @@ -765,6 +814,8 @@ public static void AddActionKeyword(string id, string newActionKeyword) public static void RemoveActionKeyword(string id, string oldActionkeyword) { var plugin = GetPluginForId(id); + if (plugin == null) return; + if (oldActionkeyword == Query.GlobalPluginWildcardSign && // Plugins may have multiple ActionKeywords that are global, eg. WebSearch plugin.Metadata.ActionKeywords @@ -775,11 +826,22 @@ public static void RemoveActionKeyword(string id, string oldActionkeyword) if (oldActionkeyword != Query.GlobalPluginWildcardSign) { - _nonGlobalPlugins.TryRemove(oldActionkeyword, out _); + if (_nonGlobalPlugins.TryGetValue(oldActionkeyword, out var plugins)) + { + lock (plugins) + { + plugins.RemoveAll(p => p.Metadata.ID == id); + + if (plugins.Count == 0) + { + _nonGlobalPlugins.TryRemove(new KeyValuePair>(oldActionkeyword, plugins)); + } + } + } } // Update action keywords and action keyword in plugin metadata - plugin.Metadata.ActionKeywords.Remove(oldActionkeyword); + plugin.Metadata.ActionKeywords.RemoveAll(k => k == oldActionkeyword); if (plugin.Metadata.ActionKeywords.Count > 0) { plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0]; @@ -1063,10 +1125,18 @@ internal static async Task UninstallPluginAsync(PluginMetadata plugin, boo { _globalPlugins.TryRemove(plugin.ID, out var _); } - var keysToRemove = _nonGlobalPlugins.Where(p => p.Value.Metadata.ID == plugin.ID).Select(p => p.Key).ToList(); - foreach (var key in keysToRemove) + var entriesToUpdate = _nonGlobalPlugins.ToList(); + foreach (var entry in entriesToUpdate) { - _nonGlobalPlugins.TryRemove(key, out var _); + lock (entry.Value) + { + entry.Value.RemoveAll(p => p.Metadata.ID == plugin.ID); + + if (entry.Value.Count == 0) + { + _nonGlobalPlugins.TryRemove(new KeyValuePair>(entry.Key, entry.Value)); + } + } } } diff --git a/Flow.Launcher.Core/Plugin/QueryBuilder.cs b/Flow.Launcher.Core/Plugin/QueryBuilder.cs index aac620cce64..c9b8b134134 100644 --- a/Flow.Launcher.Core/Plugin/QueryBuilder.cs +++ b/Flow.Launcher.Core/Plugin/QueryBuilder.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Generic; +using System.Linq; using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.Plugin { public static class QueryBuilder { - public static Query Build(string originalQuery, string trimmedQuery, Dictionary nonGlobalPlugins) + public static Query Build(string originalQuery, string trimmedQuery, Dictionary> nonGlobalPlugins) { // home query if (string.IsNullOrEmpty(trimmedQuery)) @@ -34,7 +35,7 @@ public static Query Build(string originalQuery, string trimmedQuery, Dictionary< string possibleActionKeyword = terms[0]; string[] searchTerms; - if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPair) && !pluginPair.Metadata.Disabled) + if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPairs) && HasAnyEnabledPlugin(pluginPairs)) { // use non global plugin for query actionKeyword = possibleActionKeyword; @@ -59,5 +60,13 @@ public static Query Build(string originalQuery, string trimmedQuery, Dictionary< IsHomeQuery = false }; } + + private static bool HasAnyEnabledPlugin(List pluginPairs) + { + lock (pluginPairs) + { + return pluginPairs.Any(plugin => !plugin.Metadata.Disabled); + } + } } } diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index 62ed58930e5..304f4d5c00e 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -302,6 +302,11 @@ public interface IPublicAPI /// /// The actionkeyword for checking /// True if the actionkeyword is already assigned, False otherwise + /// + /// Flow now supports assigning one action keyword to multiple plugins. + /// This method is kept only for legacy Flow compatibility. + /// + [Obsolete("Flow now supports assigning one action keyword to multiple plugins. This method always returns false for compatibility.")] bool ActionKeywordAssigned(string actionKeyword); /// diff --git a/Flow.Launcher.Test/QueryBuilderTest.cs b/Flow.Launcher.Test/QueryBuilderTest.cs index 0ede781f8ab..3a708ea5690 100644 --- a/Flow.Launcher.Test/QueryBuilderTest.cs +++ b/Flow.Launcher.Test/QueryBuilderTest.cs @@ -11,9 +11,9 @@ public class QueryBuilderTest [Test] public void ExclusivePluginQueryTest() { - var nonGlobalPlugins = new Dictionary + var nonGlobalPlugins = new Dictionary> { - {">", new PluginPair {Metadata = new PluginMetadata {ActionKeywords = new List {">"}}}} + { ">", new List(){ new() { Metadata = new PluginMetadata { ActionKeywords = [">"] } } } } }; Query q = QueryBuilder.Build("> ping google.com -n 20 -6", "> ping google.com -n 20 -6", nonGlobalPlugins); @@ -34,9 +34,9 @@ public void ExclusivePluginQueryTest() [Test] public void ExclusivePluginQueryIgnoreDisabledTest() { - var nonGlobalPlugins = new Dictionary + var nonGlobalPlugins = new Dictionary> { - {">", new PluginPair {Metadata = new PluginMetadata {ActionKeywords = new List {">"}, Disabled = true}}} + { ">", new List(){ new() { Metadata = new PluginMetadata { ActionKeywords = [">"], Disabled = true } } } } }; Query q = QueryBuilder.Build("> ping google.com -n 20 -6", "> ping google.com -n 20 -6", nonGlobalPlugins); @@ -48,10 +48,31 @@ public void ExclusivePluginQueryIgnoreDisabledTest() ClassicAssert.AreEqual("ping google.com -n 20 -6", q.SecondToEndSearch, "SecondToEndSearch should be trimmed of multiple whitespace characters"); } + [Test] + public void SharedKeywordOneDisabledPluginQueryTest() + { + var nonGlobalPlugins = new Dictionary> + { + { ">", new List() + { + new() { Metadata = new PluginMetadata { ActionKeywords = [">"], Disabled = true } }, + new() { Metadata = new PluginMetadata { ActionKeywords = [">"] } } + } + } + }; + + 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.TrimmedQuery); + ClassicAssert.AreEqual("ping google.com -n 20 -6", q.Search, "Search should not start with the ActionKeyword."); + ClassicAssert.AreEqual(">", q.ActionKeyword, "ActionKeyword should still match because an enabled plugin shares the keyword."); + ClassicAssert.AreEqual(5, q.SearchTerms.Length, "The length of SearchTerms should match."); + } + [Test] public void GenericPluginQueryTest() { - Query q = QueryBuilder.Build("file.txt file2 file3", "file.txt file2 file3", new Dictionary()); + Query q = QueryBuilder.Build("file.txt file2 file3", "file.txt file2 file3", []); ClassicAssert.AreEqual("file.txt file2 file3", q.Search); ClassicAssert.AreEqual("", q.ActionKeyword); diff --git a/Flow.Launcher/PublicAPIInstance.cs b/Flow.Launcher/PublicAPIInstance.cs index 25c6d788a85..d3b84b937c0 100644 --- a/Flow.Launcher/PublicAPIInstance.cs +++ b/Flow.Launcher/PublicAPIInstance.cs @@ -292,7 +292,9 @@ public Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, A public void AddActionKeyword(string pluginId, string newActionKeyword) => PluginManager.AddActionKeyword(pluginId, newActionKeyword); +#pragma warning disable CS0618 // Type or member is obsolete public bool ActionKeywordAssigned(string actionKeyword) => PluginManager.ActionKeywordRegistered(actionKeyword); +#pragma warning restore CS0618 // Type or member is obsolete public void RemoveActionKeyword(string pluginId, string oldActionKeyword) => PluginManager.RemoveActionKeyword(pluginId, oldActionKeyword); diff --git a/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs b/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs index 503d82cc30a..631d3daee49 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs @@ -24,7 +24,8 @@ private static List QueryResults(Query query = null) var results = from keyword in nonGlobalPlugins.Keys - let plugin = nonGlobalPlugins[keyword].Metadata + from pluginPair in nonGlobalPlugins[keyword] + let plugin = pluginPair.Metadata let keywordSearchResult = Context.API.FuzzySearch(querySearch, keyword) let searchResult = keywordSearchResult.IsSearchPrecisionScoreMet() ? keywordSearchResult : Context.API.FuzzySearch(querySearch, plugin.Name) let score = searchResult.Score @@ -47,9 +48,9 @@ from keyword in nonGlobalPlugins.Keys return [.. results]; } - private static Dictionary GetNonGlobalPlugins() + private static Dictionary> GetNonGlobalPlugins() { - var nonGlobalPlugins = new Dictionary(); + var nonGlobalPlugins = new Dictionary>(); foreach (var plugin in Context.API.GetAllPlugins()) { foreach (var actionKeyword in plugin.Metadata.ActionKeywords) @@ -57,10 +58,17 @@ private static Dictionary GetNonGlobalPlugins() // Skip global keywords if (actionKeyword == Plugin.Query.GlobalPluginWildcardSign) continue; - // Skip dulpicated keywords - if (nonGlobalPlugins.ContainsKey(actionKeyword)) continue; + // See if we already assigned plugins to this keyword + if (!nonGlobalPlugins.TryGetValue(actionKeyword, out var pluginsForKeyword)) + { + pluginsForKeyword = []; + nonGlobalPlugins[actionKeyword] = pluginsForKeyword; + } - nonGlobalPlugins.Add(actionKeyword, plugin); + // We allow the same keyword to have multiple different plugins and + // there is no need to check for the same plugin having the same keyword multiple times, + // as plugin manager and UI should prevent this - we can still display this state regardless + pluginsForKeyword.Add(plugin); } } return nonGlobalPlugins;