Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
39b2869
Deprecate ActionKeywordRegistered, update API docs
Jack251970 Feb 26, 2026
f05d31a
Add ActionKeywordAssigned method and new using directives
Jack251970 Feb 26, 2026
b8bc7fa
Support multiple plugins per action keyword
Jack251970 Feb 26, 2026
8760fe2
Return deep copy in GetNonGlobalPlugins to protect state
Jack251970 Feb 26, 2026
452e60f
Use GetNonGlobalPlugins() instead of field access
Jack251970 Feb 26, 2026
3b771d1
Add locking for thread safety in GetNonGlobalPlugins
Jack251970 Feb 26, 2026
1294d72
Improve action keyword management in plugin metadata
Jack251970 Feb 26, 2026
43a74ba
Fix code comments
Jack251970 Feb 26, 2026
2529efe
Filter out disabled plugins from valid plugin list
Jack251970 Feb 26, 2026
4537013
Clean up empty plugin lists from _nonGlobalPlugins
Jack251970 Feb 26, 2026
17d675e
Update QueryBuilder tests for new nonGlobalPlugins type
Jack251970 Feb 26, 2026
211eb8a
Refactor plugin enabled check with thread safety
Jack251970 Feb 26, 2026
a90c787
Improve obsolete description
Jack251970 Feb 26, 2026
e103d64
Update test to mark exclusive plugin as disabled
Jack251970 Feb 26, 2026
baa3a69
Fix potential race condition in the uninstall logic
Jack251970 Feb 26, 2026
88ac19a
Refactor non-global plugin retrieval for performance optimization
Jack251970 Mar 1, 2026
f10f716
Fix logic inversion bug
Jack251970 Mar 1, 2026
0dd1350
Merge branch 'dev' into action_keyword
Jack251970 May 10, 2026
04a5416
Merge branch 'dev' into action_keyword
Jack251970 Jun 10, 2026
cec3489
Fix Plugin Indicator to show all plugins per shared keyword and add s…
Copilot Jun 10, 2026
a42ec6b
Rename CheckPlugin to HasAnyEnabledPlugin in QueryBuilder
DavidGBrett Jun 10, 2026
0865fa6
Improve variable names and comments in GetNonGlobalPlugins of Plugin …
DavidGBrett Jun 10, 2026
5400502
Merge pull request #4295 from Flow-Launcher/action_keyword
Jack251970 Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 91 additions & 21 deletions Flow.Launcher.Core/Plugin/PluginManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public static class PluginManager
private static readonly ConcurrentDictionary<string, PluginPair> _allInitializedPlugins = [];
private static readonly ConcurrentDictionary<string, PluginPair> _initFailedPlugins = [];
private static readonly ConcurrentDictionary<string, PluginPair> _globalPlugins = [];
private static readonly ConcurrentDictionary<string, PluginPair> _nonGlobalPlugins = [];
private static readonly ConcurrentDictionary<string, List<PluginPair>> _nonGlobalPlugins = [];

private static PluginsSettings Settings;
private static readonly ConcurrentBag<string> ModifiedPlugins = [];
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -369,21 +381,33 @@ public static ICollection<PluginPair> ValidPluginsForQuery(Query query, bool dia
if (query is null)
return Array.Empty<PluginPair>();

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))];
else
return [.. GetGlobalPlugins().Where(p => !PluginModified(p.Metadata.ID))];
}

if (dialogJump && plugin.Plugin is not IAsyncDialogJump)
return Array.Empty<PluginPair>();
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<PluginPair>();
return [.. validPlugins];
}

return [plugin];
private static bool TryGetNonGlobalPlugins(string actionKeyword, out List<PluginPair> plugins)
{
if (_nonGlobalPlugins.TryGetValue(actionKeyword, out var list))
{
lock (list)
{
plugins = [.. list];
}
return true;
}
plugins = [];
return false;
}

public static ICollection<PluginPair> ValidPluginsForHomeQuery()
Expand Down Expand Up @@ -577,9 +601,17 @@ private static List<PluginPair> GetGlobalPlugins()
return [.. _globalPlugins.Values];
}

public static Dictionary<string, PluginPair> GetNonGlobalPlugins()
public static Dictionary<string, List<PluginPair>> GetNonGlobalPlugins()
{
return _nonGlobalPlugins.ToDictionary();
var nonGlobalPlugins = new Dictionary<string, List<PluginPair>>();
foreach (var kvp in _nonGlobalPlugins)
{
lock (kvp.Value)
{
nonGlobalPlugins.Add(kvp.Key, [.. kvp.Value]);
}
}
return nonGlobalPlugins;
}

public static List<PluginPair> GetTranslationPlugins()
Expand Down Expand Up @@ -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;
}

/// <summary>
Expand All @@ -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];
Expand All @@ -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
Expand All @@ -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<string, List<PluginPair>>(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];
Expand Down Expand Up @@ -1063,10 +1125,18 @@ internal static async Task<bool> 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<string, List<PluginPair>>(entry.Key, entry.Value));
}
}
}
}

Expand Down
13 changes: 11 additions & 2 deletions Flow.Launcher.Core/Plugin/QueryBuilder.cs
Original file line number Diff line number Diff line change
@@ -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<string, PluginPair> nonGlobalPlugins)
public static Query Build(string originalQuery, string trimmedQuery, Dictionary<string, List<PluginPair>> nonGlobalPlugins)
{
// home query
if (string.IsNullOrEmpty(trimmedQuery))
Expand Down Expand Up @@ -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;
Expand All @@ -59,5 +60,13 @@ public static Query Build(string originalQuery, string trimmedQuery, Dictionary<
IsHomeQuery = false
};
}

private static bool HasAnyEnabledPlugin(List<PluginPair> pluginPairs)
{
lock (pluginPairs)
{
return pluginPairs.Any(plugin => !plugin.Metadata.Disabled);
}
}
}
}
5 changes: 5 additions & 0 deletions Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,11 @@ public interface IPublicAPI
/// </summary>
/// <param name="actionKeyword">The actionkeyword for checking</param>
/// <returns>True if the actionkeyword is already assigned, False otherwise</returns>
/// <remarks>
/// Flow now supports assigning one action keyword to multiple plugins.
/// This method is kept only for legacy Flow compatibility.
/// </remarks>
[Obsolete("Flow now supports assigning one action keyword to multiple plugins. This method always returns false for compatibility.")]
bool ActionKeywordAssigned(string actionKeyword);

/// <summary>
Expand Down
31 changes: 26 additions & 5 deletions Flow.Launcher.Test/QueryBuilderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ public class QueryBuilderTest
[Test]
public void ExclusivePluginQueryTest()
{
var nonGlobalPlugins = new Dictionary<string, PluginPair>
var nonGlobalPlugins = new Dictionary<string, List<PluginPair>>
{
{">", new PluginPair {Metadata = new PluginMetadata {ActionKeywords = new List<string> {">"}}}}
{ ">", new List<PluginPair>(){ new() { Metadata = new PluginMetadata { ActionKeywords = [">"] } } } }
};

Query q = QueryBuilder.Build("> ping google.com -n 20 -6", "> ping google.com -n 20 -6", nonGlobalPlugins);
Expand All @@ -34,9 +34,9 @@ public void ExclusivePluginQueryTest()
[Test]
public void ExclusivePluginQueryIgnoreDisabledTest()
{
var nonGlobalPlugins = new Dictionary<string, PluginPair>
var nonGlobalPlugins = new Dictionary<string, List<PluginPair>>
{
{">", new PluginPair {Metadata = new PluginMetadata {ActionKeywords = new List<string> {">"}, Disabled = true}}}
{ ">", new List<PluginPair>(){ new() { Metadata = new PluginMetadata { ActionKeywords = [">"], Disabled = true } } } }
};

Query q = QueryBuilder.Build("> ping google.com -n 20 -6", "> ping google.com -n 20 -6", nonGlobalPlugins);
Expand All @@ -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<string, List<PluginPair>>
{
{ ">", new List<PluginPair>()
{
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<string, PluginPair>());
Query q = QueryBuilder.Build("file.txt file2 file3", "file.txt file2 file3", []);

ClassicAssert.AreEqual("file.txt file2 file3", q.Search);
ClassicAssert.AreEqual("", q.ActionKeyword);
Expand Down
2 changes: 2 additions & 0 deletions Flow.Launcher/PublicAPIInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 14 additions & 6 deletions Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ private static List<Result> 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
Expand All @@ -47,20 +48,27 @@ from keyword in nonGlobalPlugins.Keys
return [.. results];
}

private static Dictionary<string, PluginPair> GetNonGlobalPlugins()
private static Dictionary<string, List<PluginPair>> GetNonGlobalPlugins()
{
var nonGlobalPlugins = new Dictionary<string, PluginPair>();
var nonGlobalPlugins = new Dictionary<string, List<PluginPair>>();
foreach (var plugin in Context.API.GetAllPlugins())
{
foreach (var actionKeyword in plugin.Metadata.ActionKeywords)
{
// 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;
Expand Down
Loading