From 24a0e815f01b85cee1f71ae2ece6f60426fd337d Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Sat, 14 Mar 2026 02:19:08 +0200 Subject: [PATCH 01/19] refactor: extract IItemRepository interface from ItemRepository Co-Authored-By: Claude Sonnet 4.6 --- .../Items/Abstract/IItemRepository.cs | 13 +++++++++++++ .../Items/ItemRepository.cs | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 Flow.Launcher.Plugin.SlickFlow/Items/Abstract/IItemRepository.cs diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/Abstract/IItemRepository.cs b/Flow.Launcher.Plugin.SlickFlow/Items/Abstract/IItemRepository.cs new file mode 100644 index 0000000..1377a21 --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Abstract/IItemRepository.cs @@ -0,0 +1,13 @@ +namespace Flow.Launcher.Plugin.SlickFlow.Items.Abstract; + +public interface IItemRepository +{ + string AddItem(Item item); + Item? GetItemById(string id); + List GetAllItems(); + void UpdateItem(Item item); + void DeleteItem(string id); + void AddAlias(string itemId, string alias); + void RemoveAlias(string itemId, string alias); + Item? GetItemByAlias(string alias); +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/ItemRepository.cs b/Flow.Launcher.Plugin.SlickFlow/Items/ItemRepository.cs index adbd6bf..806f497 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Items/ItemRepository.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Items/ItemRepository.cs @@ -1,12 +1,13 @@ using System.IO; using System.Text.Json; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.Items; /// /// Provides methods for managing a collection of objects, including loading, saving, and CRUD operations. /// -public class ItemRepository +public class ItemRepository : IItemRepository { private readonly string _path; private readonly List _items = new(); From 5e9cfc6c68a9fbee3e888f0af141ff6764c20ca0 Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Sat, 14 Mar 2026 02:21:50 +0200 Subject: [PATCH 02/19] refactor: decouple ItemValidator from SlickFlow plugin class --- .../Items/ItemValidator.cs | 20 ++++++++++--------- Flow.Launcher.Plugin.SlickFlow/Main.cs | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/ItemValidator.cs b/Flow.Launcher.Plugin.SlickFlow/Items/ItemValidator.cs index dc7bcec..d499e69 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Items/ItemValidator.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Items/ItemValidator.cs @@ -1,15 +1,17 @@ -using System.Collections.Generic; -using System.Linq; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.items; public class ItemValidator { - private readonly SlickFlow _plugin; + private readonly IItemRepository _itemRepo; + private readonly string _slickFlowIcon; - public ItemValidator(SlickFlow plugin) + public ItemValidator(IItemRepository itemRepo, string slickFlowIcon) { - _plugin = plugin; + _itemRepo = itemRepo; + _slickFlowIcon = slickFlowIcon; } public List ValidateAliases(List aliases) @@ -20,13 +22,13 @@ public List ValidateAliases(List aliases) results.Add(new Result { Title = "No valid aliases provided", - IcoPath = _plugin._slickFlowIcon, + IcoPath = _slickFlowIcon, Score = int.MaxValue - 1000 }); return results; } - var allItems = _plugin._itemRepo.GetAllItems(); + var allItems = _itemRepo.GetAllItems(); var existing = allItems.SelectMany(i => i.Aliases.Select(a => a.ToLowerInvariant())) .Intersect(aliases.Select(a => a.ToLowerInvariant())) .ToList() as List; @@ -36,7 +38,7 @@ public List ValidateAliases(List aliases) results.Add(new Result { Title = $"Alias already exists: {string.Join(", ", existing)}", - IcoPath = _plugin._slickFlowIcon, + IcoPath = _slickFlowIcon, Score = int.MaxValue - 1000 }); } @@ -52,4 +54,4 @@ public bool IsValidProperty(string prop) _ => false }; } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Main.cs b/Flow.Launcher.Plugin.SlickFlow/Main.cs index e84fea4..96843ae 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Main.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Main.cs @@ -48,7 +48,7 @@ public void Init(PluginInitContext context) _commandProcessor = new CommandProcessor(this); _itemSearcher = new ItemSearcher(); - _itemValidator = new ItemValidator(this); + _itemValidator = new ItemValidator(_itemRepo, _slickFlowIcon); _context.API.LogInfo("SlickFlow", "Plugin loaded successfully."); } From 05f76747f05140bbc035649bae7be8decba3479f Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Sat, 14 Mar 2026 02:24:46 +0200 Subject: [PATCH 03/19] refactor: decouple command handlers and processor from SlickFlow plugin class Co-Authored-By: Claude Sonnet 4.6 --- .../CommandHandlers/AddCommandHandler.cs | 22 +++++++++------ .../CommandHandlers/AliasCommandHandler.cs | 26 ++++++++++------- .../CommandHandlers/DeleteCommandHandler.cs | 21 ++++++++------ .../CommandHandlers/RemoveCommandHandler.cs | 25 ++++++++++------- .../CommandHandlers/SetIconCommandHandler.cs | 26 ++++++++++------- .../CommandHandlers/UpdateCommandHandler.cs | 28 +++++++++++-------- .../Commands/CommandProcessor.cs | 20 +++++++------ Flow.Launcher.Plugin.SlickFlow/Main.cs | 5 ++-- 8 files changed, 104 insertions(+), 69 deletions(-) diff --git a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/AddCommandHandler.cs b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/AddCommandHandler.cs index 43a5f5e..fe3a990 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/AddCommandHandler.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/AddCommandHandler.cs @@ -1,14 +1,20 @@ using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.items; namespace Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; public class AddCommandHandler : ICommandHandler { - private readonly SlickFlow _plugin; + private readonly IItemRepository _itemRepo; + private readonly ItemValidator _itemValidator; + private readonly string _slickFlowIcon; - public AddCommandHandler(SlickFlow plugin) + public AddCommandHandler(IItemRepository itemRepo, ItemValidator itemValidator, string slickFlowIcon) { - _plugin = plugin; + _itemRepo = itemRepo; + _itemValidator = itemValidator; + _slickFlowIcon = slickFlowIcon; } public List Handle(string[] args) @@ -22,7 +28,7 @@ public List Handle(string[] args) { Title = "Usage: add [args...] [runas]", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon + IcoPath = _slickFlowIcon }); return results; } @@ -60,7 +66,7 @@ public List Handle(string[] args) } // Prevent duplicate aliases - var validationResults = _plugin._itemValidator.ValidateAliases(aliases); + var validationResults = _itemValidator.ValidateAliases(aliases); if (validationResults.Any()) { return validationResults; @@ -72,7 +78,7 @@ public List Handle(string[] args) Title = $"Add item: {string.Join(", ", aliases)}", SubTitle = $"File: {fileOrUrl} {fileArgs}".Trim(), Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon, + IcoPath = _slickFlowIcon, Action = _ => { var item = new Item @@ -84,7 +90,7 @@ public List Handle(string[] args) }; // Add the item to the repository - _plugin._itemRepo.AddItem(item); + _itemRepo.AddItem(item); return true; } @@ -92,4 +98,4 @@ public List Handle(string[] args) return results; } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/AliasCommandHandler.cs b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/AliasCommandHandler.cs index dc9dd82..ac5d13a 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/AliasCommandHandler.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/AliasCommandHandler.cs @@ -1,14 +1,20 @@ using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.items; namespace Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; public class AliasCommandHandler : ICommandHandler { - private readonly SlickFlow _plugin; + private readonly IItemRepository _itemRepo; + private readonly ItemValidator _itemValidator; + private readonly string _slickFlowIcon; - public AliasCommandHandler(SlickFlow plugin) + public AliasCommandHandler(IItemRepository itemRepo, ItemValidator itemValidator, string slickFlowIcon) { - _plugin = plugin; + _itemRepo = itemRepo; + _itemValidator = itemValidator; + _slickFlowIcon = slickFlowIcon; } public List Handle(string[] args) @@ -21,17 +27,17 @@ public List Handle(string[] args) { Title = "Usage: alias ", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon + IcoPath = _slickFlowIcon }); return results; } string target = args[0]; - Item? item = _plugin._itemRepo.GetItemById(target) ?? _plugin._itemRepo.GetItemByAlias(target); + Item? item = _itemRepo.GetItemById(target) ?? _itemRepo.GetItemByAlias(target); if (item == null) { results.Add(new Result { Title = $"No item found with '{target}'", - IcoPath = _plugin._slickFlowIcon, Score = int.MaxValue - 1000 }); + IcoPath = _slickFlowIcon, Score = int.MaxValue - 1000 }); return results; } @@ -40,7 +46,7 @@ public List Handle(string[] args) .Where(a => !string.IsNullOrWhiteSpace(a)) .ToList(); - var validationResults = _plugin._itemValidator.ValidateAliases(newAliases); + var validationResults = _itemValidator.ValidateAliases(newAliases); if (validationResults.Any()) { return validationResults; @@ -51,7 +57,7 @@ public List Handle(string[] args) Title = $"Add {newAliases.Count} alias(es) to item {item.Id}", SubTitle = $"Existing aliases: {string.Join(", ", item.Aliases)}", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon, + IcoPath = _slickFlowIcon, Action = _ => { int addedCount = 0; @@ -65,7 +71,7 @@ public List Handle(string[] args) } if (addedCount > 0) - _plugin._itemRepo.UpdateItem(item); + _itemRepo.UpdateItem(item); return true; } @@ -73,4 +79,4 @@ public List Handle(string[] args) return results; } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/DeleteCommandHandler.cs b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/DeleteCommandHandler.cs index 2c35ffd..e75623e 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/DeleteCommandHandler.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/DeleteCommandHandler.cs @@ -1,14 +1,17 @@ using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; public class DeleteCommandHandler : ICommandHandler { - private readonly SlickFlow _plugin; + private readonly IItemRepository _itemRepo; + private readonly string _slickFlowIcon; - public DeleteCommandHandler(SlickFlow plugin) + public DeleteCommandHandler(IItemRepository itemRepo, string slickFlowIcon) { - _plugin = plugin; + _itemRepo = itemRepo; + _slickFlowIcon = slickFlowIcon; } public List Handle(string[] args) @@ -21,18 +24,18 @@ public List Handle(string[] args) { Title = "Usage: delete ", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon + IcoPath = _slickFlowIcon }); return results; } string target = args[0]; - Item? item = _plugin._itemRepo.GetItemById(target) ?? _plugin._itemRepo.GetItemByAlias(target); + Item? item = _itemRepo.GetItemById(target) ?? _itemRepo.GetItemByAlias(target); if (item == null) { - results.Add(new Result { Title = $"No item found with '{target}'", IcoPath = _plugin._slickFlowIcon, Score = int.MaxValue - 1000 }); + results.Add(new Result { Title = $"No item found with '{target}'", IcoPath = _slickFlowIcon, Score = int.MaxValue - 1000 }); return results; } @@ -42,10 +45,10 @@ public List Handle(string[] args) Title = $"Confirm delete of item {item.Id}?", Score = int.MaxValue - 1000, SubTitle = $"Aliases: {string.Join(", ", item.Aliases)}", - IcoPath = _plugin._slickFlowIcon, + IcoPath = _slickFlowIcon, Action = _ => { - _plugin._itemRepo.DeleteItem(item.Id); + _itemRepo.DeleteItem(item.Id); Console.WriteLine($"[Deleted] Item {item.Id} ({item.FileName})"); return true; } @@ -53,4 +56,4 @@ public List Handle(string[] args) return results; } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/RemoveCommandHandler.cs b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/RemoveCommandHandler.cs index 7123d08..b141eed 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/RemoveCommandHandler.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/RemoveCommandHandler.cs @@ -1,12 +1,17 @@ +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; + namespace Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; public class RemoveCommandHandler : ICommandHandler { - private readonly SlickFlow _plugin; + private readonly IItemRepository _itemRepo; + private readonly string _slickFlowIcon; - public RemoveCommandHandler(SlickFlow plugin) + public RemoveCommandHandler(IItemRepository itemRepo, string slickFlowIcon) { - _plugin = plugin; + _itemRepo = itemRepo; + _slickFlowIcon = slickFlowIcon; } public List Handle(string[] args) @@ -18,20 +23,20 @@ public List Handle(string[] args) { Title = "Usage: remove ", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon + IcoPath = _slickFlowIcon }); return results; } var alias = args[0]; - var item = _plugin._itemRepo.GetItemByAlias(alias); + var item = _itemRepo.GetItemByAlias(alias); if (item == null) { results.Add(new Result { Title = $"No item found with alias '{alias}'", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon + IcoPath = _slickFlowIcon }); return results; } @@ -42,7 +47,7 @@ public List Handle(string[] args) { Title = $"Item only has one alias. Use 'delete {alias}' to delete the item instead.", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon + IcoPath = _slickFlowIcon }); return results; } @@ -51,13 +56,13 @@ public List Handle(string[] args) { Title = $"Remove alias '{alias}' from item {item.Id}", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon, + IcoPath = _slickFlowIcon, Action = _ => { - _plugin._itemRepo.RemoveAlias(item.Id, alias); + _itemRepo.RemoveAlias(item.Id, alias); return true; } }); return results; } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/SetIconCommandHandler.cs b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/SetIconCommandHandler.cs index e4fcb66..3453f7e 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/SetIconCommandHandler.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/SetIconCommandHandler.cs @@ -1,14 +1,20 @@ using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.Utils; namespace Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; public class SetIconCommandHandler : ICommandHandler { - private readonly SlickFlow _plugin; + private readonly IItemRepository _itemRepo; + private readonly IconHelper _iconHelper; + private readonly string _slickFlowIcon; - public SetIconCommandHandler(SlickFlow plugin) + public SetIconCommandHandler(IItemRepository itemRepo, IconHelper iconHelper, string slickFlowIcon) { - _plugin = plugin; + _itemRepo = itemRepo; + _iconHelper = iconHelper; + _slickFlowIcon = slickFlowIcon; } public List Handle(string[] args) @@ -21,7 +27,7 @@ public List Handle(string[] args) { Title = "Usage: seticon ", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon + IcoPath = _slickFlowIcon }); return results; } @@ -29,11 +35,11 @@ public List Handle(string[] args) string target = args[0]; string iconSource = string.Join(' ', args.Skip(1)); - Item? item = _plugin._itemRepo.GetItemById(target) ?? _plugin._itemRepo.GetItemByAlias(target); + Item? item = _itemRepo.GetItemById(target) ?? _itemRepo.GetItemByAlias(target); if (item == null) { - results.Add(new Result { Title = $"No item found with '{target}'", IcoPath = _plugin._slickFlowIcon, Score = int.MaxValue - 1000 }); + results.Add(new Result { Title = $"No item found with '{target}'", IcoPath = _slickFlowIcon, Score = int.MaxValue - 1000 }); return results; } @@ -42,14 +48,14 @@ public List Handle(string[] args) Title = $"Set custom icon for item {item.Id}", SubTitle = $"Icon source: {iconSource}", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon, + IcoPath = _slickFlowIcon, Action = _ => { - string newIconPath = _plugin._iconHelper.SetCustomIcon(iconSource, item.Id); + string newIconPath = _iconHelper.SetCustomIcon(iconSource, item.Id); if (!string.IsNullOrEmpty(newIconPath)) { item.IconPath = newIconPath; - _plugin._itemRepo.UpdateItem(item); + _itemRepo.UpdateItem(item); return true; } return false; @@ -58,4 +64,4 @@ public List Handle(string[] args) return results; } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/UpdateCommandHandler.cs b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/UpdateCommandHandler.cs index 3378f85..cbf7176 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/UpdateCommandHandler.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/UpdateCommandHandler.cs @@ -1,14 +1,20 @@ using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.items; namespace Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; public class UpdateCommandHandler : ICommandHandler { - private readonly SlickFlow _plugin; + private readonly IItemRepository _itemRepo; + private readonly ItemValidator _itemValidator; + private readonly string _slickFlowIcon; - public UpdateCommandHandler(SlickFlow plugin) + public UpdateCommandHandler(IItemRepository itemRepo, ItemValidator itemValidator, string slickFlowIcon) { - _plugin = plugin; + _itemRepo = itemRepo; + _itemValidator = itemValidator; + _slickFlowIcon = slickFlowIcon; } public List Handle(string[] args) @@ -21,7 +27,7 @@ public List Handle(string[] args) { Score = int.MaxValue - 1000, Title = "Usage: update [property value] ...", - IcoPath = _plugin._slickFlowIcon + IcoPath = _slickFlowIcon }); return results; } @@ -29,12 +35,12 @@ public List Handle(string[] args) string target = args[0]; // Just fetch the item for preview, don't change it yet - Item? item = _plugin._itemRepo.GetItemById(target) ?? _plugin._itemRepo.GetItemByAlias(target); + Item? item = _itemRepo.GetItemById(target) ?? _itemRepo.GetItemByAlias(target); if (item == null) { results.Add(new Result { Title = $"No item found with '{target}'", - IcoPath = _plugin._slickFlowIcon, Score = int.MaxValue - 1000 }); + IcoPath = _slickFlowIcon, Score = int.MaxValue - 1000 }); return results; } @@ -47,13 +53,13 @@ public List Handle(string[] args) updates[prop] = val; } - var invalidProps = updates.Keys.Where(k => !_plugin._itemValidator.IsValidProperty(k)).ToList(); + var invalidProps = updates.Keys.Where(k => !_itemValidator.IsValidProperty(k)).ToList(); if (invalidProps.Any()) { results.Add(new Result { Title = $"Invalid properties: {string.Join(", ", invalidProps)}", - IcoPath = _plugin._slickFlowIcon, + IcoPath = _slickFlowIcon, Score = int.MaxValue - 1000 }); return results; @@ -64,7 +70,7 @@ public List Handle(string[] args) { Title = $"Update item {item.Id}", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon, + IcoPath = _slickFlowIcon, SubTitle = $"Properties to update: {string.Join(", ", updates.Select(kv => $"{kv.Key}={kv.Value}"))}", Action = _ => { @@ -101,11 +107,11 @@ public List Handle(string[] args) } } - _plugin._itemRepo.UpdateItem(item); + _itemRepo.UpdateItem(item); return true; } }); return results; } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandProcessor.cs b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandProcessor.cs index b6eba45..7f149ae 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandProcessor.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandProcessor.cs @@ -1,4 +1,8 @@ using Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.items; +using Flow.Launcher.Plugin.SlickFlow.Utils; namespace Flow.Launcher.Plugin.SlickFlow.Commands; @@ -6,16 +10,16 @@ public class CommandProcessor { private readonly Dictionary _handlers; - public CommandProcessor(SlickFlow plugin) + public CommandProcessor(IItemRepository itemRepo, ItemValidator itemValidator, IconHelper iconHelper, string slickFlowIcon) { _handlers = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["add"] = new AddCommandHandler(plugin), - ["alias"] = new AliasCommandHandler(plugin), - ["remove"] = new RemoveCommandHandler(plugin), - ["delete"] = new DeleteCommandHandler(plugin), - ["update"] = new UpdateCommandHandler(plugin), - ["seticon"] = new SetIconCommandHandler(plugin) + ["add"] = new AddCommandHandler(itemRepo, itemValidator, slickFlowIcon), + ["alias"] = new AliasCommandHandler(itemRepo, itemValidator, slickFlowIcon), + ["remove"] = new RemoveCommandHandler(itemRepo, slickFlowIcon), + ["delete"] = new DeleteCommandHandler(itemRepo, slickFlowIcon), + ["update"] = new UpdateCommandHandler(itemRepo, itemValidator, slickFlowIcon), + ["seticon"] = new SetIconCommandHandler(itemRepo, iconHelper, slickFlowIcon) }; } @@ -27,4 +31,4 @@ public List Process(string command, string[] args) } return new List(); } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Main.cs b/Flow.Launcher.Plugin.SlickFlow/Main.cs index 96843ae..0b47af0 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Main.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Main.cs @@ -45,10 +45,9 @@ public void Init(PluginInitContext context) Settings = SettingsManager.Load(); _iconHelper = new IconHelper(Settings.IconDirPath); _itemRepo = new ItemRepository(Settings.DbFilePath); - - _commandProcessor = new CommandProcessor(this); - _itemSearcher = new ItemSearcher(); _itemValidator = new ItemValidator(_itemRepo, _slickFlowIcon); + _commandProcessor = new CommandProcessor(_itemRepo, _itemValidator, _iconHelper, _slickFlowIcon); + _itemSearcher = new ItemSearcher(); _context.API.LogInfo("SlickFlow", "Plugin loaded successfully."); } From 4fd534968948c651449f613d3a98d3968596e722 Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Sat, 14 Mar 2026 02:26:00 +0200 Subject: [PATCH 04/19] refactor: add InternalsVisibleTo for test project --- .../Flow.Launcher.Plugin.SlickFlow.csproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj b/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj index be311b6..e010c99 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj +++ b/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj @@ -39,6 +39,9 @@ + + + Date: Sat, 14 Mar 2026 02:26:10 +0200 Subject: [PATCH 05/19] refactor: make SettingsManager path-injectable for testability Co-Authored-By: Claude Sonnet 4.6 --- .../Settings/SettingsManager.cs | 66 ++++++------------- 1 file changed, 19 insertions(+), 47 deletions(-) diff --git a/Flow.Launcher.Plugin.SlickFlow/Settings/SettingsManager.cs b/Flow.Launcher.Plugin.SlickFlow/Settings/SettingsManager.cs index 635f59e..b40b4f7 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Settings/SettingsManager.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Settings/SettingsManager.cs @@ -9,18 +9,12 @@ public static class SettingsManager { private const string SettingsFileName = "settings.json"; - /// - /// Returns the full path to settings.json based on assembly-relative default directory. - /// - private static string GetSettingsFilePath() + private static string GetSettingsFilePath(string? baseDirectory = null) { - var defaultDir = GetDefaultDirectory(); - return Path.Combine(defaultDir, SettingsFileName); + var dir = baseDirectory ?? GetDefaultDirectory(); + return Path.Combine(dir, SettingsFileName); } - /// - /// Returns the default SlickFlow settings directory relative to the assembly. - /// private static string GetDefaultDirectory() { var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) @@ -28,86 +22,64 @@ private static string GetDefaultDirectory() return Path.GetFullPath(Path.Combine(assemblyDir, "..", "..", "Settings", "SlickFlow")); } - /// - /// Loads settings.json or creates defaults if missing/corrupted. - /// - public static Settings Load() + public static Settings Load(string? baseDirectory = null) { - var path = GetSettingsFilePath(); + var path = GetSettingsFilePath(baseDirectory); try { if (!File.Exists(path)) - return CreateDefaultSettings(); + return CreateDefaultSettings(baseDirectory); var json = File.ReadAllText(path); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - var settings = JsonSerializer.Deserialize(json, options) ?? CreateDefaultSettings(); + var settings = JsonSerializer.Deserialize(json, options) ?? CreateDefaultSettings(baseDirectory); - ValidateAndFillDefaults(settings); + ValidateAndFillDefaults(settings, baseDirectory); return settings; } catch { - // Corrupted file or other IO issues - return CreateDefaultSettings(); + return CreateDefaultSettings(baseDirectory); } } - /// - /// Saves settings to settings.json. - /// - public static void Save(Settings settings) + public static void Save(Settings settings, string? baseDirectory = null) { - var path = GetSettingsFilePath(); - Directory.CreateDirectory(Path.GetDirectoryName(path)!); // ensure folder exists + var path = GetSettingsFilePath(baseDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); var options = new JsonSerializerOptions { WriteIndented = true }; File.WriteAllText(path, JsonSerializer.Serialize(settings, options)); } - #region Default Initialization - - /// - /// Creates default settings with default DB file and icon folder. - /// - private static Settings CreateDefaultSettings() + private static Settings CreateDefaultSettings(string? baseDirectory = null) { var defaults = new Settings(); - InitializeDefaultPaths(defaults); - Save(defaults); + InitializeDefaultPaths(defaults, baseDirectory); + Save(defaults, baseDirectory); return defaults; } - /// - /// Ensures DB file and icon folder exist, sets default paths if missing. - /// - private static void InitializeDefaultPaths(Settings settings) + private static void InitializeDefaultPaths(Settings settings, string? baseDirectory = null) { - var defaultDir = GetDefaultDirectory(); + var defaultDir = baseDirectory ?? GetDefaultDirectory(); Directory.CreateDirectory(defaultDir); - // DB file if (string.IsNullOrWhiteSpace(settings.DbFilePath)) settings.DbFilePath = Path.Combine(defaultDir, "SlickFlow.json"); if (!File.Exists(settings.DbFilePath)) File.WriteAllText(settings.DbFilePath, "{}"); - // Icon directory if (string.IsNullOrWhiteSpace(settings.IconDirPath)) settings.IconDirPath = Path.Combine(defaultDir, "icons"); Directory.CreateDirectory(settings.IconDirPath); } - /// - /// Validates settings and fills in only missing paths, preserving user-set values. - /// - private static void ValidateAndFillDefaults(Settings settings) + private static void ValidateAndFillDefaults(Settings settings, string? baseDirectory = null) { - InitializeDefaultPaths(settings); + InitializeDefaultPaths(settings, baseDirectory); } - - #endregion } } From 76c0a8edfddbf5da86c49e6c4472b627dc7c70e8 Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Sat, 14 Mar 2026 02:33:49 +0200 Subject: [PATCH 06/19] chore: create xUnit test project with Moq and FluentAssertions Also add ContinueOnError=WarnAndContinue to post-build copy tasks so builds succeed when the FlowLauncher DLL is locked by the running host process. Co-Authored-By: Claude Sonnet 4.6 --- .../Flow.Launcher.Plugin.SlickFlow.csproj | 26 +++++++++-------- SlickFlow.Tests/SlickFlow.Tests.csproj | 28 +++++++++++++++++++ SlickFlow.sln | 27 ++++++++++++++++++ 3 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 SlickFlow.Tests/SlickFlow.Tests.csproj diff --git a/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj b/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj index e010c99..a0b46ec 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj +++ b/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj @@ -44,19 +44,23 @@ - + - + - + - + diff --git a/SlickFlow.Tests/SlickFlow.Tests.csproj b/SlickFlow.Tests/SlickFlow.Tests.csproj new file mode 100644 index 0000000..ac4d704 --- /dev/null +++ b/SlickFlow.Tests/SlickFlow.Tests.csproj @@ -0,0 +1,28 @@ + + + + net7.0-windows + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/SlickFlow.sln b/SlickFlow.sln index 354360e..90bf37e 100644 --- a/SlickFlow.sln +++ b/SlickFlow.sln @@ -1,19 +1,46 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flow.Launcher.Plugin.SlickFlow", "Flow.Launcher.Plugin.SlickFlow\Flow.Launcher.Plugin.SlickFlow.csproj", "{B1DBF114-A10E-B64F-98EE-D21A4D00A643}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlickFlow.Tests", "SlickFlow.Tests\SlickFlow.Tests.csproj", "{C46970B8-7949-468D-9A70-39CCE93E0A7F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Debug|x64.Build.0 = Debug|Any CPU + {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Debug|x86.Build.0 = Debug|Any CPU {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Release|Any CPU.ActiveCfg = Release|Any CPU {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Release|Any CPU.Build.0 = Release|Any CPU + {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Release|x64.ActiveCfg = Release|Any CPU + {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Release|x64.Build.0 = Release|Any CPU + {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Release|x86.ActiveCfg = Release|Any CPU + {B1DBF114-A10E-B64F-98EE-D21A4D00A643}.Release|x86.Build.0 = Release|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Debug|x64.ActiveCfg = Debug|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Debug|x64.Build.0 = Debug|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Debug|x86.ActiveCfg = Debug|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Debug|x86.Build.0 = Debug|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Release|Any CPU.Build.0 = Release|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Release|x64.ActiveCfg = Release|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Release|x64.Build.0 = Release|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Release|x86.ActiveCfg = Release|Any CPU + {C46970B8-7949-468D-9A70-39CCE93E0A7F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From dbb2ae23f5b4e7bf9c81ef82c929e0c170f9382d Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Sat, 14 Mar 2026 14:34:39 +0200 Subject: [PATCH 07/19] test: add comprehensive unit and integration tests (90 tests) - Item model tests (aliases, matching, URL detection) - ItemSearcher tests (scoring, fuzzy matching, ordering) - ItemValidator tests (alias validation, property validation) - CommandParser tests (argument splitting, quoted strings) - Command handler tests (Add, Alias, Remove, Delete, Update, SetIcon) - CommandProcessor routing tests - ItemRepository integration tests (CRUD, persistence, reload) - SettingsManager integration tests (defaults, save/load, corruption) - Fixed ItemSearcher to handle empty/whitespace queries - Updated test project to net8.0-windows (runtime compatibility) Co-Authored-By: Claude Opus 4.6 --- .../Items/ItemSearcher.cs | 3 + .../Integration/ItemRepositoryTests.cs | 133 ++++++++++++++++++ .../Integration/SettingsManagerTests.cs | 69 +++++++++ SlickFlow.Tests/SlickFlow.Tests.csproj | 3 +- .../Unit/Commands/AddCommandHandlerTests.cs | 68 +++++++++ .../Unit/Commands/AliasCommandHandlerTests.cs | 47 +++++++ .../Unit/Commands/CommandParserTests.cs | 42 ++++++ .../Unit/Commands/CommandProcessorTests.cs | 52 +++++++ .../Commands/DeleteCommandHandlerTests.cs | 44 ++++++ .../Commands/RemoveCommandHandlerTests.cs | 53 +++++++ .../Commands/SetIconCommandHandlerTests.cs | 48 +++++++ .../Commands/UpdateCommandHandlerTests.cs | 55 ++++++++ .../Unit/Items/ItemSearcherTests.cs | 116 +++++++++++++++ SlickFlow.Tests/Unit/Items/ItemTests.cs | 111 +++++++++++++++ .../Unit/Items/ItemValidatorTests.cs | 80 +++++++++++ 15 files changed, 923 insertions(+), 1 deletion(-) create mode 100644 SlickFlow.Tests/Integration/ItemRepositoryTests.cs create mode 100644 SlickFlow.Tests/Integration/SettingsManagerTests.cs create mode 100644 SlickFlow.Tests/Unit/Commands/AddCommandHandlerTests.cs create mode 100644 SlickFlow.Tests/Unit/Commands/AliasCommandHandlerTests.cs create mode 100644 SlickFlow.Tests/Unit/Commands/CommandParserTests.cs create mode 100644 SlickFlow.Tests/Unit/Commands/CommandProcessorTests.cs create mode 100644 SlickFlow.Tests/Unit/Commands/DeleteCommandHandlerTests.cs create mode 100644 SlickFlow.Tests/Unit/Commands/RemoveCommandHandlerTests.cs create mode 100644 SlickFlow.Tests/Unit/Commands/SetIconCommandHandlerTests.cs create mode 100644 SlickFlow.Tests/Unit/Commands/UpdateCommandHandlerTests.cs create mode 100644 SlickFlow.Tests/Unit/Items/ItemSearcherTests.cs create mode 100644 SlickFlow.Tests/Unit/Items/ItemTests.cs create mode 100644 SlickFlow.Tests/Unit/Items/ItemValidatorTests.cs diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/ItemSearcher.cs b/Flow.Launcher.Plugin.SlickFlow/Items/ItemSearcher.cs index 5c84f24..82a649f 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Items/ItemSearcher.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Items/ItemSearcher.cs @@ -7,6 +7,9 @@ public class ItemSearcher : IItemSearcher { public List<(string name, int score, Item item)> Search(string query, List items) { + if (string.IsNullOrWhiteSpace(query)) + return new List<(string, int, Item)>(); + var results = new List<(string, int, Item)>(); var queryLower = query.ToLower(); diff --git a/SlickFlow.Tests/Integration/ItemRepositoryTests.cs b/SlickFlow.Tests/Integration/ItemRepositoryTests.cs new file mode 100644 index 0000000..288882e --- /dev/null +++ b/SlickFlow.Tests/Integration/ItemRepositoryTests.cs @@ -0,0 +1,133 @@ +using System.IO; +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; + +namespace SlickFlow.Tests.Integration; + +public class ItemRepositoryTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _dbPath; + + public ItemRepositoryTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "SlickFlowTests_" + Guid.NewGuid()); + Directory.CreateDirectory(_tempDir); + _dbPath = Path.Combine(_tempDir, "test.json"); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + [Fact] + public void AddItem_AssignsIdAndPersists() + { + var repo = new ItemRepository(_dbPath); + var item = new Item { FileName = "notepad.exe", Aliases = new List { "np" } }; + var id = repo.AddItem(item); + id.Should().NotBeNullOrEmpty(); + File.Exists(_dbPath).Should().BeTrue(); + } + + [Fact] + public void GetItemById_ReturnsAddedItem() + { + var repo = new ItemRepository(_dbPath); + var item = new Item { FileName = "notepad.exe", Aliases = new List { "np" } }; + var id = repo.AddItem(item); + var retrieved = repo.GetItemById(id); + retrieved.Should().NotBeNull(); + retrieved!.FileName.Should().Be("notepad.exe"); + } + + [Fact] + public void GetAllItems_ReturnsAllItems() + { + var repo = new ItemRepository(_dbPath); + repo.AddItem(new Item { FileName = "a.exe", Aliases = new List { "a" } }); + repo.AddItem(new Item { FileName = "b.exe", Aliases = new List { "b" } }); + repo.GetAllItems().Should().HaveCount(2); + } + + [Fact] + public void UpdateItem_PersistsChanges() + { + var repo = new ItemRepository(_dbPath); + var item = new Item { FileName = "notepad.exe", Aliases = new List { "np" } }; + var id = repo.AddItem(item); + var toUpdate = repo.GetItemById(id)!; + toUpdate.Arguments = "/A"; + repo.UpdateItem(toUpdate); + var reloaded = new ItemRepository(_dbPath); + reloaded.GetItemById(id)!.Arguments.Should().Be("/A"); + } + + [Fact] + public void DeleteItem_RemovesFromPersistence() + { + var repo = new ItemRepository(_dbPath); + var item = new Item { FileName = "notepad.exe", Aliases = new List { "np" } }; + var id = repo.AddItem(item); + repo.DeleteItem(id); + repo.GetItemById(id).Should().BeNull(); + var reloaded = new ItemRepository(_dbPath); + reloaded.GetItemById(id).Should().BeNull(); + } + + [Fact] + public void AddAlias_PersistsNewAlias() + { + var repo = new ItemRepository(_dbPath); + var item = new Item { FileName = "notepad.exe", Aliases = new List { "np" } }; + var id = repo.AddItem(item); + repo.AddAlias(id, "note"); + repo.GetItemById(id)!.Aliases.Should().Contain("note"); + } + + [Fact] + public void RemoveAlias_PersistsRemoval() + { + var repo = new ItemRepository(_dbPath); + var item = new Item { FileName = "notepad.exe", Aliases = new List { "np", "note" } }; + var id = repo.AddItem(item); + repo.RemoveAlias(id, "np"); + repo.GetItemById(id)!.Aliases.Should().NotContain("np"); + } + + [Fact] + public void GetItemByAlias_FindsItem() + { + var repo = new ItemRepository(_dbPath); + repo.AddItem(new Item { FileName = "notepad.exe", Aliases = new List { "np" } }); + var found = repo.GetItemByAlias("np"); + found.Should().NotBeNull(); + found!.FileName.Should().Be("notepad.exe"); + } + + [Fact] + public void GetItemByAlias_CaseInsensitive() + { + var repo = new ItemRepository(_dbPath); + repo.AddItem(new Item { FileName = "notepad.exe", Aliases = new List { "NP" } }); + repo.GetItemByAlias("np").Should().NotBeNull(); + } + + [Fact] + public void Constructor_MissingFile_StartsEmpty() + { + var repo = new ItemRepository(Path.Combine(_tempDir, "nonexistent.json")); + repo.GetAllItems().Should().BeEmpty(); + } + + [Fact] + public void DataSurvivesReload() + { + var repo = new ItemRepository(_dbPath); + repo.AddItem(new Item { FileName = "notepad.exe", Aliases = new List { "np" } }); + var reloaded = new ItemRepository(_dbPath); + reloaded.GetAllItems().Should().HaveCount(1); + } +} diff --git a/SlickFlow.Tests/Integration/SettingsManagerTests.cs b/SlickFlow.Tests/Integration/SettingsManagerTests.cs new file mode 100644 index 0000000..e88ff1a --- /dev/null +++ b/SlickFlow.Tests/Integration/SettingsManagerTests.cs @@ -0,0 +1,69 @@ +using System.IO; +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Settings; + +namespace SlickFlow.Tests.Integration; + +public class SettingsManagerTests : IDisposable +{ + private readonly string _tempDir; + + public SettingsManagerTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "SlickFlowSettingsTests_" + Guid.NewGuid()); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + [Fact] + public void Load_NoFile_CreatesDefaults() + { + var settings = SettingsManager.Load(_tempDir); + settings.Should().NotBeNull(); + settings.DbFilePath.Should().NotBeNullOrEmpty(); + settings.IconDirPath.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Load_CreatesDbFile() + { + var settings = SettingsManager.Load(_tempDir); + File.Exists(settings.DbFilePath).Should().BeTrue(); + } + + [Fact] + public void Load_CreatesIconDirectory() + { + var settings = SettingsManager.Load(_tempDir); + Directory.Exists(settings.IconDirPath).Should().BeTrue(); + } + + [Fact] + public void SaveAndReload_PreservesSettings() + { + var settings = new Settings + { + DbFilePath = Path.Combine(_tempDir, "custom.json"), + IconDirPath = Path.Combine(_tempDir, "customicons") + }; + SettingsManager.Save(settings, _tempDir); + var reloaded = SettingsManager.Load(_tempDir); + reloaded.DbFilePath.Should().Be(settings.DbFilePath); + reloaded.IconDirPath.Should().Be(settings.IconDirPath); + } + + [Fact] + public void Load_CorruptedFile_ReturnsDefaults() + { + var settingsPath = Path.Combine(_tempDir, "settings.json"); + File.WriteAllText(settingsPath, "not valid json{{{"); + var settings = SettingsManager.Load(_tempDir); + settings.Should().NotBeNull(); + settings.DbFilePath.Should().NotBeNullOrEmpty(); + } +} diff --git a/SlickFlow.Tests/SlickFlow.Tests.csproj b/SlickFlow.Tests/SlickFlow.Tests.csproj index ac4d704..d4a7c3b 100644 --- a/SlickFlow.Tests/SlickFlow.Tests.csproj +++ b/SlickFlow.Tests/SlickFlow.Tests.csproj @@ -1,7 +1,7 @@  - net7.0-windows + net8.0-windows enable enable false @@ -19,6 +19,7 @@ + diff --git a/SlickFlow.Tests/Unit/Commands/AddCommandHandlerTests.cs b/SlickFlow.Tests/Unit/Commands/AddCommandHandlerTests.cs new file mode 100644 index 0000000..345e8e9 --- /dev/null +++ b/SlickFlow.Tests/Unit/Commands/AddCommandHandlerTests.cs @@ -0,0 +1,68 @@ +using FluentAssertions; +using Moq; +using Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.items; + +namespace SlickFlow.Tests.Unit.Commands; + +public class AddCommandHandlerTests +{ + private readonly Mock _mockRepo; + private readonly AddCommandHandler _handler; + + public AddCommandHandlerTests() + { + _mockRepo = new Mock(); + _mockRepo.Setup(r => r.GetAllItems()).Returns(new List()); + var validator = new ItemValidator(_mockRepo.Object, "icon.ico"); + _handler = new AddCommandHandler(_mockRepo.Object, validator, "icon.ico"); + } + + [Fact] + public void Handle_TooFewArgs_ReturnsUsage() + { + var results = _handler.Handle(new[] { "np" }); + results.Should().HaveCount(1); + results[0].Title.Should().Contain("Usage"); + } + + [Fact] + public void Handle_ValidArgs_ReturnsAddResult() + { + var results = _handler.Handle(new[] { "np", "notepad.exe" }); + results.Should().HaveCount(1); + results[0].Title.Should().Contain("Add item"); + } + + [Fact] + public void Handle_MultipleAliases_SplitsByPipe() + { + var results = _handler.Handle(new[] { "np|note", "notepad.exe" }); + results.Should().HaveCount(1); + results[0].Title.Should().Contain("np").And.Contain("note"); + } + + [Fact] + public void Handle_DuplicateAlias_ReturnsValidationError() + { + _mockRepo.Setup(r => r.GetAllItems()).Returns(new List + { + new Item("1", "notepad.exe", new[] { "np" }) + }); + var validator = new ItemValidator(_mockRepo.Object, "icon.ico"); + var handler = new AddCommandHandler(_mockRepo.Object, validator, "icon.ico"); + var results = handler.Handle(new[] { "np", "other.exe" }); + results.Should().HaveCount(1); + results[0].Title.Should().Contain("already exists"); + } + + [Fact] + public void Handle_WithRunAs_ParsesLastArgAsInt() + { + var results = _handler.Handle(new[] { "np", "notepad.exe", "1" }); + results.Should().HaveCount(1); + results[0].Title.Should().Contain("Add item"); + } +} diff --git a/SlickFlow.Tests/Unit/Commands/AliasCommandHandlerTests.cs b/SlickFlow.Tests/Unit/Commands/AliasCommandHandlerTests.cs new file mode 100644 index 0000000..77aa577 --- /dev/null +++ b/SlickFlow.Tests/Unit/Commands/AliasCommandHandlerTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; +using Moq; +using Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.items; + +namespace SlickFlow.Tests.Unit.Commands; + +public class AliasCommandHandlerTests +{ + private readonly Mock _mockRepo; + private readonly AliasCommandHandler _handler; + + public AliasCommandHandlerTests() + { + _mockRepo = new Mock(); + _mockRepo.Setup(r => r.GetAllItems()).Returns(new List()); + var validator = new ItemValidator(_mockRepo.Object, "icon.ico"); + _handler = new AliasCommandHandler(_mockRepo.Object, validator, "icon.ico"); + } + + [Fact] + public void Handle_TooFewArgs_ReturnsUsage() + { + var results = _handler.Handle(new[] { "np" }); + results[0].Title.Should().Contain("Usage"); + } + + [Fact] + public void Handle_ItemNotFound_ReturnsNotFound() + { + _mockRepo.Setup(r => r.GetItemById("xyz")).Returns((Item?)null); + _mockRepo.Setup(r => r.GetItemByAlias("xyz")).Returns((Item?)null); + var results = _handler.Handle(new[] { "xyz", "newalias" }); + results[0].Title.Should().Contain("No item found"); + } + + [Fact] + public void Handle_ValidAlias_ReturnsAddResult() + { + var item = new Item("1", "notepad.exe", new[] { "np" }); + _mockRepo.Setup(r => r.GetItemByAlias("np")).Returns(item); + var results = _handler.Handle(new[] { "np", "note" }); + results[0].Title.Should().Contain("alias"); + } +} diff --git a/SlickFlow.Tests/Unit/Commands/CommandParserTests.cs b/SlickFlow.Tests/Unit/Commands/CommandParserTests.cs new file mode 100644 index 0000000..a4ab682 --- /dev/null +++ b/SlickFlow.Tests/Unit/Commands/CommandParserTests.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Commands; + +namespace SlickFlow.Tests.Unit.Commands; + +public class CommandParserTests +{ + [Fact] + public void SplitArgs_SimpleArguments() + { + var result = CommandParser.SplitArgs("add np notepad.exe"); + result.Should().Equal("add", "np", "notepad.exe"); + } + + [Fact] + public void SplitArgs_QuotedString_PreservesContent() + { + var result = CommandParser.SplitArgs("add np \"C:\\Program Files\\app.exe\""); + result.Should().Equal("add", "np", "C:\\Program Files\\app.exe"); + } + + [Fact] + public void SplitArgs_SingleArgument() + { + var result = CommandParser.SplitArgs("add"); + result.Should().Equal("add"); + } + + [Fact] + public void SplitArgs_EmptyString_ReturnsEmpty() + { + var result = CommandParser.SplitArgs(""); + result.Should().BeEmpty(); + } + + [Fact] + public void SplitArgs_MultipleSpaces_IgnoresExtra() + { + var result = CommandParser.SplitArgs("add np notepad.exe"); + result.Should().Equal("add", "np", "notepad.exe"); + } +} diff --git a/SlickFlow.Tests/Unit/Commands/CommandProcessorTests.cs b/SlickFlow.Tests/Unit/Commands/CommandProcessorTests.cs new file mode 100644 index 0000000..df5de4d --- /dev/null +++ b/SlickFlow.Tests/Unit/Commands/CommandProcessorTests.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using Moq; +using Flow.Launcher.Plugin.SlickFlow.Commands; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.items; +using Flow.Launcher.Plugin.SlickFlow.Utils; + +namespace SlickFlow.Tests.Unit.Commands; + +public class CommandProcessorTests +{ + private readonly CommandProcessor _processor; + + public CommandProcessorTests() + { + var mockRepo = new Mock(); + mockRepo.Setup(r => r.GetAllItems()).Returns(new List()); + var validator = new ItemValidator(mockRepo.Object, "icon.ico"); + var tempDir = Path.Combine(Path.GetTempPath(), "SlickFlowCPTests_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + var iconHelper = new IconHelper(tempDir); + _processor = new CommandProcessor(mockRepo.Object, validator, iconHelper, "icon.ico"); + } + + [Theory] + [InlineData("add")] + [InlineData("alias")] + [InlineData("remove")] + [InlineData("delete")] + [InlineData("update")] + [InlineData("seticon")] + public void Process_KnownCommand_ReturnsResults(string command) + { + var results = _processor.Process(command, Array.Empty()); + results.Should().NotBeEmpty(); + } + + [Fact] + public void Process_UnknownCommand_ReturnsEmpty() + { + var results = _processor.Process("unknown", Array.Empty()); + results.Should().BeEmpty(); + } + + [Fact] + public void Process_CaseInsensitive() + { + var results = _processor.Process("ADD", Array.Empty()); + results.Should().NotBeEmpty(); + } +} diff --git a/SlickFlow.Tests/Unit/Commands/DeleteCommandHandlerTests.cs b/SlickFlow.Tests/Unit/Commands/DeleteCommandHandlerTests.cs new file mode 100644 index 0000000..81e6506 --- /dev/null +++ b/SlickFlow.Tests/Unit/Commands/DeleteCommandHandlerTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using Moq; +using Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; + +namespace SlickFlow.Tests.Unit.Commands; + +public class DeleteCommandHandlerTests +{ + private readonly Mock _mockRepo; + private readonly DeleteCommandHandler _handler; + + public DeleteCommandHandlerTests() + { + _mockRepo = new Mock(); + _handler = new DeleteCommandHandler(_mockRepo.Object, "icon.ico"); + } + + [Fact] + public void Handle_NoArgs_ReturnsUsage() + { + var results = _handler.Handle(Array.Empty()); + results[0].Title.Should().Contain("Usage"); + } + + [Fact] + public void Handle_NotFound_ReturnsNotFound() + { + _mockRepo.Setup(r => r.GetItemById("xyz")).Returns((Item?)null); + _mockRepo.Setup(r => r.GetItemByAlias("xyz")).Returns((Item?)null); + var results = _handler.Handle(new[] { "xyz" }); + results[0].Title.Should().Contain("No item found"); + } + + [Fact] + public void Handle_Found_ReturnsConfirmDelete() + { + var item = new Item("1", "notepad.exe", new[] { "np" }); + _mockRepo.Setup(r => r.GetItemByAlias("np")).Returns(item); + var results = _handler.Handle(new[] { "np" }); + results[0].Title.Should().Contain("Confirm delete"); + } +} diff --git a/SlickFlow.Tests/Unit/Commands/RemoveCommandHandlerTests.cs b/SlickFlow.Tests/Unit/Commands/RemoveCommandHandlerTests.cs new file mode 100644 index 0000000..30c23a6 --- /dev/null +++ b/SlickFlow.Tests/Unit/Commands/RemoveCommandHandlerTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using Moq; +using Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; + +namespace SlickFlow.Tests.Unit.Commands; + +public class RemoveCommandHandlerTests +{ + private readonly Mock _mockRepo; + private readonly RemoveCommandHandler _handler; + + public RemoveCommandHandlerTests() + { + _mockRepo = new Mock(); + _handler = new RemoveCommandHandler(_mockRepo.Object, "icon.ico"); + } + + [Fact] + public void Handle_NoArgs_ReturnsUsage() + { + var results = _handler.Handle(Array.Empty()); + results.Should().HaveCount(1); + results[0].Title.Should().Contain("Usage"); + } + + [Fact] + public void Handle_AliasNotFound_ReturnsNotFound() + { + _mockRepo.Setup(r => r.GetItemByAlias("np")).Returns((Item?)null); + var results = _handler.Handle(new[] { "np" }); + results[0].Title.Should().Contain("No item found"); + } + + [Fact] + public void Handle_OnlyOneAlias_SuggestsDelete() + { + var item = new Item("1", "notepad.exe", new[] { "np" }); + _mockRepo.Setup(r => r.GetItemByAlias("np")).Returns(item); + var results = _handler.Handle(new[] { "np" }); + results[0].Title.Should().Contain("only has one alias"); + } + + [Fact] + public void Handle_MultipleAliases_ReturnsRemoveResult() + { + var item = new Item("1", "notepad.exe", new[] { "np", "note" }); + _mockRepo.Setup(r => r.GetItemByAlias("np")).Returns(item); + var results = _handler.Handle(new[] { "np" }); + results[0].Title.Should().Contain("Remove alias"); + } +} diff --git a/SlickFlow.Tests/Unit/Commands/SetIconCommandHandlerTests.cs b/SlickFlow.Tests/Unit/Commands/SetIconCommandHandlerTests.cs new file mode 100644 index 0000000..953a05a --- /dev/null +++ b/SlickFlow.Tests/Unit/Commands/SetIconCommandHandlerTests.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using Moq; +using Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.Utils; + +namespace SlickFlow.Tests.Unit.Commands; + +public class SetIconCommandHandlerTests +{ + private readonly Mock _mockRepo; + private readonly SetIconCommandHandler _handler; + + public SetIconCommandHandlerTests() + { + _mockRepo = new Mock(); + var tempDir = Path.Combine(Path.GetTempPath(), "SlickFlowSetIconTests_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + var iconHelper = new IconHelper(tempDir); + _handler = new SetIconCommandHandler(_mockRepo.Object, iconHelper, "icon.ico"); + } + + [Fact] + public void Handle_TooFewArgs_ReturnsUsage() + { + var results = _handler.Handle(new[] { "np" }); + results[0].Title.Should().Contain("Usage"); + } + + [Fact] + public void Handle_ItemNotFound_ReturnsNotFound() + { + _mockRepo.Setup(r => r.GetItemById("xyz")).Returns((Item?)null); + _mockRepo.Setup(r => r.GetItemByAlias("xyz")).Returns((Item?)null); + var results = _handler.Handle(new[] { "xyz", "icon.png" }); + results[0].Title.Should().Contain("No item found"); + } + + [Fact] + public void Handle_ValidArgs_ReturnsSetIconResult() + { + var item = new Item("1", "notepad.exe", new[] { "np" }); + _mockRepo.Setup(r => r.GetItemByAlias("np")).Returns(item); + var results = _handler.Handle(new[] { "np", "icon.png" }); + results[0].Title.Should().Contain("Set custom icon"); + } +} diff --git a/SlickFlow.Tests/Unit/Commands/UpdateCommandHandlerTests.cs b/SlickFlow.Tests/Unit/Commands/UpdateCommandHandlerTests.cs new file mode 100644 index 0000000..ba2db64 --- /dev/null +++ b/SlickFlow.Tests/Unit/Commands/UpdateCommandHandlerTests.cs @@ -0,0 +1,55 @@ +using FluentAssertions; +using Moq; +using Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.items; + +namespace SlickFlow.Tests.Unit.Commands; + +public class UpdateCommandHandlerTests +{ + private readonly Mock _mockRepo; + private readonly UpdateCommandHandler _handler; + + public UpdateCommandHandlerTests() + { + _mockRepo = new Mock(); + var validator = new ItemValidator(_mockRepo.Object, "icon.ico"); + _handler = new UpdateCommandHandler(_mockRepo.Object, validator, "icon.ico"); + } + + [Fact] + public void Handle_TooFewArgs_ReturnsUsage() + { + var results = _handler.Handle(new[] { "np", "args" }); + results[0].Title.Should().Contain("Usage"); + } + + [Fact] + public void Handle_ItemNotFound_ReturnsNotFound() + { + _mockRepo.Setup(r => r.GetItemById("xyz")).Returns((Item?)null); + _mockRepo.Setup(r => r.GetItemByAlias("xyz")).Returns((Item?)null); + var results = _handler.Handle(new[] { "xyz", "args", "value" }); + results[0].Title.Should().Contain("No item found"); + } + + [Fact] + public void Handle_InvalidProperty_ReturnsError() + { + var item = new Item("1", "notepad.exe", new[] { "np" }); + _mockRepo.Setup(r => r.GetItemByAlias("np")).Returns(item); + var results = _handler.Handle(new[] { "np", "badprop", "value" }); + results[0].Title.Should().Contain("Invalid properties"); + } + + [Fact] + public void Handle_ValidUpdate_ReturnsUpdateResult() + { + var item = new Item("1", "notepad.exe", new[] { "np" }); + _mockRepo.Setup(r => r.GetItemByAlias("np")).Returns(item); + var results = _handler.Handle(new[] { "np", "args", "/A" }); + results[0].Title.Should().Contain("Update item"); + } +} diff --git a/SlickFlow.Tests/Unit/Items/ItemSearcherTests.cs b/SlickFlow.Tests/Unit/Items/ItemSearcherTests.cs new file mode 100644 index 0000000..19a4397 --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/ItemSearcherTests.cs @@ -0,0 +1,116 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; + +namespace SlickFlow.Tests.Unit.Items; + +public class ItemSearcherTests +{ + private readonly ItemSearcher _searcher = new(); + + private static Item CreateItem(string id, string fileName, params string[] aliases) + { + return new Item(id, fileName, aliases); + } + + [Fact] + public void Search_ExactMatch_ScoresHighest() + { + var items = new List { CreateItem("1", "notepad.exe", "np") }; + var results = _searcher.Search("np", items); + results.Should().HaveCount(1); + results[0].score.Should().BeGreaterThan(2000); + } + + [Fact] + public void Search_PrefixMatch_ScoresHigh() + { + var items = new List { CreateItem("1", "notepad.exe", "notepad") }; + var results = _searcher.Search("note", items); + results.Should().HaveCount(1); + // startsWith(800) + lengthBonus(44) = 844 + results[0].score.Should().BeGreaterThan(800); + } + + [Fact] + public void Search_ContainsMatch_QueryContainsAlias() + { + var items = new List { CreateItem("1", "x.exe", "foo") }; + var results = _searcher.Search("foobar", items); + results.Should().HaveCount(1); + results[0].score.Should().BeGreaterThan(0); + } + + [Fact] + public void Search_SuffixMatch() + { + var items = new List { CreateItem("1", "x.exe", "notepad") }; + var results = _searcher.Search("pad", items); + results.Should().HaveCount(1); + results[0].score.Should().BeGreaterThan(0); + } + + [Fact] + public void Search_LevenshteinDistance1_GetsBonus() + { + var items = new List { CreateItem("1", "x.exe", "np") }; + var results = _searcher.Search("mp", items); + results.Should().HaveCount(1); + results[0].score.Should().BeGreaterThanOrEqualTo(50); + } + + [Fact] + public void Search_NoMatch_ReturnsEmpty() + { + var items = new List { CreateItem("1", "notepad.exe", "np") }; + var results = _searcher.Search("zzzzz", items); + results.Should().BeEmpty(); + } + + [Fact] + public void Search_CaseInsensitive() + { + var items = new List { CreateItem("1", "notepad.exe", "NP") }; + var results = _searcher.Search("np", items); + results.Should().HaveCount(1); + } + + [Fact] + public void Search_ResultsSortedByScoreDescending() + { + // Use aliases where both match: "np" exact matches "np", "nps" starts with "np" + var items = new List + { + CreateItem("1", "x.exe", "nps"), + CreateItem("2", "y.exe", "np") + }; + var results = _searcher.Search("np", items); + results.Should().HaveCountGreaterThanOrEqualTo(2); + // "np" exact match should score higher than "nps" prefix match + results[0].name.Should().Be("np"); + } + + [Fact] + public void Search_MultipleAliasesOnSameItem() + { + var items = new List { CreateItem("1", "x.exe", "foo", "bar") }; + var results = _searcher.Search("foo", items); + results.Should().Contain(r => r.name == "foo"); + } + + [Fact] + public void Search_AdditiveScoring_ExactMatchGetsAllBonuses() + { + var items = new List { CreateItem("1", "x.exe", "np") }; + var results = _searcher.Search("np", items); + // exact(1000) + startsWith(800) + contains(400) + endsWith(50) + lengthBonus(50) = 2300 + results[0].score.Should().Be(2300); + } + + [Fact] + public void Search_EmptyQuery_ReturnsEmpty() + { + var items = new List { CreateItem("1", "x.exe", "np") }; + var results = _searcher.Search("", items); + results.Should().BeEmpty(); + } +} diff --git a/SlickFlow.Tests/Unit/Items/ItemTests.cs b/SlickFlow.Tests/Unit/Items/ItemTests.cs new file mode 100644 index 0000000..a6302b1 --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/ItemTests.cs @@ -0,0 +1,111 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; + +namespace SlickFlow.Tests.Unit.Items; + +public class ItemTests +{ + [Fact] + public void AddAlias_AddsNewAlias() + { + var item = new Item("1", "notepad.exe"); + item.AddAlias("np"); + item.Aliases.Should().Contain("np"); + } + + [Fact] + public void AddAlias_IgnoresDuplicateCaseInsensitive() + { + var item = new Item("1", "notepad.exe", new[] { "np" }); + item.AddAlias("NP"); + item.Aliases.Should().HaveCount(1); + } + + [Fact] + public void RemoveAlias_RemovesExistingAlias() + { + var item = new Item("1", "notepad.exe", new[] { "np", "note" }); + var removed = item.RemoveAlias("np"); + removed.Should().Be(1); + item.Aliases.Should().NotContain("np"); + } + + [Fact] + public void RemoveAlias_CaseInsensitive() + { + var item = new Item("1", "notepad.exe", new[] { "np" }); + var removed = item.RemoveAlias("NP"); + removed.Should().Be(1); + } + + [Fact] + public void RemoveAlias_ReturnsZeroForNonExistent() + { + var item = new Item("1", "notepad.exe", new[] { "np" }); + var removed = item.RemoveAlias("xyz"); + removed.Should().Be(0); + } + + [Fact] + public void MatchesQuery_MatchesFileName() + { + var item = new Item("1", "notepad.exe"); + item.MatchesQuery("notepad").Should().BeTrue(); + } + + [Fact] + public void MatchesQuery_MatchesSubTitle() + { + var item = new Item("1", "notepad.exe") { SubTitle = "Text Editor" }; + item.MatchesQuery("editor").Should().BeTrue(); + } + + [Fact] + public void MatchesQuery_MatchesAlias() + { + var item = new Item("1", "notepad.exe", new[] { "np" }); + item.MatchesQuery("np").Should().BeTrue(); + } + + [Fact] + public void MatchesQuery_CaseInsensitive() + { + var item = new Item("1", "notepad.exe", new[] { "NP" }); + item.MatchesQuery("np").Should().BeTrue(); + } + + [Fact] + public void MatchesQuery_ReturnsFalseForNull() + { + var item = new Item("1", "notepad.exe"); + item.MatchesQuery(null!).Should().BeFalse(); + } + + [Fact] + public void MatchesQuery_ReturnsFalseForWhitespace() + { + var item = new Item("1", "notepad.exe"); + item.MatchesQuery(" ").Should().BeFalse(); + } + + [Fact] + public void MatchesQuery_ReturnsFalseForNoMatch() + { + var item = new Item("1", "notepad.exe", new[] { "np" }); + item.MatchesQuery("xyz").Should().BeFalse(); + } + + [Fact] + public void IsUrl_ReturnsTrueForHttp() + { + var item = new Item("1", "https://google.com"); + item.IsUrl("https://google.com").Should().BeTrue(); + } + + [Fact] + public void IsUrl_ReturnsFalseForFilePath() + { + var item = new Item("1", "notepad.exe"); + item.IsUrl("notepad.exe").Should().BeFalse(); + } +} diff --git a/SlickFlow.Tests/Unit/Items/ItemValidatorTests.cs b/SlickFlow.Tests/Unit/Items/ItemValidatorTests.cs new file mode 100644 index 0000000..c75c005 --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/ItemValidatorTests.cs @@ -0,0 +1,80 @@ +using FluentAssertions; +using Moq; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.items; + +namespace SlickFlow.Tests.Unit.Items; + +public class ItemValidatorTests +{ + private readonly Mock _mockRepo; + private readonly ItemValidator _validator; + + public ItemValidatorTests() + { + _mockRepo = new Mock(); + _validator = new ItemValidator(_mockRepo.Object, "icon.ico"); + } + + [Fact] + public void ValidateAliases_EmptyList_ReturnsError() + { + var results = _validator.ValidateAliases(new List()); + results.Should().HaveCount(1); + results[0].Title.Should().Contain("No valid aliases"); + } + + [Fact] + public void ValidateAliases_DuplicateAlias_ReturnsError() + { + var existingItems = new List + { + new Item("1", "notepad.exe", new[] { "np" }) + }; + _mockRepo.Setup(r => r.GetAllItems()).Returns(existingItems); + var results = _validator.ValidateAliases(new List { "np" }); + results.Should().HaveCount(1); + results[0].Title.Should().Contain("Alias already exists"); + } + + [Fact] + public void ValidateAliases_DuplicateAlias_CaseInsensitive() + { + var existingItems = new List + { + new Item("1", "notepad.exe", new[] { "NP" }) + }; + _mockRepo.Setup(r => r.GetAllItems()).Returns(existingItems); + var results = _validator.ValidateAliases(new List { "np" }); + results.Should().HaveCount(1); + } + + [Fact] + public void ValidateAliases_UniqueAlias_ReturnsEmpty() + { + var existingItems = new List + { + new Item("1", "notepad.exe", new[] { "np" }) + }; + _mockRepo.Setup(r => r.GetAllItems()).Returns(existingItems); + var results = _validator.ValidateAliases(new List { "vim" }); + results.Should().BeEmpty(); + } + + [Theory] + [InlineData("args", true)] + [InlineData("arguments", true)] + [InlineData("runas", true)] + [InlineData("startmode", true)] + [InlineData("subtitle", true)] + [InlineData("workingdir", true)] + [InlineData("workdir", true)] + [InlineData("invalid", false)] + [InlineData("name", false)] + [InlineData("filename", false)] + public void IsValidProperty_ReturnsCorrectResult(string prop, bool expected) + { + _validator.IsValidProperty(prop).Should().Be(expected); + } +} From 1faa1f46a4dbd0212f6c7759bcaa0172ca5f4cec Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Sat, 14 Mar 2026 14:35:33 +0200 Subject: [PATCH 08/19] ci: add GitHub Actions test workflow and AppVeyor test step Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 30 ++++++++++++++++++++++++++++++ appveyor.yml | 3 +++ 2 files changed, 33 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e898fa0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Tests + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + test: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 7.0.x + 8.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore + + - name: Test + run: dotnet test --no-build --verbosity normal diff --git a/appveyor.yml b/appveyor.yml index 8094282..17cffef 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -8,6 +8,9 @@ environment: build_script: - ps: dotnet publish -c Release -r win-x64 --no-self-contained Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj +test_script: +- ps: dotnet test --verbosity normal + after_build: - ps: Compress-Archive -Path "Flow.Launcher.Plugin.SlickFlow\bin\Release\win-x64\publish\*" -DestinationPath "Flow.Launcher.Plugin.SlickFlow.zip" From 8c28580b827f3a489fbf059835fd70b9b5eeef7e Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Sat, 14 Mar 2026 14:40:22 +0200 Subject: [PATCH 09/19] ci: add release workflow and normalize asset sizes to 32x32 - Add GitHub Actions release workflow that builds, tests, creates zip with DLL, assets, icon, and plugin.json, and uploads to GitHub release - Resize all context menu assets to consistent 32x32px Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yml | 40 ++++++++++++++++++ .../Assets/Graph.png | Bin 12344 -> 1134 bytes .../Assets/IncognitoIcon.png | Bin 30645 -> 1961 bytes .../Assets/Powershell.png | Bin 8265 -> 1715 bytes .../Assets/Shield.png | Bin 14474 -> 1188 bytes Flow.Launcher.Plugin.SlickFlow/Assets/Tag.png | Bin 9606 -> 1233 bytes .../Assets/Terminal.png | Bin 8137 -> 738 bytes 7 files changed, 40 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7478ce8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Release + +on: + release: + types: [published] + +jobs: + build-and-upload: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 7.0.x + 8.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Run tests + run: dotnet test --verbosity normal + + - name: Publish + run: dotnet publish Flow.Launcher.Plugin.SlickFlow -c Release -r win-x64 --no-self-contained + + - name: Create release zip + shell: pwsh + run: | + $publishDir = "Flow.Launcher.Plugin.SlickFlow/bin/Release/win-x64/publish" + $zipPath = "Flow.Launcher.Plugin.SlickFlow.zip" + Compress-Archive -Path "$publishDir/*" -DestinationPath $zipPath -Force + + - name: Upload release asset + uses: softprops/action-gh-release@v2 + with: + files: Flow.Launcher.Plugin.SlickFlow.zip diff --git a/Flow.Launcher.Plugin.SlickFlow/Assets/Graph.png b/Flow.Launcher.Plugin.SlickFlow/Assets/Graph.png index cb1018e404738e1d34d422a3b28067ec8658983f..7fad06ead85b92b95f918acdf618931f801aa804 100644 GIT binary patch literal 1134 zcmV-!1d;oRP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1OrJ#K~z{r?N?i9 z990-zEqDd1h~k632)^lq;02XXsG6HewwvspvvXz}ZIdcel1ftx7EP?uhl+^SqKQef zn>lAT*_l~WV{K~^V^eElnzSiip1gpyNFVHzDB{ES%bCrjn=xW*visx*Kb-%3|98&E zFmwJhkw_Vkl-Q6O#u=t?YU4qiXA%S9nv%k>>Bf|c2e`sq%P3LV4aE1;^+pb3%!bDl z@?2t1xTa)a688!R=Zqm(3;77M@l?33BoH~Y3KzeM96Exz!~oq<*F)DXtz2a^y^d>y z16#QGnGe#sFkez|N$X4x-wPLq39RVv+(ei`998@dAt*e&=ujD0M)&y zdG07tdX6YPOZ3JJQImzyaNQ*#nx?g;i&xA%eoA@xFs^X7o=-|NnR#?`g#$c`1I7?u zi%Hz3xm7X1v$)lCvB&iAqv>H>456Z!XA<8C2Zxx#UY5mPV-UY_g_!16WFVQvJ*JD^ zl!vpXhmXP=2S_N)vCKt|S(r>_kkoo8MAPU=7}g0&!V=f9}QLSp&@EhRG;yI ze#Hj(AZ|AXF)tiSFZY<6(pbOcC{f24QO79J_DQ0Ig_(d~v4IHD(maH!9(uI>NZ~uy z@%Eg*$l_tLas&T@>OQP*J6u>m$D1epMUDsjsu<9xTJu5FGYgG|jmBgSkJWupXq)9R zAaN{}#}7Bc@LIsgCw07*qoM6N<$g8o<+ Ai2wiq literal 12344 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 JPDHLkV1iJ=FlGP% literal 8265 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 R3*GoeJmh$==73+y{{dpbsm=fZ diff --git a/Flow.Launcher.Plugin.SlickFlow/Assets/Shield.png b/Flow.Launcher.Plugin.SlickFlow/Assets/Shield.png index 69b05e97fa5e43877ea7fb132067979e120ad911..f0325da35deeb6cdd61b18461a8d462d4120d071 100644 GIT binary patch delta 1166 zcmV;91abR{aij?$iBL{Q4GJ0x0000DNk~Le0000W0000W2nGNE0CReJ^pPPae-6U{ z01m?e$8V@)000CzNklzF%xJG5LwPW)rWT}W(-L^4-wb7i1yxr)!j|*a6{Dfe+PE9IUs0t zVP~rydTjz=B)~KQ<_KUWxIaql?mkLsQ;|~(*ZJ9V?S66Ge6Q>-9Xq(Y=OGfi|3>1~ zzYyQ~0C8svO9adiK*y>lz${@-#Y}(&9$eRmoSW|z@p;XmgpPX< zD(OrBkfGtdd@v2;4ucV}PWyLzyC_8-iwsr!bbb z!HO6KV`RM@uUxc1Hz5M}D@lbznei?2h-tL5r4~B6WQe={O$%-B(7MMRYe zx-k*+Zt22F)|;&K^)!K_lgPOFUP;RB&4{gcAfnEWh#CvRt1SqxGGj+2!h6RL1^PsQXee(wB;% z>uEUBREf9hI`K}u5*c+$WYjA0Hf>EC4%D?Hvs{6!FFsD^=06dSx{1>BC$su^S7g`X zay5wbPx#2^;Ujl}k}>k6PLQ*U2*{b7BatKi-0UY}`XcWDeA$&ylPD@A3!*$vG8x`4$8gOf%-| z;V+%Vwt^YNe;;qj;^wW3sY-sNA;9osmj@|Gim!p>nh}tv$BVgoypV51sLTYx zhwT;IoToxx>HjH)DdzJZAiyBOazBMVMLi&2o8bSA4!(It1Q(hh_~`O?AcV)wcxv=z zzS&DX6!S%72S}0kfPD2EeDmfInrlGp2bbzw38A>pWIi({i~J8RbWjfbM1Ub4#Aj6C zl^NI&0);B2)v`Mruax%)O8P099S@8+D&3r##0 g$i278CeIE30-~!r+bRWN0ssI207*qoM6N<$f=2i(6#xJL literal 14474 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 W*mz_-;;nxWpd_a*TPJN9_J06mvUeN+ diff --git a/Flow.Launcher.Plugin.SlickFlow/Assets/Tag.png b/Flow.Launcher.Plugin.SlickFlow/Assets/Tag.png index f92615077f9a23d6ba68111c8cc147448a29103a..18205838625b09fcce64a8a87ee2d15da0eae796 100644 GIT binary patch literal 1233 zcmV;?1TOoDP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1ZGJ@K~z{r)s=lr z8+91RF(!+NG4T)LpGGqSOQ{3yN(+=;+xy)Wh2b<4D73;vd2yptcUMB>9S6k8h>AKB zM}RU`wh6Kq2EA^p^3EVmaFmxt;xJB~FY$$K(WN}*cN+w*or402ev>EHJa>7%pFYoX zzY7TXFNtB8Cs>v}&M?d|gwQk6`x`EVkb`B}W`<$PSeBh;S@v_SR=ZjH+=hv!X%|8$ z6aS8ljooB07^+#8{S6@$Cw+E9MALLSP1947+zysy|3(NUEzNI~?!y2K!{jYZ($Pc= z(rJFz>0+p*`S_}_hv^!3xVFR{1ZyXOO`iosts`@g!0OdJ?8 zib4PG*TB|{LVWEQBz!OmaqnLOs`A3zxxS0TkV0Vz#WkZPNRXFr|-s$ytPUEo|l2u86vfzBxVnR;s!v{fS>UBmS(q>E{N zI?-HXJa$zu*{?uy!-N z%tCVGRdhnhIUXbP z&QC^`jzFlT6J%NKo^7=kJ$TV@Ve`>%A-Udt9sg_5yx#3)?h|6AZ-;_&JQZ0o3<|3g zL1t216%&<7;%1X*S~DE4-FQV&mwBajwf zX{>4|Nbc+oWkElXRwsm7I$%pin};gxzLk1-dR-ti6+?5;WxYD7@1nwahe=g92qZ6v zW55tW>8&6_G_Mh&aRL&XzX$5g-T|raq6l7iSygl!z$b*iov3#MKD1KpGdH$>_@zPW zyC{-(=BkQ@76H};;*NRBk(nDwbz?~CxlH67V$9@Yftm!3hWN1+WnE?2{0@vsFAN z%tvRzXqy6jYWi)CMR1OWrLW=L0(T+o)lQIQo`9ei+it~H4|kaDGmzH$ga77OBrn)i zg#%uOcMEyWDUiR?1%c1mwwmpicc-;oyOexroR-#Iu?SB1RJlTc+q=*q0)%9rh?ds! zO9U^RQx^7nnfVHF40}2uB>OakWVMshT7HS-gx09?QNR%Rn}GKLFQpj8cdlX#LS*p} zDDwN^$=tIbd#P=Uw3c5AwVVlz$nTCPI3bbbyI#QG27+^1NKVKL=Nu&j@2Dd9u6IaY v_()+D8p2;c*CxwsuX)tcu}NC%f1`f@$Nt~>ng2T300000NkvXXu0mjfohUx~ literal 9606 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 LNkvXXu0mjfzaB_x literal 8137 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 Date: Wed, 1 Apr 2026 00:52:45 +0300 Subject: [PATCH 10/19] feat: add search bar to filter items in settings panel Adds a search TextBox above the items list that filters by alias using case-insensitive substring matching. Includes placeholder "Search" text. Co-Authored-By: Claude Opus 4.6 --- .../ViewModels/Settings/Settings.xaml | 26 ++++++++++++++- .../ViewModels/Settings/SettingsViewModel.cs | 32 +++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/Settings.xaml b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/Settings.xaml index 78290b2..f79763b 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/Settings.xaml +++ b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/Settings.xaml @@ -73,8 +73,32 @@ FontWeight="Bold" Margin="4,4,0,4" /> + + + + + + + - _allItems = new(); public ObservableCollection Items { get; } = new(); + private string _searchText = string.Empty; + public string SearchText + { + get => _searchText; + set + { + if (_searchText == value) return; + _searchText = value; + OnPropertyChanged(nameof(SearchText)); + FilterItems(); + } + } + public SettingsViewModel(ItemRepository repo) { _settings = SettingsManager.Load(); @@ -162,15 +177,28 @@ private void BrowseIconFolder() private void ReloadItems() { - Items.Clear(); + _allItems.Clear(); var tempItems = _repo.GetAllItems(); tempItems.Reverse(); foreach (var item in tempItems) { var vm = new ItemViewModel(item, _repo); vm.Deleted += ReloadItems; - Items.Add(vm); + _allItems.Add(vm); } + FilterItems(); + } + + private void FilterItems() + { + Items.Clear(); + var filtered = string.IsNullOrWhiteSpace(_searchText) + ? _allItems + : _allItems.Where(i => i.AliasesString + .Contains(_searchText, StringComparison.OrdinalIgnoreCase)) + .ToList(); + foreach (var item in filtered) + Items.Add(item); } private void AddItem() From 5ae2b5a53d3c3a1d75ca847360ce415b90e0fed8 Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Wed, 1 Apr 2026 00:55:26 +0300 Subject: [PATCH 11/19] fix textbox looks --- .../ViewModels/Settings/Settings.xaml | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/Settings.xaml b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/Settings.xaml index f79763b..28504bb 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/Settings.xaml +++ b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/Settings.xaml @@ -76,26 +76,8 @@ - - - - + Margin="4,0,0,4" + VerticalAlignment="Center" /> Date: Wed, 1 Apr 2026 01:14:20 +0300 Subject: [PATCH 12/19] feat: add RunAs dropdown to item edit mode Adds a ComboBox to select Normal or Administrator run mode when editing an item, bound to the existing RunAs property. Co-Authored-By: Claude Opus 4.6 --- .../ViewModels/Item/ItemView.xaml | 10 ++++++++++ .../ViewModels/Item/ItemViewModel.cs | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemView.xaml b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemView.xaml index c80e7ca..0110332 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemView.xaml +++ b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemView.xaml @@ -118,6 +118,16 @@ + + + + + + diff --git a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemViewModel.cs b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemViewModel.cs index 557ce20..95daed9 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemViewModel.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemViewModel.cs @@ -83,6 +83,12 @@ private set #region Exposed Properties + public static List> RunAsOptions { get; } = new() + { + new(0, "Normal"), + new(1, "Administrator"), + }; + public string AliasesString => string.Join(", ", Aliases); public string ArgsDisplay => string.IsNullOrWhiteSpace(Arguments) ? "" : "Args: " + Arguments; From b1c5114ad05b940e699f0d5bcc5f60be7c27a04a Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Wed, 1 Apr 2026 01:20:56 +0300 Subject: [PATCH 13/19] fix: resolve all CS build warnings Suppress CS1591 (missing XML doc comments) in csproj since this is a plugin, not a public API. Remove unused _commands field (CS0169). Fix nullable warnings (CS8618/CS8604/CS8625) across Main, Item, ItemViewModel, and SettingsViewModel. Co-Authored-By: Claude Opus 4.6 --- .../Flow.Launcher.Plugin.SlickFlow.csproj | 1 + Flow.Launcher.Plugin.SlickFlow/Items/Item.cs | 2 +- Flow.Launcher.Plugin.SlickFlow/Main.cs | 16 +++++++--------- .../ViewModels/Item/ItemViewModel.cs | 8 ++++---- .../ViewModels/Settings/SettingsViewModel.cs | 8 ++++---- 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj b/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj index a0b46ec..7382e2a 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj +++ b/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj @@ -11,6 +11,7 @@ true false true + CS1591 enable enable true diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs b/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs index 52ac71f..b5f6510 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs @@ -4,7 +4,7 @@ namespace Flow.Launcher.Plugin.SlickFlow.Items; public class Item { - public string Id { get; set; } + public string Id { get; set; } = string.Empty; public string Arguments { get; set; } = string.Empty; public string FileName { get; set; } = string.Empty; public string SubTitle { get; set; } = string.Empty; diff --git a/Flow.Launcher.Plugin.SlickFlow/Main.cs b/Flow.Launcher.Plugin.SlickFlow/Main.cs index 0b47af0..23991b7 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Main.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Main.cs @@ -17,18 +17,16 @@ namespace Flow.Launcher.Plugin.SlickFlow; public class SlickFlow : IPlugin, IContextMenu , ISettingProvider { #region Constants - private delegate List CommandHandler(string[] args); - internal PluginInitContext _context; - internal ItemRepository _itemRepo; - private Dictionary _commands; + internal PluginInitContext _context = null!; + internal ItemRepository _itemRepo = null!; public static string AssemblyDirectory { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; - - internal IconHelper _iconHelper ; + + internal IconHelper _iconHelper = null!; internal readonly string _slickFlowIcon = Path.Combine(AssemblyDirectory, "icon.ico"); - internal CommandProcessor _commandProcessor; - internal ItemSearcher _itemSearcher; - internal ItemValidator _itemValidator; + internal CommandProcessor _commandProcessor = null!; + internal ItemSearcher _itemSearcher = null!; + internal ItemValidator _itemValidator = null!; public Settings.Settings Settings { get; set; } = new(); #endregion diff --git a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemViewModel.cs b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemViewModel.cs index 95daed9..ef8d3dc 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemViewModel.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Item/ItemViewModel.cs @@ -40,7 +40,7 @@ public ItemViewModel(Items.Item item, ItemRepository repository) SaveCommand = new RelayCommand(_ => Save(), _ => IsEditing); CancelCommand = new RelayCommand(_ => Cancel(), _ => IsEditing); AddAliasCommand = new RelayCommand(_ => AddAlias(), _ => IsEditing && !string.IsNullOrWhiteSpace(NewAliasInput)); - RemoveAliasCommand = new RelayCommand(obj => RemoveAlias(obj as string), _ => IsEditing); + RemoveAliasCommand = new RelayCommand(obj => RemoveAlias(obj as string ?? string.Empty), _ => IsEditing); DeleteItemCommand = new RelayCommand(_ => DeleteItem()); } @@ -167,9 +167,9 @@ public string IconPath } } - private ImageSource _icon; - - public ImageSource Icon + private ImageSource? _icon; + + public ImageSource? Icon { get => _icon; private set diff --git a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/SettingsViewModel.cs b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/SettingsViewModel.cs index 3d3d437..ca87cfe 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/SettingsViewModel.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/SettingsViewModel.cs @@ -56,7 +56,7 @@ public SettingsViewModel(ItemRepository repo) #region DB File Properties - private string _dbFilePath; + private string _dbFilePath = string.Empty; public string DbFilePath { get => _dbFilePath; @@ -68,7 +68,7 @@ public string DbFilePath } } - private string _savedDbPath; + private string _savedDbPath = string.Empty; public string SavedDbPath { get => _savedDbPath; @@ -84,7 +84,7 @@ private set #region Icon Directory Properties - private string _iconDirPath; + private string _iconDirPath = string.Empty; public string IconDirPath { get => _iconDirPath; @@ -96,7 +96,7 @@ public string IconDirPath } } - private string _savedIconDirPath; + private string _savedIconDirPath = string.Empty; public string SavedIconDirPath { get => _savedIconDirPath; From bc87686c74c6e1f1a536cefbbea6f91692b41f31 Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Wed, 1 Apr 2026 01:25:55 +0300 Subject: [PATCH 14/19] test: add 68 new tests covering ViewModels, Utils, and error paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests for RelayCommand, InverseBooleanConverter, InverseBooleanToVisibilityConverter, Item.ToString, ItemRepository error paths, SettingsViewModel (search filtering, AddItem, property change), and ItemViewModel (edit/save/cancel flow, alias management, display properties, delete event). Total: 90 → 158 tests. Co-Authored-By: Claude Opus 4.6 --- .../Unit/Items/ItemRepositoryErrorTests.cs | 113 ++++++ .../Unit/Items/ItemToStringTests.cs | 64 ++++ .../Utils/InverseBooleanConverterTests.cs | 37 ++ ...nverseBooleanToVisibilityConverterTests.cs | 52 +++ .../Unit/Utils/RelayCommandTests.cs | 69 ++++ .../Unit/ViewModels/ItemViewModelTests.cs | 324 ++++++++++++++++++ .../Unit/ViewModels/SettingsViewModelTests.cs | 162 +++++++++ 7 files changed, 821 insertions(+) create mode 100644 SlickFlow.Tests/Unit/Items/ItemRepositoryErrorTests.cs create mode 100644 SlickFlow.Tests/Unit/Items/ItemToStringTests.cs create mode 100644 SlickFlow.Tests/Unit/Utils/InverseBooleanConverterTests.cs create mode 100644 SlickFlow.Tests/Unit/Utils/InverseBooleanToVisibilityConverterTests.cs create mode 100644 SlickFlow.Tests/Unit/Utils/RelayCommandTests.cs create mode 100644 SlickFlow.Tests/Unit/ViewModels/ItemViewModelTests.cs create mode 100644 SlickFlow.Tests/Unit/ViewModels/SettingsViewModelTests.cs diff --git a/SlickFlow.Tests/Unit/Items/ItemRepositoryErrorTests.cs b/SlickFlow.Tests/Unit/Items/ItemRepositoryErrorTests.cs new file mode 100644 index 0000000..44b6249 --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/ItemRepositoryErrorTests.cs @@ -0,0 +1,113 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; + +namespace SlickFlow.Tests.Unit.Items; + +public class ItemRepositoryErrorTests : IDisposable +{ + private readonly string _tempFile; + private readonly ItemRepository _repo; + + public ItemRepositoryErrorTests() + { + _tempFile = Path.Combine(Path.GetTempPath(), $"slickflow_test_{Guid.NewGuid()}.json"); + _repo = new ItemRepository(_tempFile); + } + + public void Dispose() + { + if (File.Exists(_tempFile)) + File.Delete(_tempFile); + } + + [Fact] + public void UpdateItem_NonExistentId_ThrowsInvalidOperation() + { + var item = new Item("nonexistent", "test.exe"); + + var act = () => _repo.UpdateItem(item); + + act.Should().Throw() + .WithMessage("*nonexistent*"); + } + + [Fact] + public void AddAlias_NonExistentId_ThrowsInvalidOperation() + { + var act = () => _repo.AddAlias("nonexistent", "alias"); + + act.Should().Throw() + .WithMessage("*nonexistent*"); + } + + [Fact] + public void RemoveAlias_NonExistentId_ThrowsInvalidOperation() + { + var act = () => _repo.RemoveAlias("nonexistent", "alias"); + + act.Should().Throw() + .WithMessage("*nonexistent*"); + } + + [Fact] + public void DeleteItem_NonExistentId_DoesNotThrow() + { + var act = () => _repo.DeleteItem("nonexistent"); + + act.Should().NotThrow(); + } + + [Fact] + public void GetItemById_NonExistentId_ReturnsNull() + { + _repo.GetItemById("nonexistent").Should().BeNull(); + } + + [Fact] + public void GetItemByAlias_NonExistentAlias_ReturnsNull() + { + _repo.GetItemByAlias("nonexistent").Should().BeNull(); + } + + [Fact] + public void Load_CorruptedJson_DoesNotThrow() + { + File.WriteAllText(_tempFile, "not valid json {{{"); + var act = () => new ItemRepository(_tempFile); + + act.Should().NotThrow(); + } + + [Fact] + public void Load_CorruptedJson_ReturnsEmptyList() + { + File.WriteAllText(_tempFile, "not valid json {{{"); + var repo = new ItemRepository(_tempFile); + + repo.GetAllItems().Should().BeEmpty(); + } + + [Fact] + public void AddAlias_DuplicateAlias_DoesNotAddAgain() + { + var item = new Item { Aliases = new List { "existing" } }; + var id = _repo.AddItem(item); + + _repo.AddAlias(id, "existing"); + + var retrieved = _repo.GetItemById(id)!; + retrieved.Aliases.Should().HaveCount(1); + } + + [Fact] + public void RemoveAlias_NonExistentAlias_DoesNotThrow() + { + var item = new Item { Aliases = new List { "keep" } }; + var id = _repo.AddItem(item); + + var act = () => _repo.RemoveAlias(id, "nonexistent"); + + act.Should().NotThrow(); + _repo.GetItemById(id)!.Aliases.Should().Contain("keep"); + } +} diff --git a/SlickFlow.Tests/Unit/Items/ItemToStringTests.cs b/SlickFlow.Tests/Unit/Items/ItemToStringTests.cs new file mode 100644 index 0000000..3ff85a2 --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/ItemToStringTests.cs @@ -0,0 +1,64 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; + +namespace SlickFlow.Tests.Unit.Items; + +public class ItemToStringTests +{ + [Fact] + public void ToString_WithAliases_FormatsCorrectly() + { + var item = new Item("42", "notepad.exe", new[] { "np", "note" }) + { + Arguments = "-f test.txt", + RunAs = 1, + StartMode = 2, + ExecCount = 5 + }; + + var result = item.ToString(); + + result.Should().Contain("[#42]"); + result.Should().Contain("notepad.exe"); + result.Should().Contain("-f test.txt"); + result.Should().Contain("np, note"); + result.Should().Contain("RunAs=1"); + result.Should().Contain("StartMode=2"); + result.Should().Contain("ExecCount=5"); + } + + [Fact] + public void ToString_NoAliases_ShowsNone() + { + var item = new Item("1", "calc.exe"); + + var result = item.ToString(); + + result.Should().Contain("Aliases=[none]"); + } + + [Fact] + public void DefaultConstructor_SetsDefaults() + { + var item = new Item(); + + item.Id.Should().Be(string.Empty); + item.FileName.Should().Be(string.Empty); + item.Arguments.Should().Be(string.Empty); + item.SubTitle.Should().Be(string.Empty); + item.WorkingDir.Should().Be(string.Empty); + item.IconPath.Should().Be(string.Empty); + item.RunAs.Should().Be(0); + item.StartMode.Should().Be(0); + item.ExecCount.Should().Be(0); + item.Aliases.Should().BeEmpty(); + } + + [Fact] + public void Constructor_WithNullAliases_UsesEmptyList() + { + var item = new Item("1", "test.exe", null); + + item.Aliases.Should().BeEmpty(); + } +} diff --git a/SlickFlow.Tests/Unit/Utils/InverseBooleanConverterTests.cs b/SlickFlow.Tests/Unit/Utils/InverseBooleanConverterTests.cs new file mode 100644 index 0000000..9b35b91 --- /dev/null +++ b/SlickFlow.Tests/Unit/Utils/InverseBooleanConverterTests.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using System.Globalization; + +namespace SlickFlow.Tests.Unit.Utils; + +public class InverseBooleanConverterTests +{ + private readonly InverseBooleanConverter _converter = new(); + + [Fact] + public void Convert_True_ReturnsFalse() + { + _converter.Convert(true, typeof(bool), null!, CultureInfo.InvariantCulture) + .Should().Be(false); + } + + [Fact] + public void Convert_False_ReturnsTrue() + { + _converter.Convert(false, typeof(bool), null!, CultureInfo.InvariantCulture) + .Should().Be(true); + } + + [Fact] + public void ConvertBack_True_ReturnsFalse() + { + _converter.ConvertBack(true, typeof(bool), null!, CultureInfo.InvariantCulture) + .Should().Be(false); + } + + [Fact] + public void ConvertBack_False_ReturnsTrue() + { + _converter.ConvertBack(false, typeof(bool), null!, CultureInfo.InvariantCulture) + .Should().Be(true); + } +} diff --git a/SlickFlow.Tests/Unit/Utils/InverseBooleanToVisibilityConverterTests.cs b/SlickFlow.Tests/Unit/Utils/InverseBooleanToVisibilityConverterTests.cs new file mode 100644 index 0000000..06abcd4 --- /dev/null +++ b/SlickFlow.Tests/Unit/Utils/InverseBooleanToVisibilityConverterTests.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using System.Globalization; +using System.Windows; + +namespace SlickFlow.Tests.Unit.Utils; + +public class InverseBooleanToVisibilityConverterTests +{ + private readonly InverseBooleanToVisibilityConverter _converter = new(); + + [Fact] + public void Convert_True_ReturnsCollapsed() + { + _converter.Convert(true, typeof(Visibility), null!, CultureInfo.InvariantCulture) + .Should().Be(Visibility.Collapsed); + } + + [Fact] + public void Convert_False_ReturnsVisible() + { + _converter.Convert(false, typeof(Visibility), null!, CultureInfo.InvariantCulture) + .Should().Be(Visibility.Visible); + } + + [Fact] + public void Convert_NonBool_ReturnsVisible() + { + _converter.Convert("not a bool", typeof(Visibility), null!, CultureInfo.InvariantCulture) + .Should().Be(Visibility.Visible); + } + + [Fact] + public void ConvertBack_Visible_ReturnsFalse() + { + _converter.ConvertBack(Visibility.Visible, typeof(bool), null!, CultureInfo.InvariantCulture) + .Should().Be(false); + } + + [Fact] + public void ConvertBack_Collapsed_ReturnsTrue() + { + _converter.ConvertBack(Visibility.Collapsed, typeof(bool), null!, CultureInfo.InvariantCulture) + .Should().Be(true); + } + + [Fact] + public void ConvertBack_Hidden_ReturnsTrue() + { + _converter.ConvertBack(Visibility.Hidden, typeof(bool), null!, CultureInfo.InvariantCulture) + .Should().Be(true); + } +} diff --git a/SlickFlow.Tests/Unit/Utils/RelayCommandTests.cs b/SlickFlow.Tests/Unit/Utils/RelayCommandTests.cs new file mode 100644 index 0000000..e3c711f --- /dev/null +++ b/SlickFlow.Tests/Unit/Utils/RelayCommandTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Utils; + +namespace SlickFlow.Tests.Unit.Utils; + +public class RelayCommandTests +{ + [Fact] + public void Execute_InvokesAction() + { + object? received = null; + var cmd = new RelayCommand(p => received = p); + + cmd.Execute("hello"); + + received.Should().Be("hello"); + } + + [Fact] + public void Execute_WithNullParameter_InvokesAction() + { + var invoked = false; + var cmd = new RelayCommand(_ => invoked = true); + + cmd.Execute(null); + + invoked.Should().BeTrue(); + } + + [Fact] + public void CanExecute_ReturnsTrue_WhenNoCanExecuteProvided() + { + var cmd = new RelayCommand(_ => { }); + + cmd.CanExecute(null).Should().BeTrue(); + } + + [Fact] + public void CanExecute_DelegatesToCanExecuteFunc() + { + var cmd = new RelayCommand(_ => { }, p => p is string s && s == "yes"); + + cmd.CanExecute("yes").Should().BeTrue(); + cmd.CanExecute("no").Should().BeFalse(); + cmd.CanExecute(null).Should().BeFalse(); + } + + [Fact] + public void RaiseCanExecuteChanged_FiresEvent() + { + var cmd = new RelayCommand(_ => { }); + var fired = false; + cmd.CanExecuteChanged += (_, _) => fired = true; + + cmd.RaiseCanExecuteChanged(); + + fired.Should().BeTrue(); + } + + [Fact] + public void RaiseCanExecuteChanged_DoesNotThrow_WhenNoSubscribers() + { + var cmd = new RelayCommand(_ => { }); + + var act = () => cmd.RaiseCanExecuteChanged(); + + act.Should().NotThrow(); + } +} diff --git a/SlickFlow.Tests/Unit/ViewModels/ItemViewModelTests.cs b/SlickFlow.Tests/Unit/ViewModels/ItemViewModelTests.cs new file mode 100644 index 0000000..58b16e8 --- /dev/null +++ b/SlickFlow.Tests/Unit/ViewModels/ItemViewModelTests.cs @@ -0,0 +1,324 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.ViewModels.Item; + +namespace SlickFlow.Tests.Unit.ViewModels; + +public class ItemViewModelTests : IDisposable +{ + private readonly string _tempFile; + private readonly ItemRepository _repo; + + public ItemViewModelTests() + { + _tempFile = Path.Combine(Path.GetTempPath(), $"slickflow_ivm_test_{Guid.NewGuid()}.json"); + _repo = new ItemRepository(_tempFile); + } + + public void Dispose() + { + if (File.Exists(_tempFile)) + File.Delete(_tempFile); + } + + private ItemViewModel CreateVm(Item? item = null) + { + item ??= new Item("1", "notepad.exe", new[] { "np", "note" }) + { + Arguments = "-f file.txt", + SubTitle = "Text Editor", + WorkingDir = @"C:\temp", + RunAs = 0, + StartMode = 1, + ExecCount = 3 + }; + _repo.AddItem(item); + return new ItemViewModel(item, _repo); + } + + #region Display Properties + + [Fact] + public void AliasesString_JoinsAliases() + { + var vm = CreateVm(); + vm.AliasesString.Should().Be("np, note"); + } + + [Fact] + public void ArgsDisplay_ShowsArgsPrefix() + { + var vm = CreateVm(); + vm.ArgsDisplay.Should().Be("Args: -f file.txt"); + } + + [Fact] + public void ArgsDisplay_EmptyArgs_ReturnsEmpty() + { + var item = new Item("1", "test.exe") { Arguments = "" }; + _repo.AddItem(item); + var vm = new ItemViewModel(item, _repo); + + vm.ArgsDisplay.Should().BeEmpty(); + } + + [Fact] + public void WorkingDirDisplay_ShowsPrefix() + { + var vm = CreateVm(); + vm.WorkingDirDisplay.Should().Be(@"working dir: C:\temp"); + } + + [Fact] + public void WorkingDirDisplay_EmptyDir_ReturnsEmpty() + { + var item = new Item("1", "test.exe") { WorkingDir = "" }; + _repo.AddItem(item); + var vm = new ItemViewModel(item, _repo); + + vm.WorkingDirDisplay.Should().BeEmpty(); + } + + [Fact] + public void ExecCount_ReflectsItemValue() + { + var vm = CreateVm(); + vm.ExecCount.Should().Be(3); + } + + [Fact] + public void RunAsOptions_ContainsBothOptions() + { + ItemViewModel.RunAsOptions.Should().HaveCount(2); + ItemViewModel.RunAsOptions.Should().Contain(kvp => kvp.Key == 0 && kvp.Value == "Normal"); + ItemViewModel.RunAsOptions.Should().Contain(kvp => kvp.Key == 1 && kvp.Value == "Administrator"); + } + + #endregion + + #region Edit Flow + + [Fact] + public void IsEditing_InitiallyFalse() + { + var vm = CreateVm(); + vm.IsEditing.Should().BeFalse(); + } + + [Fact] + public void EditCommand_SetsIsEditingTrue() + { + var vm = CreateVm(); + vm.EditCommand.Execute(null); + vm.IsEditing.Should().BeTrue(); + } + + [Fact] + public void EditCommand_CannotExecute_WhenAlreadyEditing() + { + var vm = CreateVm(); + vm.EditCommand.Execute(null); + vm.EditCommand.CanExecute(null).Should().BeFalse(); + } + + [Fact] + public void SaveCommand_CannotExecute_WhenNotEditing() + { + var vm = CreateVm(); + vm.SaveCommand.CanExecute(null).Should().BeFalse(); + } + + [Fact] + public void CancelCommand_CannotExecute_WhenNotEditing() + { + var vm = CreateVm(); + vm.CancelCommand.CanExecute(null).Should().BeFalse(); + } + + [Fact] + public void SaveCommand_PersistsChanges() + { + var vm = CreateVm(); + vm.EditCommand.Execute(null); + + vm.FileName = "newfile.exe"; + vm.Arguments = "--new-arg"; + vm.SubTitle = "New Title"; + vm.RunAs = 1; + vm.StartMode = 2; + vm.WorkingDir = @"C:\new"; + + vm.SaveCommand.Execute(null); + + vm.IsEditing.Should().BeFalse(); + vm.FileName.Should().Be("newfile.exe"); + vm.Arguments.Should().Be("--new-arg"); + vm.SubTitle.Should().Be("New Title"); + vm.RunAs.Should().Be(1); + vm.StartMode.Should().Be(2); + vm.WorkingDir.Should().Be(@"C:\new"); + } + + [Fact] + public void CancelCommand_RevertsChanges() + { + var vm = CreateVm(); + var originalFileName = vm.FileName; + + vm.EditCommand.Execute(null); + vm.FileName = "changed.exe"; + vm.CancelCommand.Execute(null); + + vm.IsEditing.Should().BeFalse(); + vm.FileName.Should().Be(originalFileName); + } + + [Fact] + public void CancelCommand_ClearsNewAliasInput() + { + var vm = CreateVm(); + vm.EditCommand.Execute(null); + vm.NewAliasInput = "draft"; + vm.CancelCommand.Execute(null); + + vm.NewAliasInput.Should().BeEmpty(); + } + + #endregion + + #region Alias Management + + [Fact] + public void AddAliasCommand_AddsAlias() + { + var vm = CreateVm(); + vm.EditCommand.Execute(null); + vm.NewAliasInput = "new-alias"; + vm.AddAliasCommand.Execute(null); + + vm.Aliases.Should().Contain("new-alias"); + } + + [Fact] + public void AddAliasCommand_ClearsInput() + { + var vm = CreateVm(); + vm.EditCommand.Execute(null); + vm.NewAliasInput = "new-alias"; + vm.AddAliasCommand.Execute(null); + + vm.NewAliasInput.Should().BeEmpty(); + } + + [Fact] + public void AddAliasCommand_SkipsDuplicateCaseInsensitive() + { + var vm = CreateVm(); + vm.EditCommand.Execute(null); + var countBefore = vm.Aliases.Count; + + vm.NewAliasInput = "NP"; + vm.AddAliasCommand.Execute(null); + + vm.Aliases.Should().HaveCount(countBefore); + } + + [Fact] + public void AddAliasCommand_CannotExecute_WhenInputEmpty() + { + var vm = CreateVm(); + vm.EditCommand.Execute(null); + vm.NewAliasInput = ""; + + vm.AddAliasCommand.CanExecute(null).Should().BeFalse(); + } + + [Fact] + public void AddAliasCommand_TrimsWhitespace() + { + var vm = CreateVm(); + vm.EditCommand.Execute(null); + vm.NewAliasInput = " trimmed "; + vm.AddAliasCommand.Execute(null); + + vm.Aliases.Should().Contain("trimmed"); + } + + [Fact] + public void RemoveAliasCommand_RemovesAlias() + { + var vm = CreateVm(); + vm.EditCommand.Execute(null); + vm.RemoveAliasCommand.Execute("np"); + + vm.Aliases.Should().NotContain("np"); + } + + #endregion + + #region Property Changed Notifications + + [Fact] + public void FileName_RaisesPropertyChanged() + { + var vm = CreateVm(); + var raised = new List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName!); + + vm.FileName = "changed.exe"; + + raised.Should().Contain("FileName"); + } + + [Fact] + public void IsEditing_RaisesPropertyChanged() + { + var vm = CreateVm(); + var raised = new List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName!); + + vm.EditCommand.Execute(null); + + raised.Should().Contain("IsEditing"); + } + + [Fact] + public void NewAliasInput_RaisesPropertyChanged() + { + var vm = CreateVm(); + var raised = new List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName!); + + vm.NewAliasInput = "test"; + + raised.Should().Contain("NewAliasInput"); + } + + #endregion + + #region Delete + + [Fact] + public void DeleteItemCommand_RaisesDeletedEvent() + { + var vm = CreateVm(); + var deleted = false; + vm.Deleted += () => deleted = true; + + vm.DeleteItemCommand.Execute(null); + + deleted.Should().BeTrue(); + } + + [Fact] + public void DeleteItemCommand_SetsIsEditingFalse() + { + var vm = CreateVm(); + vm.EditCommand.Execute(null); + vm.DeleteItemCommand.Execute(null); + + vm.IsEditing.Should().BeFalse(); + } + + #endregion +} diff --git a/SlickFlow.Tests/Unit/ViewModels/SettingsViewModelTests.cs b/SlickFlow.Tests/Unit/ViewModels/SettingsViewModelTests.cs new file mode 100644 index 0000000..16d683c --- /dev/null +++ b/SlickFlow.Tests/Unit/ViewModels/SettingsViewModelTests.cs @@ -0,0 +1,162 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.ViewModels.Settings; + +namespace SlickFlow.Tests.Unit.ViewModels; + +public class SettingsViewModelTests : IDisposable +{ + private readonly string _tempFile; + private readonly ItemRepository _repo; + private readonly SettingsViewModel _vm; + + public SettingsViewModelTests() + { + _tempFile = Path.Combine(Path.GetTempPath(), $"slickflow_vm_test_{Guid.NewGuid()}.json"); + _repo = new ItemRepository(_tempFile); + _vm = new SettingsViewModel(_repo); + } + + public void Dispose() + { + if (File.Exists(_tempFile)) + File.Delete(_tempFile); + } + + [Fact] + public void Constructor_LoadsItemsFromRepo() + { + _repo.AddItem(new Item { Aliases = new List { "alpha" } }); + _repo.AddItem(new Item { Aliases = new List { "beta" } }); + + var vm = new SettingsViewModel(_repo); + + vm.Items.Should().HaveCount(2); + } + + [Fact] + public void SearchText_FiltersItems() + { + _repo.AddItem(new Item { Aliases = new List { "notepad" } }); + _repo.AddItem(new Item { Aliases = new List { "calculator" } }); + _repo.AddItem(new Item { Aliases = new List { "note-taker" } }); + + var vm = new SettingsViewModel(_repo); + vm.SearchText = "note"; + + vm.Items.Should().HaveCount(2); + vm.Items.Should().AllSatisfy(i => + i.AliasesString.Should().ContainEquivalentOf("note")); + } + + [Fact] + public void SearchText_CaseInsensitive() + { + _repo.AddItem(new Item { Aliases = new List { "Notepad" } }); + _repo.AddItem(new Item { Aliases = new List { "calculator" } }); + + var vm = new SettingsViewModel(_repo); + vm.SearchText = "NOTEPAD"; + + vm.Items.Should().HaveCount(1); + } + + [Fact] + public void SearchText_EmptyString_ShowsAll() + { + _repo.AddItem(new Item { Aliases = new List { "alpha" } }); + _repo.AddItem(new Item { Aliases = new List { "beta" } }); + + var vm = new SettingsViewModel(_repo); + vm.SearchText = "alpha"; + vm.Items.Should().HaveCount(1); + + vm.SearchText = ""; + vm.Items.Should().HaveCount(2); + } + + [Fact] + public void SearchText_RaisesPropertyChanged() + { + var raised = new List(); + _vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName!); + + _vm.SearchText = "test"; + + raised.Should().Contain("SearchText"); + } + + [Fact] + public void DbFilePath_RaisesPropertyChanged() + { + var raised = new List(); + _vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName!); + + _vm.DbFilePath = "new/path.json"; + + raised.Should().Contain("DbFilePath"); + } + + [Fact] + public void IconDirPath_RaisesPropertyChanged() + { + var raised = new List(); + _vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName!); + + _vm.IconDirPath = "new/icon/dir"; + + raised.Should().Contain("IconDirPath"); + } + + [Fact] + public void DbFilePath_SameValue_DoesNotRaisePropertyChanged() + { + _vm.DbFilePath = "initial"; + var raised = false; + _vm.PropertyChanged += (_, _) => raised = true; + + _vm.DbFilePath = "initial"; + + raised.Should().BeFalse(); + } + + [Fact] + public void AddItemCommand_AddsNewItem() + { + var initialCount = _vm.Items.Count; + + _vm.AddItemCommand.Execute(null); + + _vm.Items.Should().HaveCount(initialCount + 1); + } + + [Fact] + public void AddItemCommand_GeneratesUniqueAliases() + { + _vm.AddItemCommand.Execute(null); + _vm.AddItemCommand.Execute(null); + + var aliases = _vm.Items.Select(i => i.AliasesString).ToList(); + aliases.Should().OnlyHaveUniqueItems(); + } + + [Fact] + public void ReloadItemsCommand_RefreshesItems() + { + _repo.AddItem(new Item { Aliases = new List { "external" } }); + + _vm.ReloadItemsCommand.Execute(null); + + _vm.Items.Should().Contain(i => i.AliasesString.Contains("external")); + } + + [Fact] + public void Commands_AreNotNull() + { + _vm.SaveCommand.Should().NotBeNull(); + _vm.BrowseDbFolderCommand.Should().NotBeNull(); + _vm.BrowseIconFolderCommand.Should().NotBeNull(); + _vm.ReloadItemsCommand.Should().NotBeNull(); + _vm.AddItemCommand.Should().NotBeNull(); + } +} From 27dab2031e807baf3f1beae8e8e008b01bff0453 Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Wed, 1 Apr 2026 01:59:25 +0300 Subject: [PATCH 15/19] refactor: split monolithic IconHelper into focused classes Break the 525-line IconHelper into 5 single-responsibility classes under Utils/Icons/: - ShellIconExtractor: native P/Invoke shell icon extraction - FaviconDownloader: HTTP favicon download (patterns, HTML, root) - ImageConverter: bytes/Icon to PNG conversion - FileNameSanitizer: safe file name generation - IconHelper: slim orchestrator delegating to the above All functionality preserved. 158 tests pass. Co-Authored-By: Claude Opus 4.6 --- .../CommandHandlers/SetIconCommandHandler.cs | 1 + .../Commands/CommandProcessor.cs | 1 + Flow.Launcher.Plugin.SlickFlow/Main.cs | 1 + .../Utils/IconHelper.cs | 525 ------------------ .../Utils/Icons/FaviconDownloader.cs | 149 +++++ .../Utils/Icons/FileNameSanitizer.cs | 28 + .../Utils/Icons/IconHelper.cs | 169 ++++++ .../Utils/Icons/ImageConverter.cs | 64 +++ .../Utils/Icons/ShellIconExtractor.cs | 72 +++ .../Unit/Commands/CommandProcessorTests.cs | 1 + .../Commands/SetIconCommandHandlerTests.cs | 1 + 11 files changed, 487 insertions(+), 525 deletions(-) delete mode 100644 Flow.Launcher.Plugin.SlickFlow/Utils/IconHelper.cs create mode 100644 Flow.Launcher.Plugin.SlickFlow/Utils/Icons/FaviconDownloader.cs create mode 100644 Flow.Launcher.Plugin.SlickFlow/Utils/Icons/FileNameSanitizer.cs create mode 100644 Flow.Launcher.Plugin.SlickFlow/Utils/Icons/IconHelper.cs create mode 100644 Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ImageConverter.cs create mode 100644 Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ShellIconExtractor.cs diff --git a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/SetIconCommandHandler.cs b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/SetIconCommandHandler.cs index 3453f7e..1849748 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/SetIconCommandHandler.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/SetIconCommandHandler.cs @@ -1,6 +1,7 @@ using Flow.Launcher.Plugin.SlickFlow.Items; using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; using Flow.Launcher.Plugin.SlickFlow.Utils; +using Flow.Launcher.Plugin.SlickFlow.Utils.Icons; namespace Flow.Launcher.Plugin.SlickFlow.Commands.CommandHandlers; diff --git a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandProcessor.cs b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandProcessor.cs index 7f149ae..c33578f 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandProcessor.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandProcessor.cs @@ -3,6 +3,7 @@ using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; using Flow.Launcher.Plugin.SlickFlow.items; using Flow.Launcher.Plugin.SlickFlow.Utils; +using Flow.Launcher.Plugin.SlickFlow.Utils.Icons; namespace Flow.Launcher.Plugin.SlickFlow.Commands; diff --git a/Flow.Launcher.Plugin.SlickFlow/Main.cs b/Flow.Launcher.Plugin.SlickFlow/Main.cs index 23991b7..374574c 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Main.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Main.cs @@ -7,6 +7,7 @@ using Flow.Launcher.Plugin.SlickFlow.Items; using Flow.Launcher.Plugin.SlickFlow.Settings; using Flow.Launcher.Plugin.SlickFlow.Utils; +using Flow.Launcher.Plugin.SlickFlow.Utils.Icons; using Flow.Launcher.Plugin.SlickFlow.ViewModels.Settings; namespace Flow.Launcher.Plugin.SlickFlow; diff --git a/Flow.Launcher.Plugin.SlickFlow/Utils/IconHelper.cs b/Flow.Launcher.Plugin.SlickFlow/Utils/IconHelper.cs deleted file mode 100644 index 1609c83..0000000 --- a/Flow.Launcher.Plugin.SlickFlow/Utils/IconHelper.cs +++ /dev/null @@ -1,525 +0,0 @@ -using System.Collections.Concurrent; -using System.Net.Http; -using System.Drawing.Imaging; -using System.IO; -using System.Net; -using System.Runtime.InteropServices; -using System.Text.RegularExpressions; -using System.Windows; -using System.Windows.Media.Imaging; - -namespace Flow.Launcher.Plugin.SlickFlow.Utils -{ - /// - /// Provides helper methods for retrieving an icon (local or remote) and storing it as a PNG file. - /// - public sealed class IconHelper - { - #region Native interop - private const uint SHGFI_ICON = 0x000000100; - private const uint SHGFI_LARGEICON = 0x000000000; - private const uint SHGFI_SMALLICON = 0x000000001; - - [StructLayout(LayoutKind.Sequential)] - private struct SHFILEINFO - { - public IntPtr hIcon; - public int iIcon; - public uint dwAttributes; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] - public string szDisplayName; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] - public string szTypeName; - } - - [DllImport("shell32.dll", CharSet = CharSet.Unicode)] - private static extern IntPtr SHGetFileInfo( - string pszPath, - uint dwFileAttributes, - ref SHFILEINFO psfi, - uint cbFileInfo, - uint uFlags - ); - - [DllImport("user32.dll")] - private static extern bool DestroyIcon(IntPtr hIcon); - - #endregion - - #region Fields - - private static readonly HttpClient HttpClient = new( - new HttpClientHandler - { - AllowAutoRedirect = true, - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate - }) - { - Timeout = TimeSpan.FromSeconds(5) - }; - - - - private readonly ConcurrentDictionary _attempts = new(); - private readonly Dictionary _faviconUrlPatterns = new() - { - // “Default” is handled manually because it needs the scheme. - ["Default"] = "{0}://{1}/favicon.ico", - ["DuckDuckGo"] = "https://icons.duckduckgo.com/ip2/{0}.ico", - ["Google"] = "https://www.google.com/s2/favicons?domain_url={0}" - }; - - private readonly string _iconFolder; - private readonly Action? _log; // optional logger - - #endregion - - #region Construction - - /// - /// Creates a new . - /// - /// - /// Folder where the PNG files will be stored. The folder is created if it does not exist. - /// - /// - /// Optional logger – any string will be forwarded to the host (for debugging). - /// - public IconHelper(string iconFolder, Action? log = null) - { - - _iconFolder = Path.GetFullPath(iconFolder); - Directory.CreateDirectory(_iconFolder); - _log = log; - } - - static IconHelper() - { - HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd( - "Mozilla/5.0 (Windows NT 10.0; IconHelper/1.0)"); - } - - #endregion - - #region Public API (async & sync) - - /// - /// Tries to retrieve an icon for and saves it as {itemId}.png. - /// - /// Local file/dir or a web URL. - /// Identifier used for the file name. All illegal file‑name characters are stripped. - /// Optional cancellation token. - /// true on success, otherwise false. - public async Task<(bool Success, string SavedPath)> TrySaveIconAsync( - string pathOrUrl, - string itemId, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - string safeFileName = MakeSafeFileName(itemId) + ".png"; - string targetPath = Path.Combine(_iconFolder, safeFileName); - - // Fast path - if (File.Exists(targetPath)) - return (true, targetPath); - - string attemptKey = $"{pathOrUrl}|{safeFileName}"; - - try - { - // ---- URL first (cheap check, avoids filesystem hit) ---- - if (Uri.TryCreate(pathOrUrl, UriKind.Absolute, out Uri? uri) && - (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || - uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase))) - { - if (await TryDownloadFaviconFromPatternsAsync(uri, targetPath, cancellationToken).ConfigureAwait(false) || - await TryExtractIconFromHtmlAsync(uri, targetPath, cancellationToken).ConfigureAwait(false) || - await TryDownloadRootFaviconAsync(uri, targetPath, cancellationToken).ConfigureAwait(false)) - { - _attempts.TryRemove(attemptKey, out _); - return (true, targetPath); - } - } - // ---- Local file / directory ---- - else if (File.Exists(pathOrUrl) || Directory.Exists(pathOrUrl)) - { - if (ExtractIconFromPath(pathOrUrl, targetPath)) - { - _attempts.TryRemove(attemptKey, out _); - return (true, targetPath); - } - } - - // ---- Failure bookkeeping ---- - int attempts = _attempts.AddOrUpdate( - attemptKey, - _ => 1, - (_, cur) => cur + 1); - - if (attempts > 3) - _log?.Invoke($"IconHelper: giving up after {attempts} attempts for \"{pathOrUrl}\""); - } - catch (Exception ex) when (!ex.IsCritical()) - { - _log?.Invoke($"IconHelper: unexpected error for \"{pathOrUrl}\" – {ex.Message}"); - } - - return (false, string.Empty); - } - - - /// - /// Set a custom icon for an item. The source can be a local image file or a remote URL. - /// - /// File path or URL. - /// Identifier used to name the PNG file. - /// CancellationToken - /// Full path of the saved PNG on success, otherwise string.Empty. - public async Task SetCustomIconAsync(string iconSource, string itemId, CancellationToken ct = default) - { - string safeFileName = MakeSafeFileName(itemId) + ".png"; - string destPath = Path.Combine(_iconFolder, safeFileName); - - try - { - // Local file → just copy - if (File.Exists(iconSource)) - { - File.Copy(iconSource, destPath, overwrite: true); - return destPath; - } - - // Remote URL → download the image (re‑use the same pipeline as SaveIcon) - var result = await TrySaveIconAsync(iconSource, itemId, ct); - return result.Success ? result.SavedPath : ""; - } - catch (Exception ex) when (!ex.IsCritical()) - { - _log?.Invoke($"IconHelper:SetCustomIcon failed for \"{iconSource}\" {ex.Message}"); - } - - return string.Empty; - } - - /// - /// Synchronous version of (blocks the calling thread). - /// - public string SetCustomIcon(string iconSource, string itemId) - => SetCustomIconAsync(iconSource, itemId).GetAwaiter().GetResult(); - - #endregion - - #region Private helpers - - /// - /// Normalises a user supplied identifier into a safe file name. - /// - private static string MakeSafeFileName(string input) - { - var invalid = Path.GetInvalidFileNameChars(); - var sb = new System.Text.StringBuilder(input.Length); - foreach (char ch in input) - { - if (Array.IndexOf(invalid, ch) < 0 && ch != Path.DirectorySeparatorChar && ch != Path.AltDirectorySeparatorChar) - sb.Append(ch); - } - // Trim any leading/trailing dots (Windows doesn't like those) - return sb.ToString().Trim('.').Trim(); - } - - - private static Icon? GetShellIcon(string path, bool largeIcon) - { - SHFILEINFO shinfo = new SHFILEINFO(); - - uint flags = - SHGFI_ICON | - (largeIcon ? SHGFI_LARGEICON : SHGFI_SMALLICON); - - SHGetFileInfo( - path, - 0, - ref shinfo, - (uint)Marshal.SizeOf(shinfo), - flags - ); - - if (shinfo.hIcon == IntPtr.Zero) - return null; - - // Clone so we can destroy original handle - Icon icon = (Icon)Icon.FromHandle(shinfo.hIcon).Clone(); - DestroyIcon(shinfo.hIcon); - - return icon; - } - - - - - - #region Local icon extraction - - /// - /// Extracts a system icon from a file **or** a directory and writes it as a PNG. - /// Returns true on success. - /// - private bool ExtractIconFromPath(string path, string pngPath) - { - try - { - Icon? sysIcon = null; - IntPtr nativeIcon = IntPtr.Zero; - // Folder ? - if (Directory.Exists(path)) - { - sysIcon = GetShellIcon(path, true); - if (sysIcon == null) - sysIcon = GetShellIcon(path, false); - } - else if (File.Exists(path)) - { - sysIcon = Icon.ExtractAssociatedIcon(path); - } - - if (sysIcon == null) - return false; - - using (sysIcon) - { - SaveIconToPng(sysIcon, pngPath); - } - // Release native handle we got from SHGetFileInfo - if (nativeIcon != IntPtr.Zero) - DestroyIcon(nativeIcon); - return true; - } - catch (Exception ex) when (!ex.IsCritical()) - { - _log?.Invoke($"ExtractIconFromPath failed for \"{path}\" - {ex.Message}"); - return false; - } - } - - /// - /// Writes a to a PNG file using WPF interop (preserves transparency). - /// - private void SaveIconToPng(Icon icon, string destinationPath) - { - // Convert the HICON to a WPF BitmapSource – this respects alpha channels. - IntPtr hIcon = icon.Handle; - BitmapSource src = System.Windows.Interop.Imaging.CreateBitmapSourceFromHIcon( - hIcon, - Int32Rect.Empty, - BitmapSizeOptions.FromEmptyOptions()); - - // Ensure the source is frozen so it can be used across threads and the underlying handle can be released. - src.Freeze(); - - // Encode to PNG. - var encoder = new PngBitmapEncoder(); - encoder.Frames.Add(BitmapFrame.Create(src)); - - using var file = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None); - encoder.Save(file); - } - - #endregion - - #region Remote favicon download (known services) - - private async Task TryDownloadFaviconFromPatternsAsync( - Uri siteUri, - string pngPath, - CancellationToken ct) - { - foreach (var kvp in _faviconUrlPatterns) - { - IEnumerable urls = kvp.Key == "Default" - ? new[] - { - $"https://{siteUri.Host}/favicon.ico", - $"http://{siteUri.Host}/favicon.ico" - } - : new[] - { - string.Format(kvp.Value, siteUri.Host) - }; - - foreach (string requestUrl in urls) - { - if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out Uri? requestUri)) - continue; - - try - { - using var resp = await HttpClient.GetAsync(requestUri, ct).ConfigureAwait(false); - if (!resp.IsSuccessStatusCode) - continue; - - byte[] data = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); - - if (TrySaveBytesAsPng(data, pngPath)) - return true; - } - catch (Exception ex) when (!ex.IsCritical()) - { - _log?.Invoke($"Favicon service \"{kvp.Key}\" failed for \"{siteUri.Host}\" – {ex.Message}"); - } - } - } - - return false; -} - - - #endregion - - #region Remote favicon via HTML - - // This regex tolerates any order of attributes and accepts single‑ or double‑quoted values. - // It also captures URLs that contain spaces (escaped as %20) and ignores any trailing '>' - - private static readonly Regex IconLinkRegex = new( - @"]*\brel\s*=\s*['""][^'""]*icon[^'""]*['""])[^>]*>", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - private static readonly Regex HrefRegex = new( - @"\bhref\s*=\s*['""](?[^'""]+)['""]", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - private static readonly Regex BaseHrefRegex = new( - @"]*\bhref\s*=\s*['""](?[^'""]+)['""]", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - - private async Task TryExtractIconFromHtmlAsync( - Uri siteUri, - string pngPath, - CancellationToken ct) - { - try - { - string html = await HttpClient.GetStringAsync(siteUri, ct).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(html)) - return false; - - // Detect - Uri baseUri = siteUri; - var baseMatch = BaseHrefRegex.Match(html); - if (baseMatch.Success && - Uri.TryCreate(baseMatch.Groups["url"].Value, UriKind.Absolute, out var b)) - { - baseUri = b; - } - - foreach (Match linkMatch in IconLinkRegex.Matches(html)) - { - var hrefMatch = HrefRegex.Match(linkMatch.Value); - if (!hrefMatch.Success) - continue; - - string rawHref = hrefMatch.Groups["url"].Value.Trim(); - if (rawHref.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) - continue; - - Uri resolved; - try - { - resolved = new Uri(baseUri, rawHref); - } - catch - { - continue; - } - - using var resp = await HttpClient.GetAsync(resolved, ct).ConfigureAwait(false); - if (!resp.IsSuccessStatusCode) - continue; - - byte[] data = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); - - if (TrySaveBytesAsPng(data, pngPath)) - return true; - } - - return false; - } - catch (Exception ex) when (!ex.IsCritical()) - { - _log?.Invoke($"HTML favicon extraction failed for \"{siteUri}\" – {ex.Message}"); - return false; - } - } - - - private async Task TryDownloadRootFaviconAsync( - Uri siteUri, - string pngPath, - CancellationToken ct) - { - try - { - var uri = new Uri($"{siteUri.Scheme}://{siteUri.Host}/favicon.ico"); - using var resp = await HttpClient.GetAsync(uri, ct).ConfigureAwait(false); - if (!resp.IsSuccessStatusCode) - return false; - - byte[] data = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); - return TrySaveBytesAsPng(data, pngPath); - } - catch - { - return false; - } - } - - - - - #endregion - - #region Byte-to-PNG conversion - - /// - /// Tries to interpret as an image (ICO, PNG, JPEG, GIF, BMP, …) and writes it as PNG. - /// - private static bool TrySaveBytesAsPng(byte[] data, string pngPath) - { - if ( data.Length == 0) - return false; - - try - { - using var ms = new MemoryStream(data, writable: false); - // ArgumentException on unsupported data – we swallow that and fall back to - // the Bitmap constructor which has a slightly different detection order. - using Image img = Image.FromStream(ms, useEmbeddedColorManagement: false, validateImageData: false); - - // Ensure we are writing a 32‑bpp PNG so the alpha channel is kept. - using var bmp = new Bitmap(img.Width, img.Height, PixelFormat.Format32bppArgb); - using (Graphics g = Graphics.FromImage(bmp)) - { - g.Clear(Color.Transparent); - g.DrawImageUnscaled(img, 0, 0); - } - - bmp.Save(pngPath, ImageFormat.Png); - return true; - } - catch (Exception ex) when (!ex.IsCritical()) - { - // The data was not a recognizable image type. - // Log for debugging – the caller will just get “false”. - // (In production you may replace this with a proper logger.) - System.Diagnostics.Debug.WriteLine($"TrySaveBytesAsPng failed: {ex.Message}"); - return false; - } - } - - #endregion - - #endregion - } -} diff --git a/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/FaviconDownloader.cs b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/FaviconDownloader.cs new file mode 100644 index 0000000..0a8afb9 --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/FaviconDownloader.cs @@ -0,0 +1,149 @@ +using System.Net.Http; +using System.Text.RegularExpressions; + +namespace Flow.Launcher.Plugin.SlickFlow.Utils.Icons; + +/// +/// Downloads favicons from websites using known service patterns and HTML link extraction. +/// +internal sealed class FaviconDownloader +{ + private static readonly Regex IconLinkRegex = new( + @"]*\brel\s*=\s*['""][^'""]*icon[^'""]*['""])[^>]*>", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex HrefRegex = new( + @"\bhref\s*=\s*['""](?[^'""]+)['""]", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex BaseHrefRegex = new( + @"]*\bhref\s*=\s*['""](?[^'""]+)['""]", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private readonly HttpClient _httpClient; + private readonly Action? _log; + + private readonly Dictionary _faviconUrlPatterns = new() + { + ["Default"] = "{0}://{1}/favicon.ico", + ["DuckDuckGo"] = "https://icons.duckduckgo.com/ip2/{0}.ico", + ["Google"] = "https://www.google.com/s2/favicons?domain_url={0}" + }; + + public FaviconDownloader(HttpClient httpClient, Action? log = null) + { + _httpClient = httpClient; + _log = log; + } + + /// + /// Tries multiple favicon sources in order: known service patterns, HTML link tags, root /favicon.ico. + /// Returns the downloaded bytes on success, or null on failure. + /// + public async Task TryDownloadAsync(Uri siteUri, CancellationToken ct) + { + return await TryFromPatternsAsync(siteUri, ct) + ?? await TryFromHtmlAsync(siteUri, ct) + ?? await TryFromRootAsync(siteUri, ct); + } + + private async Task TryFromPatternsAsync(Uri siteUri, CancellationToken ct) + { + foreach (var kvp in _faviconUrlPatterns) + { + IEnumerable urls = kvp.Key == "Default" + ? new[] + { + $"https://{siteUri.Host}/favicon.ico", + $"http://{siteUri.Host}/favicon.ico" + } + : new[] { string.Format(kvp.Value, siteUri.Host) }; + + foreach (string requestUrl in urls) + { + if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out var requestUri)) + continue; + + try + { + var data = await DownloadBytesAsync(requestUri, ct); + if (data != null) + return data; + } + catch (Exception ex) when (!ex.IsCritical()) + { + _log?.Invoke($"Favicon service \"{kvp.Key}\" failed for \"{siteUri.Host}\" - {ex.Message}"); + } + } + } + + return null; + } + + private async Task TryFromHtmlAsync(Uri siteUri, CancellationToken ct) + { + try + { + string html = await _httpClient.GetStringAsync(siteUri, ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(html)) + return null; + + Uri baseUri = siteUri; + var baseMatch = BaseHrefRegex.Match(html); + if (baseMatch.Success && + Uri.TryCreate(baseMatch.Groups["url"].Value, UriKind.Absolute, out var b)) + { + baseUri = b; + } + + foreach (Match linkMatch in IconLinkRegex.Matches(html)) + { + var hrefMatch = HrefRegex.Match(linkMatch.Value); + if (!hrefMatch.Success) + continue; + + string rawHref = hrefMatch.Groups["url"].Value.Trim(); + if (rawHref.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + continue; + + Uri resolved; + try { resolved = new Uri(baseUri, rawHref); } + catch { continue; } + + var data = await DownloadBytesAsync(resolved, ct); + if (data != null) + return data; + } + + return null; + } + catch (Exception ex) when (!ex.IsCritical()) + { + _log?.Invoke($"HTML favicon extraction failed for \"{siteUri}\" - {ex.Message}"); + return null; + } + } + + private async Task TryFromRootAsync(Uri siteUri, CancellationToken ct) + { + try + { + var uri = new Uri($"{siteUri.Scheme}://{siteUri.Host}/favicon.ico"); + return await DownloadBytesAsync(uri, ct); + } + catch + { + return null; + } + } + + private async Task DownloadBytesAsync(Uri uri, CancellationToken ct) + { + using var resp = await _httpClient.GetAsync(uri, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + return null; + + var data = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + return data.Length > 0 ? data : null; + } +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/FileNameSanitizer.cs b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/FileNameSanitizer.cs new file mode 100644 index 0000000..5f4538c --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/FileNameSanitizer.cs @@ -0,0 +1,28 @@ +using System.IO; + +namespace Flow.Launcher.Plugin.SlickFlow.Utils.Icons; + +/// +/// Sanitizes strings for use as file names. +/// +internal static class FileNameSanitizer +{ + /// + /// Strips illegal file name characters and trims leading/trailing dots. + /// + public static string MakeSafe(string input) + { + var invalid = Path.GetInvalidFileNameChars(); + var sb = new System.Text.StringBuilder(input.Length); + foreach (char ch in input) + { + if (Array.IndexOf(invalid, ch) < 0 + && ch != Path.DirectorySeparatorChar + && ch != Path.AltDirectorySeparatorChar) + { + sb.Append(ch); + } + } + return sb.ToString().Trim('.').Trim(); + } +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/IconHelper.cs b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/IconHelper.cs new file mode 100644 index 0000000..65ccb3e --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/IconHelper.cs @@ -0,0 +1,169 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Net; +using System.Net.Http; + +namespace Flow.Launcher.Plugin.SlickFlow.Utils.Icons; + +/// +/// Orchestrates icon retrieval from local files/directories and remote URLs, +/// saving results as PNG files. Delegates to specialized classes for extraction, +/// downloading, and conversion. +/// +public sealed class IconHelper +{ + private static readonly HttpClient HttpClient = new( + new HttpClientHandler + { + AllowAutoRedirect = true, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }) + { + Timeout = TimeSpan.FromSeconds(5) + }; + + private readonly ConcurrentDictionary _attempts = new(); + private readonly FaviconDownloader _faviconDownloader; + private readonly string _iconFolder; + private readonly Action? _log; + + static IconHelper() + { + HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; IconHelper/1.0)"); + } + + /// + /// Creates a new . + /// + /// Folder where PNG files will be stored. Created if it does not exist. + /// Optional logger for debugging. + public IconHelper(string iconFolder, Action? log = null) + { + _iconFolder = Path.GetFullPath(iconFolder); + Directory.CreateDirectory(_iconFolder); + _log = log; + _faviconDownloader = new FaviconDownloader(HttpClient, log); + } + + /// + /// Tries to retrieve an icon for and saves it as {itemId}.png. + /// + public async Task<(bool Success, string SavedPath)> TrySaveIconAsync( + string pathOrUrl, + string itemId, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + string safeFileName = FileNameSanitizer.MakeSafe(itemId) + ".png"; + string targetPath = Path.Combine(_iconFolder, safeFileName); + + if (File.Exists(targetPath)) + return (true, targetPath); + + string attemptKey = $"{pathOrUrl}|{safeFileName}"; + + try + { + if (IsHttpUrl(pathOrUrl, out var uri)) + { + if (await TrySaveFromUrlAsync(uri!, targetPath, cancellationToken)) + { + _attempts.TryRemove(attemptKey, out _); + return (true, targetPath); + } + } + else if (File.Exists(pathOrUrl) || Directory.Exists(pathOrUrl)) + { + if (TrySaveFromLocalPath(pathOrUrl, targetPath)) + { + _attempts.TryRemove(attemptKey, out _); + return (true, targetPath); + } + } + + int attempts = _attempts.AddOrUpdate(attemptKey, _ => 1, (_, cur) => cur + 1); + if (attempts > 3) + _log?.Invoke($"IconHelper: giving up after {attempts} attempts for \"{pathOrUrl}\""); + } + catch (Exception ex) when (!ex.IsCritical()) + { + _log?.Invoke($"IconHelper: unexpected error for \"{pathOrUrl}\" - {ex.Message}"); + } + + return (false, string.Empty); + } + + /// + /// Set a custom icon for an item. The source can be a local image file or a remote URL. + /// + public async Task SetCustomIconAsync(string iconSource, string itemId, CancellationToken ct = default) + { + string safeFileName = FileNameSanitizer.MakeSafe(itemId) + ".png"; + string destPath = Path.Combine(_iconFolder, safeFileName); + + try + { + if (File.Exists(iconSource)) + { + File.Copy(iconSource, destPath, overwrite: true); + return destPath; + } + + var result = await TrySaveIconAsync(iconSource, itemId, ct); + return result.Success ? result.SavedPath : ""; + } + catch (Exception ex) when (!ex.IsCritical()) + { + _log?.Invoke($"IconHelper:SetCustomIcon failed for \"{iconSource}\" {ex.Message}"); + } + + return string.Empty; + } + + /// + /// Synchronous version of (blocks the calling thread). + /// + public string SetCustomIcon(string iconSource, string itemId) + => SetCustomIconAsync(iconSource, itemId).GetAwaiter().GetResult(); + + #region Private helpers + + private async Task TrySaveFromUrlAsync(Uri uri, string targetPath, CancellationToken ct) + { + byte[]? data = await _faviconDownloader.TryDownloadAsync(uri, ct); + return data != null && ImageConverter.TrySaveBytesAsPng(data, targetPath); + } + + private bool TrySaveFromLocalPath(string path, string pngPath) + { + try + { + var icon = ShellIconExtractor.Extract(path); + if (icon == null) + return false; + + using (icon) + { + ImageConverter.SaveIconToPng(icon, pngPath); + } + return true; + } + catch (Exception ex) when (!ex.IsCritical()) + { + _log?.Invoke($"ExtractIconFromPath failed for \"{path}\" - {ex.Message}"); + return false; + } + } + + private static bool IsHttpUrl(string pathOrUrl, out Uri? uri) + { + uri = null; + return Uri.TryCreate(pathOrUrl, UriKind.Absolute, out uri) && + (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || + uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)); + } + + #endregion +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ImageConverter.cs b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ImageConverter.cs new file mode 100644 index 0000000..0f58ad1 --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ImageConverter.cs @@ -0,0 +1,64 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Windows; +using System.Windows.Media.Imaging; + +namespace Flow.Launcher.Plugin.SlickFlow.Utils.Icons; + +/// +/// Converts image data (bytes, Icon objects) to PNG files. +/// +internal static class ImageConverter +{ + /// + /// Writes a to a PNG file using WPF interop (preserves transparency). + /// + public static void SaveIconToPng(Icon icon, string destinationPath) + { + IntPtr hIcon = icon.Handle; + BitmapSource src = System.Windows.Interop.Imaging.CreateBitmapSourceFromHIcon( + hIcon, + Int32Rect.Empty, + BitmapSizeOptions.FromEmptyOptions()); + + src.Freeze(); + + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(src)); + + using var file = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None); + encoder.Save(file); + } + + /// + /// Tries to interpret raw bytes as an image (ICO, PNG, JPEG, GIF, BMP, etc.) and writes it as PNG. + /// Returns true on success. + /// + public static bool TrySaveBytesAsPng(byte[] data, string pngPath) + { + if (data.Length == 0) + return false; + + try + { + using var ms = new MemoryStream(data, writable: false); + using Image img = Image.FromStream(ms, useEmbeddedColorManagement: false, validateImageData: false); + + using var bmp = new Bitmap(img.Width, img.Height, PixelFormat.Format32bppArgb); + using (Graphics g = Graphics.FromImage(bmp)) + { + g.Clear(Color.Transparent); + g.DrawImageUnscaled(img, 0, 0); + } + + bmp.Save(pngPath, ImageFormat.Png); + return true; + } + catch (Exception ex) when (!ex.IsCritical()) + { + System.Diagnostics.Debug.WriteLine($"TrySaveBytesAsPng failed: {ex.Message}"); + return false; + } + } +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ShellIconExtractor.cs b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ShellIconExtractor.cs new file mode 100644 index 0000000..2e2dc77 --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ShellIconExtractor.cs @@ -0,0 +1,72 @@ +using System.Drawing; +using System.IO; +using System.Runtime.InteropServices; + +namespace Flow.Launcher.Plugin.SlickFlow.Utils.Icons; + +/// +/// Extracts system shell icons from local files and directories using Windows Shell API. +/// +internal static class ShellIconExtractor +{ + #region Native interop + + private const uint SHGFI_ICON = 0x000000100; + private const uint SHGFI_LARGEICON = 0x000000000; + private const uint SHGFI_SMALLICON = 0x000000001; + + [StructLayout(LayoutKind.Sequential)] + private struct SHFILEINFO + { + public IntPtr hIcon; + public int iIcon; + public uint dwAttributes; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string szDisplayName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] + public string szTypeName; + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr SHGetFileInfo( + string pszPath, + uint dwFileAttributes, + ref SHFILEINFO psfi, + uint cbFileInfo, + uint uFlags); + + [DllImport("user32.dll")] + private static extern bool DestroyIcon(IntPtr hIcon); + + #endregion + + /// + /// Extracts a shell icon from a file or directory path. + /// Returns null if no icon could be extracted. + /// + public static Icon? Extract(string path) + { + if (Directory.Exists(path)) + return GetShellIcon(path, largeIcon: true) ?? GetShellIcon(path, largeIcon: false); + + if (File.Exists(path)) + return Icon.ExtractAssociatedIcon(path); + + return null; + } + + private static Icon? GetShellIcon(string path, bool largeIcon) + { + var shinfo = new SHFILEINFO(); + uint flags = SHGFI_ICON | (largeIcon ? SHGFI_LARGEICON : SHGFI_SMALLICON); + + SHGetFileInfo(path, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), flags); + + if (shinfo.hIcon == IntPtr.Zero) + return null; + + var icon = (Icon)Icon.FromHandle(shinfo.hIcon).Clone(); + DestroyIcon(shinfo.hIcon); + return icon; + } +} diff --git a/SlickFlow.Tests/Unit/Commands/CommandProcessorTests.cs b/SlickFlow.Tests/Unit/Commands/CommandProcessorTests.cs index df5de4d..c7226f9 100644 --- a/SlickFlow.Tests/Unit/Commands/CommandProcessorTests.cs +++ b/SlickFlow.Tests/Unit/Commands/CommandProcessorTests.cs @@ -5,6 +5,7 @@ using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; using Flow.Launcher.Plugin.SlickFlow.items; using Flow.Launcher.Plugin.SlickFlow.Utils; +using Flow.Launcher.Plugin.SlickFlow.Utils.Icons; namespace SlickFlow.Tests.Unit.Commands; diff --git a/SlickFlow.Tests/Unit/Commands/SetIconCommandHandlerTests.cs b/SlickFlow.Tests/Unit/Commands/SetIconCommandHandlerTests.cs index 953a05a..9a3b408 100644 --- a/SlickFlow.Tests/Unit/Commands/SetIconCommandHandlerTests.cs +++ b/SlickFlow.Tests/Unit/Commands/SetIconCommandHandlerTests.cs @@ -4,6 +4,7 @@ using Flow.Launcher.Plugin.SlickFlow.Items; using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; using Flow.Launcher.Plugin.SlickFlow.Utils; +using Flow.Launcher.Plugin.SlickFlow.Utils.Icons; namespace SlickFlow.Tests.Unit.Commands; From 2bea18bebae476e75b833c204ab62ebc5494aa71 Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Tue, 12 May 2026 22:23:17 +0300 Subject: [PATCH 16/19] feat: add meta items with cycle-safe alias chaining Meta items are items whose FileName matches @alias1@@alias2@... and execute each referenced alias in sequence by resolving them via the item repository. Nested meta items (alias resolves to another meta item) work transparently. Cycle detection uses a DFS path set: ExecuteMetaItem walks the alias graph once via CollectMetaItemPlan, throwing InvalidOperationException if any ancestor is revisited on the same call path. The walk is side-effect free, so no leaf launches and no ExecCount increments when a cycle is detected, even when the cycle hides behind a leading non-cyclic sibling. ContextMenuProvider delegate now takes IItemRepository so RunAsAdministratorProvider can forward it to Execute for meta items. Tests: 17 cases covering pattern detection, single/multiple/nested resolution, missing-alias errors, force-admin propagation, direct self-reference, two-node and long-chain cycles, sibling re-use (not a cycle), and atomicity when cycle is detected mid-walk. Co-Authored-By: Claude Opus 4.7 --- .../Abstract/ContextMenuProvider.cs | 4 +- .../ContextMenuResults/ContextMenuBuilder.cs | 5 +- .../ContextMenuProviders/AliasProvider.cs | 3 +- .../ExecutionCountProvider.cs | 3 +- .../OpenInPowershellProvider.cs | 3 +- .../OpenInTerminalProvider.cs | 3 +- .../OpenIncognitoProvider.cs | 3 +- .../ContextMenuProviders/OpenPathProvider.cs | 3 +- .../RunAsAdministratorProvider.cs | 7 +- Flow.Launcher.Plugin.SlickFlow/Items/Item.cs | 84 +++++- Flow.Launcher.Plugin.SlickFlow/Main.cs | 4 +- SlickFlow.Tests/Unit/Items/MetaItemTests.cs | 252 ++++++++++++++++++ 12 files changed, 359 insertions(+), 15 deletions(-) create mode 100644 SlickFlow.Tests/Unit/Items/MetaItemTests.cs diff --git a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/Abstract/ContextMenuProvider.cs b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/Abstract/ContextMenuProvider.cs index daf1404..4f36001 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/Abstract/ContextMenuProvider.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/Abstract/ContextMenuProvider.cs @@ -1,8 +1,10 @@ using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.ContextMenuResults.Abstract; public delegate Result? ContextMenuProvider( Result selectedResult, - Item item + Item item, + IItemRepository itemRepo ); \ No newline at end of file diff --git a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuBuilder.cs b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuBuilder.cs index 42bf0f3..f71b39d 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuBuilder.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuBuilder.cs @@ -1,6 +1,7 @@ using Flow.Launcher.Plugin.SlickFlow.ContextMenuResults.Abstract; using Flow.Launcher.Plugin.SlickFlow.ContextMenuResults.Results; using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.ContextMenuResults { @@ -17,13 +18,13 @@ public class ContextMenuBuilder AliasesProvider.Provide }; - public List Build(Result selectedResult, Item item) + public List Build(Result selectedResult, Item item, IItemRepository itemRepo) { var results = new List(); foreach (var provider in _providers) { - var result = provider(selectedResult, item); + var result = provider(selectedResult, item, itemRepo); if (result != null) results.Add(result); } diff --git a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/AliasProvider.cs b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/AliasProvider.cs index bfd722d..706e4f1 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/AliasProvider.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/AliasProvider.cs @@ -1,10 +1,11 @@ using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.ContextMenuResults.Results; public static class AliasesProvider { - public static Result? Provide(Result selectedResult, Item item) + public static Result? Provide(Result selectedResult, Item item, IItemRepository itemRepo) { if (item.Aliases == null || item.Aliases.Count == 0) return null; diff --git a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/ExecutionCountProvider.cs b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/ExecutionCountProvider.cs index ce57ba4..1fda54e 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/ExecutionCountProvider.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/ExecutionCountProvider.cs @@ -1,10 +1,11 @@ using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.ContextMenuResults.Results; public static class ExecutionCountProvider { - public static Result? Provide(Result selectedResult, Item item) + public static Result? Provide(Result selectedResult, Item item, IItemRepository itemRepo) { return new Result { diff --git a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenInPowershellProvider.cs b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenInPowershellProvider.cs index eee6ad2..c4be01e 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenInPowershellProvider.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenInPowershellProvider.cs @@ -1,12 +1,13 @@ using System.Diagnostics; using System.IO; using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.ContextMenuResults.Results; public static class OpenInPowerShellProvider { - public static Result? Provide(Result selectedResult, Item item) + public static Result? Provide(Result selectedResult, Item item, IItemRepository itemRepo) { var folder = GetFolder(item.FileName); if (folder == null || !HasPowerShell()) diff --git a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenInTerminalProvider.cs b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenInTerminalProvider.cs index 0599ee8..dd2ce9f 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenInTerminalProvider.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenInTerminalProvider.cs @@ -1,12 +1,13 @@ using System.Diagnostics; using System.IO; using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.ContextMenuResults.Results; public static class OpenInTerminalProvider { - public static Result? Provide(Result selectedResult, Item item) + public static Result? Provide(Result selectedResult, Item item, IItemRepository itemRepo) { var folder = GetFolder(item.FileName); if (folder == null) diff --git a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenIncognitoProvider.cs b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenIncognitoProvider.cs index 05effbb..f35ab1b 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenIncognitoProvider.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenIncognitoProvider.cs @@ -1,12 +1,13 @@ using System.Diagnostics; using Microsoft.Win32; using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.ContextMenuResults.Results; public static class OpenIncognitoProvider { - public static Result? Provide(Result selectedResult, Item item) + public static Result? Provide(Result selectedResult, Item item, IItemRepository itemRepo) { if (string.IsNullOrWhiteSpace(item.FileName) || !item.IsUrl(item.FileName)) return null; diff --git a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenPathProvider.cs b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenPathProvider.cs index 36daa85..7c813c6 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenPathProvider.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/OpenPathProvider.cs @@ -1,13 +1,14 @@ using System.Diagnostics; using System.IO; using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.ContextMenuResults.Results; public static class OpenPathProvider { - public static Result? Provide(Result selectedResult, Item item) + public static Result? Provide(Result selectedResult, Item item, IItemRepository itemRepo) { var path = item.FileName; diff --git a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/RunAsAdministratorProvider.cs b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/RunAsAdministratorProvider.cs index 45b5685..ae74f5f 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/RunAsAdministratorProvider.cs +++ b/Flow.Launcher.Plugin.SlickFlow/ContextMenuResults/ContextMenuProviders/RunAsAdministratorProvider.cs @@ -1,11 +1,12 @@ using System.IO; using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.ContextMenuResults.Results; public static class RunAsAdministratorProvider { - public static Result? Provide(Result selectedResult, Item item) + public static Result? Provide(Result selectedResult, Item item, IItemRepository itemRepo) { if (!ShouldShow(item)) return null; @@ -21,7 +22,7 @@ public static class RunAsAdministratorProvider { try { - item.Execute(forceAdminExec: true); + item.Execute(forceAdminExec: true, itemRepo: itemRepo); } catch (Exception ex) { @@ -30,7 +31,7 @@ public static class RunAsAdministratorProvider } }); - return true; + return true; } }; } diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs b/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs index b5f6510..a88b5b3 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.IO; +using System.Text.RegularExpressions; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; namespace Flow.Launcher.Plugin.SlickFlow.Items; public class Item @@ -43,8 +45,19 @@ public bool MatchesQuery(string query) || SubTitle.ToLowerInvariant().Contains(query) || Aliases.Any(a => a.ToLowerInvariant().Contains(query)); } - public void Execute(bool forceAdminExec = false) + private static readonly Regex MetaItemPattern = new(@"^(@[^@]+@)+$", RegexOptions.Compiled); + private static readonly Regex AliasExtractor = new(@"@([^@]+)@", RegexOptions.Compiled); + + public bool IsMetaItem => MetaItemPattern.IsMatch(FileName ?? string.Empty); + + public void Execute(bool forceAdminExec = false, IItemRepository? itemRepo = null) { + if (IsMetaItem) + { + ExecuteMetaItem(forceAdminExec, itemRepo); + return; + } + try { if (!IsUrl(FileName) && !File.Exists(FileName)) @@ -84,6 +97,75 @@ public void Execute(bool forceAdminExec = false) Console.WriteLine($"[Error] Failed to execute '{FileName}': {ex.Message}"); } } + + private void ExecuteMetaItem(bool forceAdminExec, IItemRepository? itemRepo) + { + if (itemRepo == null) + throw new InvalidOperationException("Meta items require an item repository to resolve alias references."); + + var leaves = new List(); + var metas = new List(); + CollectMetaItemPlan(itemRepo, new HashSet(), leaves, metas); + + foreach (var leaf in leaves) + leaf.Execute(forceAdminExec, itemRepo); + + foreach (var meta in metas) + meta.ExecCount++; + } + + private void CollectMetaItemPlan( + IItemRepository itemRepo, + HashSet visited, + List leaves, + List metas) + { + // visited tracks the current DFS call path. Re-adding means we've looped + // back to an ancestor on this same path - a cycle. Removed in finally so + // sibling branches that legitimately revisit a meta item aren't flagged. + if (!visited.Add(this)) + throw new InvalidOperationException( + $"Cycle detected in meta item chain at item \"{Id}\". Execution aborted."); + + try + { + var aliases = AliasExtractor.Matches(FileName) + .Select(m => m.Groups[1].Value) + .ToList(); + + var missing = new List(); + var resolved = new List(); + + foreach (var alias in aliases) + { + var target = itemRepo.GetItemByAlias(alias); + if (target == null) + missing.Add(alias); + else + resolved.Add(target); + } + + if (missing.Count > 0) + { + var names = string.Join(", ", missing.Select(m => $"\"{m}\"")); + throw new InvalidOperationException($"Unknown aliases in item: {names}"); + } + + foreach (var target in resolved) + { + if (target.IsMetaItem) + target.CollectMetaItemPlan(itemRepo, visited, leaves, metas); + else + leaves.Add(target); + } + + metas.Add(this); + } + finally + { + visited.Remove(this); + } + } public bool IsUrl(string fileName) { return Uri.TryCreate(fileName, UriKind.Absolute, out var uriResult) diff --git a/Flow.Launcher.Plugin.SlickFlow/Main.cs b/Flow.Launcher.Plugin.SlickFlow/Main.cs index 374574c..44f36ad 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Main.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Main.cs @@ -86,7 +86,7 @@ public List LoadContextMenus(Result selectedResult) return new List(); var builder = new ContextMenuBuilder(); - return builder.Build(selectedResult, item); + return builder.Build(selectedResult, item, _itemRepo); } @@ -150,7 +150,7 @@ private List GetResults(string query, List items) { try { - item.Execute(); + item.Execute(itemRepo: _itemRepo); _itemRepo.UpdateItem(item); } catch (Exception ex) diff --git a/SlickFlow.Tests/Unit/Items/MetaItemTests.cs b/SlickFlow.Tests/Unit/Items/MetaItemTests.cs new file mode 100644 index 0000000..dc76c51 --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/MetaItemTests.cs @@ -0,0 +1,252 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Moq; + +namespace SlickFlow.Tests.Unit.Items; + +public class MetaItemTests +{ + [Theory] + [InlineData("@spotify@", true)] + [InlineData("@spotify@@discord@", true)] + [InlineData("@a@@b@@c@", true)] + [InlineData("notepad.exe", false)] + [InlineData("@incomplete", false)] + [InlineData("prefix@spotify@", false)] + [InlineData("@spotify@suffix", false)] + [InlineData("", false)] + [InlineData("@@", false)] + public void IsMetaItem_DetectsPatternCorrectly(string fileName, bool expected) + { + var item = new Item("1", fileName); + item.IsMetaItem.Should().Be(expected); + } + + [Fact] + public void Execute_MetaItem_ResolvesAndExecutesSingleAlias() + { + // Target uses a non-existent file so no real process starts + var target = new Item("2", "fake_nonexistent_app.xyz", new[] { "np" }); + var metaItem = new Item("1", "@np@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("np")).Returns(target); + + metaItem.Execute(itemRepo: repo.Object); + + repo.Verify(r => r.GetItemByAlias("np"), Times.Once); + metaItem.ExecCount.Should().Be(1); + } + + [Fact] + public void Execute_MetaItem_ResolvesMultipleAliases() + { + var spotify = new Item("2", "fake_spotify.xyz", new[] { "spotify" }); + var discord = new Item("3", "fake_discord.xyz", new[] { "discord" }); + var metaItem = new Item("1", "@spotify@@discord@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("spotify")).Returns(spotify); + repo.Setup(r => r.GetItemByAlias("discord")).Returns(discord); + + metaItem.Execute(itemRepo: repo.Object); + + repo.Verify(r => r.GetItemByAlias("spotify"), Times.Once); + repo.Verify(r => r.GetItemByAlias("discord"), Times.Once); + metaItem.ExecCount.Should().Be(1); + } + + [Fact] + public void Execute_MetaItem_ThrowsForAllMissingAliases() + { + var metaItem = new Item("1", "@AA@@BB@@CC@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias(It.IsAny())).Returns((Item?)null); + + var act = () => metaItem.Execute(itemRepo: repo.Object); + + act.Should().Throw() + .WithMessage("*\"AA\"*\"BB\"*\"CC\"*"); + } + + [Fact] + public void Execute_MetaItem_ThrowsOnlyForMissingAliases() + { + var spotify = new Item("2", "fake_spotify.xyz", new[] { "spotify" }); + var metaItem = new Item("1", "@spotify@@missing@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("spotify")).Returns(spotify); + repo.Setup(r => r.GetItemByAlias("missing")).Returns((Item?)null); + + var act = () => metaItem.Execute(itemRepo: repo.Object); + + act.Should().Throw() + .WithMessage("*\"missing\"*") + .Which.Message.Should().NotContain("\"spotify\""); + } + + [Fact] + public void Execute_MetaItem_ThrowsWithoutRepository() + { + var metaItem = new Item("1", "@np@"); + + var act = () => metaItem.Execute(); + + act.Should().Throw() + .WithMessage("*require*repository*"); + } + + [Fact] + public void Execute_NonMetaItem_DoesNotRequireRepository() + { + var item = new Item("1", "some_app.exe"); + item.IsMetaItem.Should().BeFalse(); + } + + [Fact] + public void Execute_MetaItem_ForceAdmin_PropagatesThrough() + { + // forceAdminExec is passed to each resolved target via Execute(forceAdminExec, itemRepo). + // We verify propagation through a nested meta chain: outer -> inner -> leaf. + // If forceAdmin weren't forwarded, the inner meta call would lose it. + var leaf = new Item("3", "fake_app.xyz", new[] { "np" }); + var inner = new Item("2", "@np@", new[] { "inner" }); + var outer = new Item("1", "@inner@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("inner")).Returns(inner); + repo.Setup(r => r.GetItemByAlias("np")).Returns(leaf); + + // Should not throw - forceAdminExec flows through the entire chain + outer.Execute(forceAdminExec: true, itemRepo: repo.Object); + + repo.Verify(r => r.GetItemByAlias("inner"), Times.Once); + repo.Verify(r => r.GetItemByAlias("np"), Times.Once); + outer.ExecCount.Should().Be(1); + inner.ExecCount.Should().Be(1); + } + + [Fact] + public void Execute_MetaItem_ResolvesNestedMetaItems() + { + var leaf = new Item("3", "fake_notepad.xyz", new[] { "np" }); + var inner = new Item("2", "@np@", new[] { "inner" }); + var outer = new Item("1", "@inner@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("inner")).Returns(inner); + repo.Setup(r => r.GetItemByAlias("np")).Returns(leaf); + + outer.Execute(itemRepo: repo.Object); + + repo.Verify(r => r.GetItemByAlias("inner"), Times.Once); + repo.Verify(r => r.GetItemByAlias("np"), Times.Once); + outer.ExecCount.Should().Be(1); + inner.ExecCount.Should().Be(1); + } + + [Fact] + public void Execute_MetaItem_ThrowsOnDirectSelfReference() + { + // Simplest cycle: meta item whose only alias resolves back to itself. + var m1 = new Item("1", "@self@", new[] { "self" }); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("self")).Returns(m1); + + var act = () => m1.Execute(itemRepo: repo.Object); + + act.Should().Throw() + .WithMessage("*cycle*"); + m1.ExecCount.Should().Be(0); + } + + [Fact] + public void Execute_MetaItem_ThrowsOnTwoNodeCycle() + { + // A -> B -> A + var a = new Item("A", "@b@", new[] { "a" }); + var b = new Item("B", "@a@", new[] { "b" }); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("a")).Returns(a); + repo.Setup(r => r.GetItemByAlias("b")).Returns(b); + + var act = () => a.Execute(itemRepo: repo.Object); + + act.Should().Throw() + .WithMessage("*cycle*"); + a.ExecCount.Should().Be(0); + b.ExecCount.Should().Be(0); + } + + [Fact] + public void Execute_MetaItem_ThrowsOnLongChainCycle() + { + // A -> B -> C -> D -> A: cycle reached only after a long chain. + var a = new Item("A", "@b@", new[] { "a" }); + var b = new Item("B", "@c@", new[] { "b" }); + var c = new Item("C", "@d@", new[] { "c" }); + var d = new Item("D", "@a@", new[] { "d" }); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("a")).Returns(a); + repo.Setup(r => r.GetItemByAlias("b")).Returns(b); + repo.Setup(r => r.GetItemByAlias("c")).Returns(c); + repo.Setup(r => r.GetItemByAlias("d")).Returns(d); + + var act = () => a.Execute(itemRepo: repo.Object); + + act.Should().Throw() + .WithMessage("*cycle*"); + a.ExecCount.Should().Be(0); + b.ExecCount.Should().Be(0); + c.ExecCount.Should().Be(0); + d.ExecCount.Should().Be(0); + } + + [Fact] + public void Execute_MetaItem_DoesNotExecuteAnyLeafWhenCycleDetected() + { + // M1 = @safe@@cyclic@ where `safe` is a normal leaf and `cyclic` loops back + // to M1. The safe leaf must NOT execute - cycle detection precedes execution. + var safe = new Item("S", "fake_safe.xyz", new[] { "safe" }); + var bad = new Item("B", "@m1@", new[] { "cyclic" }); + var m1 = new Item("M1", "@safe@@cyclic@", new[] { "m1" }); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("safe")).Returns(safe); + repo.Setup(r => r.GetItemByAlias("cyclic")).Returns(bad); + repo.Setup(r => r.GetItemByAlias("m1")).Returns(m1); + + var act = () => m1.Execute(itemRepo: repo.Object); + + act.Should().Throw() + .WithMessage("*cycle*"); + safe.ExecCount.Should().Be(0); + bad.ExecCount.Should().Be(0); + m1.ExecCount.Should().Be(0); + } + + [Fact] + public void Execute_MetaItem_AllowsSameMetaInSiblingBranches() + { + // M1 = @m2@@m2@ - m2 is referenced twice as siblings, not on the same call + // path, so this is NOT a cycle. m2 should execute twice. + var leaf = new Item("3", "fake.xyz", new[] { "leaf" }); + var m2 = new Item("2", "@leaf@", new[] { "m2" }); + var m1 = new Item("1", "@m2@@m2@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("m2")).Returns(m2); + repo.Setup(r => r.GetItemByAlias("leaf")).Returns(leaf); + + m1.Execute(itemRepo: repo.Object); + + m1.ExecCount.Should().Be(1); + m2.ExecCount.Should().Be(2); + } +} From db4c99a41a4635abc8577925d5f6350fe3836479 Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Tue, 12 May 2026 22:55:27 +0300 Subject: [PATCH 17/19] feat: parameterized items with sequential prompt UX Items can now declare placeholders in FileName and Arguments using <>, optionally with default value and display hint: <> just a name <> with default <> with hint <> default + hint When such an item is invoked, the Flow Launcher bar enters a sequential prompt mode driven by ChangeQuery: " | k1=v1 | ... | name: input". The user fills one placeholder at a time; defaults pre-fill the prompt; each Enter advances to the next placeholder or executes the item once all are filled. The bar pattern is the only state - editing away from it exits prompt mode and resumes normal search. Meta-items participate too: PlaceholderSchema walks the alias chain (reusing the cycle-safe traversal extracted from ExecuteMetaItem), collects placeholders from every parameterized leaf, deduplicates by name (first occurrence wins), and the same prompt loop covers the chain. On execute, values flow through the chain to each leaf via a new optional values dict on Item.Execute. Substitution happens against the original strings each call, so items are never mutated. New components under Items/Parameters/: - Placeholder, PlaceholderParser: regex extraction + substitution - PlaceholderSchema: ordered, deduplicated schema across a meta chain - PromptModeParser: parse/format the Flow bar prompt syntax - PromptModeHandler: builds the guided Result for the active prompt Item.cs: refactored CollectMetaItemPlan into a reusable WalkMetaChain that takes onLeaf/onMetaExit callbacks; ExecuteMetaItem and PlaceholderSchema both use it. Execute gained an optional values parameter that substitutes locally without mutating stored fields. Tests: 77 new (parser syntax, IsParameterized, Substitute clone semantics, schema dedup + chain walk, prompt-mode round-trip, handler preview/advance/execute including meta-with-parameterized-leaf). Full suite: 257 / 257. Co-Authored-By: Claude Opus 4.7 --- Flow.Launcher.Plugin.SlickFlow/Items/Item.cs | 84 ++++++--- .../Items/Parameters/Placeholder.cs | 3 + .../Items/Parameters/PlaceholderParser.cs | 64 +++++++ .../Items/Parameters/PlaceholderSchema.cs | 36 ++++ .../Items/Parameters/PromptModeHandler.cs | 117 +++++++++++++ .../Items/Parameters/PromptModeParser.cs | 75 ++++++++ Flow.Launcher.Plugin.SlickFlow/Main.cs | 48 +++++- .../Unit/Items/ItemExecuteWithValuesTests.cs | 100 +++++++++++ .../Unit/Items/ItemIsParameterizedTests.cs | 50 ++++++ .../Unit/Items/ItemSubstituteTests.cs | 162 ++++++++++++++++++ .../Parameters/PlaceholderParserTests.cs | 144 ++++++++++++++++ .../Parameters/PlaceholderSchemaTests.cs | 147 ++++++++++++++++ .../Parameters/PromptModeHandlerTests.cs | 153 +++++++++++++++++ .../Items/Parameters/PromptModeParserTests.cs | 126 ++++++++++++++ 14 files changed, 1283 insertions(+), 26 deletions(-) create mode 100644 Flow.Launcher.Plugin.SlickFlow/Items/Parameters/Placeholder.cs create mode 100644 Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PlaceholderParser.cs create mode 100644 Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PlaceholderSchema.cs create mode 100644 Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeHandler.cs create mode 100644 Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeParser.cs create mode 100644 SlickFlow.Tests/Unit/Items/ItemExecuteWithValuesTests.cs create mode 100644 SlickFlow.Tests/Unit/Items/ItemIsParameterizedTests.cs create mode 100644 SlickFlow.Tests/Unit/Items/ItemSubstituteTests.cs create mode 100644 SlickFlow.Tests/Unit/Items/Parameters/PlaceholderParserTests.cs create mode 100644 SlickFlow.Tests/Unit/Items/Parameters/PlaceholderSchemaTests.cs create mode 100644 SlickFlow.Tests/Unit/Items/Parameters/PromptModeHandlerTests.cs create mode 100644 SlickFlow.Tests/Unit/Items/Parameters/PromptModeParserTests.cs diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs b/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs index a88b5b3..f7ce073 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs @@ -2,6 +2,7 @@ using System.IO; using System.Text.RegularExpressions; using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.Items.Parameters; namespace Flow.Launcher.Plugin.SlickFlow.Items; public class Item @@ -50,36 +51,61 @@ public bool MatchesQuery(string query) public bool IsMetaItem => MetaItemPattern.IsMatch(FileName ?? string.Empty); - public void Execute(bool forceAdminExec = false, IItemRepository? itemRepo = null) + public bool IsParameterized => + PlaceholderParser.ContainsPlaceholders(FileName) + || PlaceholderParser.ContainsPlaceholders(Arguments); + + public Item Substitute(IReadOnlyDictionary values) + { + return new Item + { + Id = Id, + FileName = PlaceholderParser.Substitute(FileName, values), + Arguments = PlaceholderParser.Substitute(Arguments, values), + SubTitle = SubTitle, + RunAs = RunAs, + StartMode = StartMode, + WorkingDir = WorkingDir, + ExecCount = ExecCount, + Aliases = new List(Aliases), + IconPath = IconPath + }; + } + + public void Execute( + bool forceAdminExec = false, + IItemRepository? itemRepo = null, + IReadOnlyDictionary? values = null) { if (IsMetaItem) { - ExecuteMetaItem(forceAdminExec, itemRepo); + ExecuteMetaItem(forceAdminExec, itemRepo, values); return; } + var fileName = values != null ? PlaceholderParser.Substitute(FileName, values) : FileName; + var arguments = values != null ? PlaceholderParser.Substitute(Arguments, values) : Arguments; + try { - if (!IsUrl(FileName) && !File.Exists(FileName)) + if (!IsUrl(fileName) && !File.Exists(fileName)) { - string sysPath = Path.Combine(Environment.SystemDirectory, FileName); + string sysPath = Path.Combine(Environment.SystemDirectory, fileName); if (File.Exists(sysPath)) - { - FileName = sysPath; - } + fileName = sysPath; } var psi = new ProcessStartInfo { - FileName = FileName, - Arguments = Arguments, + FileName = fileName, + Arguments = arguments, WorkingDirectory = string.IsNullOrWhiteSpace(WorkingDir) ? Environment.CurrentDirectory : WorkingDir, UseShellExecute = true }; - if (!IsUrl(FileName) && (RunAs == 1 || forceAdminExec)) + if (!IsUrl(fileName) && (RunAs == 1 || forceAdminExec)) psi.Verb = "runas"; psi.WindowStyle = StartMode switch @@ -94,31 +120,40 @@ public void Execute(bool forceAdminExec = false, IItemRepository? itemRepo = nul } catch (Exception ex) { - Console.WriteLine($"[Error] Failed to execute '{FileName}': {ex.Message}"); + Console.WriteLine($"[Error] Failed to execute '{fileName}': {ex.Message}"); } } - private void ExecuteMetaItem(bool forceAdminExec, IItemRepository? itemRepo) + private void ExecuteMetaItem( + bool forceAdminExec, + IItemRepository? itemRepo, + IReadOnlyDictionary? values) { if (itemRepo == null) throw new InvalidOperationException("Meta items require an item repository to resolve alias references."); var leaves = new List(); var metas = new List(); - CollectMetaItemPlan(itemRepo, new HashSet(), leaves, metas); + WalkMetaChain(itemRepo, new HashSet(), leaves.Add, metas.Add); foreach (var leaf in leaves) - leaf.Execute(forceAdminExec, itemRepo); + leaf.Execute(forceAdminExec, itemRepo, values); foreach (var meta in metas) meta.ExecCount++; } - private void CollectMetaItemPlan( + /// + /// Depth-first traversal of the meta-item alias chain rooted at this item. + /// Invokes for each non-meta item encountered, + /// and after a meta item's children are visited. + /// Throws on cycles (revisited ancestor) and unresolved aliases. + /// + internal void WalkMetaChain( IItemRepository itemRepo, HashSet visited, - List leaves, - List metas) + Action onLeaf, + Action? onMetaExit = null) { // visited tracks the current DFS call path. Re-adding means we've looped // back to an ancestor on this same path - a cycle. Removed in finally so @@ -129,6 +164,12 @@ private void CollectMetaItemPlan( try { + if (!IsMetaItem) + { + onLeaf(this); + return; + } + var aliases = AliasExtractor.Matches(FileName) .Select(m => m.Groups[1].Value) .ToList(); @@ -152,14 +193,9 @@ private void CollectMetaItemPlan( } foreach (var target in resolved) - { - if (target.IsMetaItem) - target.CollectMetaItemPlan(itemRepo, visited, leaves, metas); - else - leaves.Add(target); - } + target.WalkMetaChain(itemRepo, visited, onLeaf, onMetaExit); - metas.Add(this); + onMetaExit?.Invoke(this); } finally { diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/Placeholder.cs b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/Placeholder.cs new file mode 100644 index 0000000..9201690 --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/Placeholder.cs @@ -0,0 +1,3 @@ +namespace Flow.Launcher.Plugin.SlickFlow.Items.Parameters; + +public record Placeholder(string Name, string? Default, string? Hint); diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PlaceholderParser.cs b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PlaceholderParser.cs new file mode 100644 index 0000000..1965418 --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PlaceholderParser.cs @@ -0,0 +1,64 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace Flow.Launcher.Plugin.SlickFlow.Items.Parameters; + +public static class PlaceholderParser +{ + // <> + // Name disallows < > = |. Default disallows < > |. Hint disallows < >. + private static readonly Regex Pattern = new( + @"<<([^<>=|]+)(?:=([^<>|]*))?(?:\|([^<>]*))?>>", + RegexOptions.Compiled); + + public static IEnumerable Extract(string? text) + { + if (string.IsNullOrEmpty(text)) + yield break; + + foreach (Match m in Pattern.Matches(text)) + { + yield return new Placeholder( + Name: m.Groups[1].Value, + Default: m.Groups[2].Success ? m.Groups[2].Value : null, + Hint: m.Groups[3].Success ? m.Groups[3].Value : null); + } + } + + public static bool ContainsPlaceholders(string? text) + { + return !string.IsNullOrEmpty(text) && Pattern.IsMatch(text); + } + + /// + /// Replaces each placeholder with its supplied value. Falls back to the + /// placeholder's default when missing from , and + /// leaves the placeholder literal in place when neither is available. + /// + public static string Substitute(string? text, IReadOnlyDictionary values) + { + if (string.IsNullOrEmpty(text)) + return text ?? string.Empty; + + var sb = new StringBuilder(text.Length); + int last = 0; + + foreach (Match m in Pattern.Matches(text)) + { + sb.Append(text, last, m.Index - last); + + var name = m.Groups[1].Value; + if (values.TryGetValue(name, out var supplied)) + sb.Append(supplied); + else if (m.Groups[2].Success) + sb.Append(m.Groups[2].Value); + else + sb.Append(m.Value); + + last = m.Index + m.Length; + } + + sb.Append(text, last, text.Length - last); + return sb.ToString(); + } +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PlaceholderSchema.cs b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PlaceholderSchema.cs new file mode 100644 index 0000000..8bd525e --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PlaceholderSchema.cs @@ -0,0 +1,36 @@ +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; + +namespace Flow.Launcher.Plugin.SlickFlow.Items.Parameters; + +/// +/// Resolves the ordered, deduplicated set of placeholders that need to be +/// filled before an item (or a meta-item chain) can be executed. +/// +public static class PlaceholderSchema +{ + public static IReadOnlyList From(Item item, IItemRepository? repo = null) + { + if (item.IsMetaItem && repo == null) + throw new InvalidOperationException( + "Meta items require an item repository to resolve alias references."); + + var seenNames = new HashSet(); + var result = new List(); + + void CollectFromLeaf(Item leaf) + { + AddDistinct(PlaceholderParser.Extract(leaf.FileName)); + AddDistinct(PlaceholderParser.Extract(leaf.Arguments)); + } + + void AddDistinct(IEnumerable placeholders) + { + foreach (var ph in placeholders) + if (seenNames.Add(ph.Name)) + result.Add(ph); + } + + item.WalkMetaChain(repo!, new HashSet(), CollectFromLeaf); + return result; + } +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeHandler.cs b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeHandler.cs new file mode 100644 index 0000000..382e890 --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeHandler.cs @@ -0,0 +1,117 @@ +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; + +namespace Flow.Launcher.Plugin.SlickFlow.Items.Parameters; + +/// +/// Drives the sequential-prompt UX. Given a parsed , +/// builds the guided the user sees in Flow Launcher while +/// filling in placeholders. Pressing Enter advances to the next prompt via +/// IPublicAPI.ChangeQuery, or executes the item when the last placeholder +/// is filled. +/// +public class PromptModeHandler +{ + private readonly IItemRepository _repo; + private readonly IPublicAPI _api; + + public PromptModeHandler(IItemRepository repo, IPublicAPI api) + { + _repo = repo; + _api = api; + } + + public List BuildResults(PromptModeState state) + { + var item = _repo.GetItemByAlias(state.Alias); + if (item == null) + return new List(); + + IReadOnlyList schema; + try + { + schema = PlaceholderSchema.From(item, _repo); + } + catch (InvalidOperationException) + { + return new List(); + } + + if (schema.Count == 0) + return new List(); + + var currentIndex = IndexOfName(schema, state.CurrentName); + if (currentIndex < 0) + return new List(); + + var values = new Dictionary(); + foreach (var (n, v) in state.Filled) + values[n] = v; + values[state.CurrentName] = state.CurrentInput; + + var title = item.IsMetaItem + ? $"{state.Alias} (chain)" + : PlaceholderParser.Substitute(item.FileName, values); + + var ph = schema[currentIndex]; + var hint = string.IsNullOrEmpty(ph.Hint) ? "" : $" ({ph.Hint})"; + var isLast = currentIndex == schema.Count - 1; + var subtitle = isLast + ? $"Set {ph.Name}{hint} — Press Enter to launch" + : $"Set {ph.Name}{hint} — Press Enter for {schema[currentIndex + 1].Name}"; + + return new List + { + new Result + { + Title = title, + SubTitle = subtitle, + IcoPath = item.IconPath, + Action = _ => AdvanceOrExecute(item, state, schema, values, currentIndex) + } + }; + } + + private bool AdvanceOrExecute( + Item item, + PromptModeState state, + IReadOnlyList schema, + Dictionary values, + int currentIndex) + { + if (currentIndex >= schema.Count - 1) + { + try + { + item.Execute(itemRepo: _repo, values: values); + _repo.UpdateItem(item); + } + catch (Exception ex) + { + Console.WriteLine($"[Error] Failed to execute '{item.FileName}': {ex.Message}"); + } + return true; + } + + var newFilled = new List<(string, string)>(state.Filled) + { + (state.CurrentName, state.CurrentInput) + }; + var next = schema[currentIndex + 1]; + var newQuery = PromptModeParser.Format( + state.Alias, + newFilled, + next.Name, + next.Default ?? ""); + _api.ChangeQuery(newQuery, requery: true); + return false; + } + + private static int IndexOfName(IReadOnlyList schema, string name) + { + for (int i = 0; i < schema.Count; i++) + if (schema[i].Name == name) + return i; + return -1; + } +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeParser.cs b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeParser.cs new file mode 100644 index 0000000..7938aa9 --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeParser.cs @@ -0,0 +1,75 @@ +using System.Text; + +namespace Flow.Launcher.Plugin.SlickFlow.Items.Parameters; + +/// +/// Snapshot of the prompt-mode query in the Flow Launcher bar. +/// +/// Format: <alias> | name1=value1 | ... | currentName: currentInput. +/// The trailing name: input segment is the active prompt; the user is +/// typing input and pressing Enter advances to the next placeholder +/// or executes the item if all placeholders are filled. +/// +/// +public record PromptModeState( + string Alias, + IReadOnlyList<(string Name, string Value)> Filled, + string CurrentName, + string CurrentInput); + +public static class PromptModeParser +{ + private const string Separator = " | "; + + public static PromptModeState? TryParse(string? query) + { + if (string.IsNullOrWhiteSpace(query)) + return null; + + var segments = query.Split('|').Select(s => s.Trim()).ToList(); + if (segments.Count < 2) + return null; + + var alias = segments[0]; + if (string.IsNullOrEmpty(alias)) + return null; + + var filled = new List<(string, string)>(); + for (int i = 1; i < segments.Count - 1; i++) + { + var seg = segments[i]; + var eq = seg.IndexOf('='); + if (eq < 0) + return null; + var name = seg[..eq].Trim(); + var value = seg[(eq + 1)..].Trim(); + if (string.IsNullOrEmpty(name)) + return null; + filled.Add((name, value)); + } + + var last = segments[^1]; + var colon = last.IndexOf(':'); + if (colon < 0) + return null; + var currentName = last[..colon].Trim(); + if (string.IsNullOrEmpty(currentName)) + return null; + var currentInput = colon + 1 < last.Length ? last[(colon + 1)..].TrimStart() : ""; + + return new PromptModeState(alias, filled, currentName, currentInput); + } + + public static string Format( + string alias, + IEnumerable<(string Name, string Value)> filled, + string nextName, + string nextInitial) + { + var sb = new StringBuilder(alias); + foreach (var (n, v) in filled) + sb.Append(Separator).Append(n).Append('=').Append(v); + sb.Append(Separator).Append(nextName).Append(": ").Append(nextInitial); + return sb.ToString(); + } +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Main.cs b/Flow.Launcher.Plugin.SlickFlow/Main.cs index 44f36ad..64b7b36 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Main.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Main.cs @@ -5,6 +5,7 @@ using Flow.Launcher.Plugin.SlickFlow.ContextMenuResults; using Flow.Launcher.Plugin.SlickFlow.items; using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Parameters; using Flow.Launcher.Plugin.SlickFlow.Settings; using Flow.Launcher.Plugin.SlickFlow.Utils; using Flow.Launcher.Plugin.SlickFlow.Utils.Icons; @@ -58,6 +59,17 @@ public void Init(PluginInitContext context) /// A list of matching results public List Query(Query query) { + // Prompt-mode short-circuit: if the user is filling placeholders for an + // item, the bar holds a special " | k=v | ... | name: input" pattern. + // Bypass commands and normal search when we recognize it. + var promptState = PromptModeParser.TryParse(query.Search); + if (promptState != null) + { + var promptResults = new PromptModeHandler(_itemRepo, _context.API).BuildResults(promptState); + if (promptResults.Count > 0) + return promptResults; + } + var results = new List(); var items = _itemRepo.GetAllItems(); var parts = CommandParser.SplitArgs(query.Search); @@ -146,12 +158,15 @@ private List GetResults(string query, List items) ContextData = this, Action = _ => { + if (TryEnterPromptMode(name, item)) + return false; + Task.Run(() => { try { item.Execute(itemRepo: _itemRepo); - _itemRepo.UpdateItem(item); + _itemRepo.UpdateItem(item); } catch (Exception ex) { @@ -160,7 +175,7 @@ private List GetResults(string query, List items) } }); - return true; + return true; } }); } @@ -181,5 +196,34 @@ private bool DoNotSearch(string query, List items) return false; } + private bool TryEnterPromptMode(string alias, Item item) + { + // Only items with placeholders (directly or via meta-chain leaves) trigger + // sequential prompts. The schema collection is cycle-safe and throws on + // unresolved aliases - either way, fall back to direct execution. + if (!item.IsParameterized && !item.IsMetaItem) + return false; + + IReadOnlyList schema; + try + { + schema = PlaceholderSchema.From(item, _itemRepo); + } + catch (InvalidOperationException) + { + return false; + } + if (schema.Count == 0) + return false; + + var first = schema[0]; + var newQuery = PromptModeParser.Format( + alias, + filled: Array.Empty<(string, string)>(), + nextName: first.Name, + nextInitial: first.Default ?? ""); + _context.API.ChangeQuery(newQuery, requery: true); + return true; + } } \ No newline at end of file diff --git a/SlickFlow.Tests/Unit/Items/ItemExecuteWithValuesTests.cs b/SlickFlow.Tests/Unit/Items/ItemExecuteWithValuesTests.cs new file mode 100644 index 0000000..af087a2 --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/ItemExecuteWithValuesTests.cs @@ -0,0 +1,100 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Moq; + +namespace SlickFlow.Tests.Unit.Items; + +public class ItemExecuteWithValuesTests +{ + [Fact] + public void Execute_LeafWithValues_DoesNotMutateStoredFileName() + { + // The leaf's stored FileName must remain in its parameterized form so + // subsequent prompts can substitute fresh values each call. + var item = new Item("1", "fake_app_<>.xyz"); + + item.Execute(values: new Dictionary { ["port"] = "8080" }); + + item.FileName.Should().Be("fake_app_<>.xyz"); + } + + [Fact] + public void Execute_LeafWithValues_DoesNotMutateStoredArguments() + { + var item = new Item("1", "fake_app.xyz") { Arguments = "--port <>" }; + + item.Execute(values: new Dictionary { ["port"] = "8080" }); + + item.Arguments.Should().Be("--port <>"); + } + + [Fact] + public void Execute_LeafWithoutValues_DoesNotThrowOnPlaceholderInFileName() + { + // No values dict provided: Execute uses the literal FileName. Process.Start + // may or may not succeed (Windows shell behavior varies), but Execute itself + // must not propagate exceptions - failures are swallowed by the catch. + var item = new Item("1", "fake_app_<>.xyz"); + + var act = () => item.Execute(); + + act.Should().NotThrow(); + item.FileName.Should().Be("fake_app_<>.xyz"); // never mutated + } + + [Fact] + public void Execute_MetaItem_PropagatesValuesToParameterizedLeaf() + { + var leaf = new Item("L", "fake_app_<>.xyz", new[] { "l" }); + var meta = new Item("M", "@l@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("l")).Returns(leaf); + + meta.Execute( + itemRepo: repo.Object, + values: new Dictionary { ["port"] = "8080" }); + + meta.ExecCount.Should().Be(1); + leaf.FileName.Should().Be("fake_app_<>.xyz"); // original preserved + } + + [Fact] + public void Execute_MetaItem_PropagatesValuesAcrossNestedMetas() + { + var leaf = new Item("L", "fake_app.xyz", new[] { "l" }) { Arguments = "<>" }; + var inner = new Item("I", "@l@", new[] { "inner" }); + var outer = new Item("O", "@inner@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("inner")).Returns(inner); + repo.Setup(r => r.GetItemByAlias("l")).Returns(leaf); + + outer.Execute( + itemRepo: repo.Object, + values: new Dictionary { ["port"] = "8080" }); + + outer.ExecCount.Should().Be(1); + inner.ExecCount.Should().Be(1); + leaf.Arguments.Should().Be("<>"); // original preserved + } + + [Fact] + public void Execute_MetaItem_WithValuesAndForceAdmin_BothPropagate() + { + var leaf = new Item("L", "fake_app.xyz", new[] { "l" }) { Arguments = "<>" }; + var meta = new Item("M", "@l@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("l")).Returns(leaf); + + var act = () => meta.Execute( + forceAdminExec: true, + itemRepo: repo.Object, + values: new Dictionary { ["port"] = "8080" }); + + act.Should().NotThrow(); + meta.ExecCount.Should().Be(1); + } +} diff --git a/SlickFlow.Tests/Unit/Items/ItemIsParameterizedTests.cs b/SlickFlow.Tests/Unit/Items/ItemIsParameterizedTests.cs new file mode 100644 index 0000000..df09e49 --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/ItemIsParameterizedTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; + +namespace SlickFlow.Tests.Unit.Items; + +public class ItemIsParameterizedTests +{ + [Fact] + public void IsParameterized_True_WhenFileNameHasPlaceholder() + { + var item = new Item("1", "http://localhost:<>"); + item.IsParameterized.Should().BeTrue(); + } + + [Fact] + public void IsParameterized_True_WhenArgumentsHasPlaceholder() + { + var item = new Item("1", "script.exe") { Arguments = "--port <>" }; + item.IsParameterized.Should().BeTrue(); + } + + [Fact] + public void IsParameterized_True_WhenBothHavePlaceholders() + { + var item = new Item("1", "<>.exe") { Arguments = "--port <>" }; + item.IsParameterized.Should().BeTrue(); + } + + [Fact] + public void IsParameterized_False_WhenPlainText() + { + var item = new Item("1", "notepad.exe") { Arguments = "--silent" }; + item.IsParameterized.Should().BeFalse(); + } + + [Fact] + public void IsParameterized_False_WhenEmpty() + { + var item = new Item(); + item.IsParameterized.Should().BeFalse(); + } + + [Fact] + public void IsParameterized_False_ForMetaItem() + { + // Meta items use @alias@ syntax, not <>; they're not parameterized. + var item = new Item("1", "@spotify@@discord@"); + item.IsParameterized.Should().BeFalse(); + } +} diff --git a/SlickFlow.Tests/Unit/Items/ItemSubstituteTests.cs b/SlickFlow.Tests/Unit/Items/ItemSubstituteTests.cs new file mode 100644 index 0000000..cdcc8d4 --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/ItemSubstituteTests.cs @@ -0,0 +1,162 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; + +namespace SlickFlow.Tests.Unit.Items; + +public class ItemSubstituteTests +{ + [Fact] + public void Substitute_NoPlaceholders_ReturnsCloneWithIdenticalContent() + { + var item = new Item("1", "notepad.exe") { Arguments = "file.txt" }; + + var clone = item.Substitute(new Dictionary()); + + clone.Should().NotBeSameAs(item); + clone.FileName.Should().Be("notepad.exe"); + clone.Arguments.Should().Be("file.txt"); + } + + [Fact] + public void Substitute_ReplacesPlaceholderInFileName() + { + var item = new Item("1", "http://localhost:<>"); + + var clone = item.Substitute(new Dictionary { ["port"] = "8080" }); + + clone.FileName.Should().Be("http://localhost:8080"); + } + + [Fact] + public void Substitute_ReplacesPlaceholderInArguments() + { + var item = new Item("1", "script.exe") { Arguments = "--port <>" }; + + var clone = item.Substitute(new Dictionary { ["port"] = "8080" }); + + clone.Arguments.Should().Be("--port 8080"); + } + + [Fact] + public void Substitute_ReplacesMultipleDistinctPlaceholders() + { + var item = new Item("1", "<>:<>") { Arguments = "--user <>" }; + + var clone = item.Substitute(new Dictionary + { + ["host"] = "example.com", + ["port"] = "443", + ["user"] = "admin" + }); + + clone.FileName.Should().Be("example.com:443"); + clone.Arguments.Should().Be("--user admin"); + } + + [Fact] + public void Substitute_ReplacesRepeatedPlaceholderEverywhere() + { + var item = new Item("1", "<>") { Arguments = "--also <> <>" }; + + var clone = item.Substitute(new Dictionary { ["port"] = "8080" }); + + clone.FileName.Should().Be("8080"); + clone.Arguments.Should().Be("--also 8080 8080"); + } + + [Fact] + public void Substitute_HandlesPlaceholderWithDefault() + { + var item = new Item("1", "http://localhost:<>"); + + var clone = item.Substitute(new Dictionary { ["port"] = "9090" }); + + clone.FileName.Should().Be("http://localhost:9090"); + } + + [Fact] + public void Substitute_HandlesPlaceholderWithHint() + { + var item = new Item("1", "http://<>"); + + var clone = item.Substitute(new Dictionary { ["host"] = "example.com" }); + + clone.FileName.Should().Be("http://example.com"); + } + + [Fact] + public void Substitute_HandlesPlaceholderWithDefaultAndHint() + { + var item = new Item("1", "http://<>:<>"); + + var clone = item.Substitute(new Dictionary + { + ["host"] = "example.com", + ["port"] = "443" + }); + + clone.FileName.Should().Be("http://example.com:443"); + } + + [Fact] + public void Substitute_FallsBackToDefault_WhenValueMissingFromDict() + { + var item = new Item("1", "http://localhost:<>"); + + var clone = item.Substitute(new Dictionary()); + + clone.FileName.Should().Be("http://localhost:8080"); + } + + [Fact] + public void Substitute_LeavesPlaceholderLiteral_WhenNoValueAndNoDefault() + { + // Best-effort safety: an unfilled placeholder with no default stays visible + // in the output rather than becoming empty, so failure modes are debuggable. + var item = new Item("1", "http://localhost:<>"); + + var clone = item.Substitute(new Dictionary()); + + clone.FileName.Should().Be("http://localhost:<>"); + } + + [Fact] + public void Substitute_DoesNotMutateOriginal() + { + var item = new Item("1", "<>"); + + var _ = item.Substitute(new Dictionary { ["port"] = "8080" }); + + item.FileName.Should().Be("<>"); + } + + [Fact] + public void Substitute_PreservesNonStringFields() + { + var item = new Item("42", "<>", new[] { "alias1", "alias2" }) + { + Arguments = "<>", + SubTitle = "sub", + RunAs = 1, + StartMode = 2, + WorkingDir = "C:/work", + ExecCount = 17, + IconPath = "icon.png" + }; + + var clone = item.Substitute(new Dictionary + { + ["host"] = "h", + ["arg"] = "a" + }); + + clone.Id.Should().Be("42"); + clone.Aliases.Should().BeEquivalentTo(new[] { "alias1", "alias2" }); + clone.SubTitle.Should().Be("sub"); + clone.RunAs.Should().Be(1); + clone.StartMode.Should().Be(2); + clone.WorkingDir.Should().Be("C:/work"); + clone.ExecCount.Should().Be(17); + clone.IconPath.Should().Be("icon.png"); + } +} diff --git a/SlickFlow.Tests/Unit/Items/Parameters/PlaceholderParserTests.cs b/SlickFlow.Tests/Unit/Items/Parameters/PlaceholderParserTests.cs new file mode 100644 index 0000000..5bc39fc --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/Parameters/PlaceholderParserTests.cs @@ -0,0 +1,144 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items.Parameters; + +namespace SlickFlow.Tests.Unit.Items.Parameters; + +public class PlaceholderParserTests +{ + [Fact] + public void Extract_NoPlaceholders_ReturnsEmpty() + { + PlaceholderParser.Extract("hello world").Should().BeEmpty(); + } + + [Fact] + public void Extract_EmptyString_ReturnsEmpty() + { + PlaceholderParser.Extract("").Should().BeEmpty(); + } + + [Fact] + public void Extract_NullString_ReturnsEmpty() + { + PlaceholderParser.Extract(null!).Should().BeEmpty(); + } + + [Fact] + public void Extract_NameOnly_ReturnsPlaceholderWithNullDefaultAndHint() + { + var result = PlaceholderParser.Extract("http://localhost:<>").ToList(); + + result.Should().ContainSingle(); + result[0].Name.Should().Be("port"); + result[0].Default.Should().BeNull(); + result[0].Hint.Should().BeNull(); + } + + [Fact] + public void Extract_NameAndDefault_ParsesBoth() + { + var result = PlaceholderParser.Extract("<>").ToList(); + + result.Should().ContainSingle(); + result[0].Name.Should().Be("port"); + result[0].Default.Should().Be("8080"); + result[0].Hint.Should().BeNull(); + } + + [Fact] + public void Extract_NameAndHint_ParsesBoth() + { + var result = PlaceholderParser.Extract("<>").ToList(); + + result.Should().ContainSingle(); + result[0].Name.Should().Be("port"); + result[0].Default.Should().BeNull(); + result[0].Hint.Should().Be("web server port"); + } + + [Fact] + public void Extract_NameDefaultAndHint_ParsesAll() + { + var result = PlaceholderParser.Extract("<>").ToList(); + + result.Should().ContainSingle(); + result[0].Name.Should().Be("port"); + result[0].Default.Should().Be("8080"); + result[0].Hint.Should().Be("web server port"); + } + + [Fact] + public void Extract_MultiplePlaceholders_PreservesOrder() + { + var result = PlaceholderParser.Extract("<> middle <> end <>").ToList(); + + result.Should().HaveCount(3); + result[0].Name.Should().Be("a"); + result[1].Name.Should().Be("b"); + result[1].Default.Should().Be("2"); + result[2].Name.Should().Be("c"); + result[2].Hint.Should().Be("hint"); + } + + [Fact] + public void Extract_RepeatedName_ReturnsBothOccurrences() + { + // Parser is the raw extractor - it returns every occurrence. + // Deduplication by name is the schema's responsibility, not the parser's. + var result = PlaceholderParser.Extract("<>:<>").ToList(); + + result.Should().HaveCount(2); + result[0].Name.Should().Be("port"); + result[1].Name.Should().Be("port"); + } + + [Theory] + [InlineData("")] // single angle brackets, not a placeholder + [InlineData("<<>>")] // empty name + [InlineData("<>")] // no opening + public void Extract_Malformed_ReturnsEmpty(string input) + { + PlaceholderParser.Extract(input).Should().BeEmpty(); + } + + [Fact] + public void Extract_GarbageBeforeValidPlaceholder_StillFindsValidOne() + { + // The parser finds placeholders wherever they appear; surrounding garbage is harmless. + var result = PlaceholderParser.Extract("<>").ToList(); + + result.Should().ContainSingle(); + result[0].Name.Should().Be("b"); + } + + [Fact] + public void Extract_EmptyDefault_AllowedAsEmptyString() + { + // <> means port has an explicit empty default. + var result = PlaceholderParser.Extract("<>").ToList(); + + result.Should().ContainSingle(); + result[0].Name.Should().Be("port"); + result[0].Default.Should().Be(""); + } + + [Fact] + public void ContainsPlaceholders_True_WhenAny() + { + PlaceholderParser.ContainsPlaceholders("hi <>").Should().BeTrue(); + } + + [Fact] + public void ContainsPlaceholders_False_WhenNone() + { + PlaceholderParser.ContainsPlaceholders("hi there").Should().BeFalse(); + } + + [Fact] + public void ContainsPlaceholders_False_OnNullOrEmpty() + { + PlaceholderParser.ContainsPlaceholders("").Should().BeFalse(); + PlaceholderParser.ContainsPlaceholders(null!).Should().BeFalse(); + } +} diff --git a/SlickFlow.Tests/Unit/Items/Parameters/PlaceholderSchemaTests.cs b/SlickFlow.Tests/Unit/Items/Parameters/PlaceholderSchemaTests.cs new file mode 100644 index 0000000..159fb7d --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/Parameters/PlaceholderSchemaTests.cs @@ -0,0 +1,147 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.Items.Parameters; +using Moq; + +namespace SlickFlow.Tests.Unit.Items.Parameters; + +public class PlaceholderSchemaTests +{ + [Fact] + public void From_LeafItemWithoutPlaceholders_ReturnsEmpty() + { + var item = new Item("1", "notepad.exe"); + PlaceholderSchema.From(item).Should().BeEmpty(); + } + + [Fact] + public void From_LeafItemWithFileNamePlaceholder_ReturnsIt() + { + var item = new Item("1", "http://localhost:<>"); + var result = PlaceholderSchema.From(item); + + result.Should().ContainSingle(); + result[0].Name.Should().Be("port"); + } + + [Fact] + public void From_LeafItem_ScansFileNameBeforeArguments() + { + var item = new Item("1", "<>") { Arguments = "<>" }; + + var result = PlaceholderSchema.From(item); + + result.Should().HaveCount(2); + result[0].Name.Should().Be("host"); + result[1].Name.Should().Be("port"); + } + + [Fact] + public void From_LeafItem_DeduplicatesByName_KeepingFirstOccurrence() + { + // Same name in FileName (with default) and Arguments (no default). + // First occurrence (with default) wins. + var item = new Item("1", "<>") { Arguments = "--port <>" }; + + var result = PlaceholderSchema.From(item); + + result.Should().ContainSingle(); + result[0].Name.Should().Be("port"); + result[0].Default.Should().Be("8080"); + } + + [Fact] + public void From_MetaItemRequiresRepository() + { + var meta = new Item("1", "@a@"); + + var act = () => PlaceholderSchema.From(meta); + + act.Should().Throw() + .WithMessage("*repository*"); + } + + [Fact] + public void From_MetaChain_CollectsFromAllParameterizedLeaves() + { + var leafA = new Item("A", "<>", new[] { "a" }); + var leafB = new Item("B", "script.exe", new[] { "b" }) { Arguments = "--port <>" }; + var meta = new Item("M", "@a@@b@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("a")).Returns(leafA); + repo.Setup(r => r.GetItemByAlias("b")).Returns(leafB); + + var result = PlaceholderSchema.From(meta, repo.Object); + + result.Should().HaveCount(2); + result[0].Name.Should().Be("host"); + result[1].Name.Should().Be("port"); + } + + [Fact] + public void From_MetaChain_DeduplicatesAcrossLeaves() + { + // Two leaves both use <>. Schema returns one entry, from first encounter. + var leafA = new Item("A", "http://x:<>", new[] { "a" }); + var leafB = new Item("B", "http://y:<>", new[] { "b" }); + var meta = new Item("M", "@a@@b@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("a")).Returns(leafA); + repo.Setup(r => r.GetItemByAlias("b")).Returns(leafB); + + var result = PlaceholderSchema.From(meta, repo.Object); + + result.Should().ContainSingle(); + result[0].Name.Should().Be("port"); + result[0].Default.Should().Be("8080"); + } + + [Fact] + public void From_MetaChain_NestedMetaItems_WalksThrough() + { + var leaf = new Item("3", "<>", new[] { "np" }); + var inner = new Item("2", "@np@", new[] { "inner" }); + var outer = new Item("1", "@inner@"); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("inner")).Returns(inner); + repo.Setup(r => r.GetItemByAlias("np")).Returns(leaf); + + var result = PlaceholderSchema.From(outer, repo.Object); + + result.Should().ContainSingle(); + result[0].Name.Should().Be("port"); + } + + [Fact] + public void From_MetaChain_ThrowsOnCycle() + { + var a = new Item("A", "@b@", new[] { "a" }); + var b = new Item("B", "@a@", new[] { "b" }); + + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("a")).Returns(a); + repo.Setup(r => r.GetItemByAlias("b")).Returns(b); + + var act = () => PlaceholderSchema.From(a, repo.Object); + + act.Should().Throw() + .WithMessage("*cycle*"); + } + + [Fact] + public void From_MetaChain_ThrowsOnMissingAlias() + { + var meta = new Item("1", "@missing@"); + var repo = new Mock(); + repo.Setup(r => r.GetItemByAlias("missing")).Returns((Item?)null); + + var act = () => PlaceholderSchema.From(meta, repo.Object); + + act.Should().Throw() + .WithMessage("*Unknown aliases*"); + } +} diff --git a/SlickFlow.Tests/Unit/Items/Parameters/PromptModeHandlerTests.cs b/SlickFlow.Tests/Unit/Items/Parameters/PromptModeHandlerTests.cs new file mode 100644 index 0000000..0db811f --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/Parameters/PromptModeHandlerTests.cs @@ -0,0 +1,153 @@ +using FluentAssertions; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SlickFlow.Items; +using Flow.Launcher.Plugin.SlickFlow.Items.Abstract; +using Flow.Launcher.Plugin.SlickFlow.Items.Parameters; +using Moq; + +namespace SlickFlow.Tests.Unit.Items.Parameters; + +public class PromptModeHandlerTests +{ + private static (Mock repo, Mock api, PromptModeHandler handler) + BuildHandler() + { + var repo = new Mock(); + var api = new Mock(); + var handler = new PromptModeHandler(repo.Object, api.Object); + return (repo, api, handler); + } + + [Fact] + public void BuildResults_UnknownAlias_ReturnsEmpty() + { + var (repo, _, handler) = BuildHandler(); + repo.Setup(r => r.GetItemByAlias("missing")).Returns((Item?)null); + + var state = new PromptModeState("missing", Array.Empty<(string, string)>(), "port", ""); + handler.BuildResults(state).Should().BeEmpty(); + } + + [Fact] + public void BuildResults_AliasMatchesNonParameterizedItem_ReturnsEmpty() + { + var (repo, _, handler) = BuildHandler(); + repo.Setup(r => r.GetItemByAlias("plain")).Returns(new Item("1", "notepad.exe")); + + var state = new PromptModeState("plain", Array.Empty<(string, string)>(), "port", ""); + handler.BuildResults(state).Should().BeEmpty(); + } + + [Fact] + public void BuildResults_CurrentNameNotInSchema_ReturnsEmpty() + { + // The query references a placeholder name that doesn't exist on the item. + // Most likely the user edited the bar to a stale name; reject gracefully. + var (repo, _, handler) = BuildHandler(); + repo.Setup(r => r.GetItemByAlias("server")).Returns(new Item("1", "http://x:<>")); + + var state = new PromptModeState("server", Array.Empty<(string, string)>(), "nope", "8080"); + handler.BuildResults(state).Should().BeEmpty(); + } + + [Fact] + public void BuildResults_FirstPromptShowsPreviewAndNextHint() + { + var (repo, _, handler) = BuildHandler(); + repo.Setup(r => r.GetItemByAlias("server")).Returns( + new Item("1", "http://<>:<>")); + + var state = new PromptModeState("server", Array.Empty<(string, string)>(), "host", "0.0.0.0"); + var results = handler.BuildResults(state); + + results.Should().ContainSingle(); + // Preview uses the typed value for the current placeholder and falls back + // to each remaining placeholder's default so the user sees the real launch target. + results[0].Title.Should().Be("http://0.0.0.0:8080"); + results[0].SubTitle.Should().Contain("host"); + results[0].SubTitle.Should().Contain("port"); // hints at next placeholder + } + + [Fact] + public void BuildResults_LastPromptShowsLaunchPrompt() + { + var (repo, _, handler) = BuildHandler(); + repo.Setup(r => r.GetItemByAlias("server")).Returns( + new Item("1", "http://localhost:<>")); + + var state = new PromptModeState("server", Array.Empty<(string, string)>(), "port", "8080"); + var results = handler.BuildResults(state); + + results.Should().ContainSingle(); + results[0].Title.Should().Be("http://localhost:8080"); + results[0].SubTitle.Should().Contain("Enter to launch"); + } + + [Fact] + public void BuildResults_ShowsHintInSubtitle() + { + var (repo, _, handler) = BuildHandler(); + repo.Setup(r => r.GetItemByAlias("server")).Returns( + new Item("1", "<>")); + + var state = new PromptModeState("server", Array.Empty<(string, string)>(), "port", ""); + var results = handler.BuildResults(state); + + results[0].SubTitle.Should().Contain("web server port"); + } + + [Fact] + public void Action_NotLastPlaceholder_AdvancesViaChangeQuery_KeepsFlowOpen() + { + var (repo, api, handler) = BuildHandler(); + repo.Setup(r => r.GetItemByAlias("server")).Returns( + new Item("1", "http://<>:<>")); + + var state = new PromptModeState("server", Array.Empty<(string, string)>(), "host", "0.0.0.0"); + var result = handler.BuildResults(state).Single(); + + var closeFlow = result.Action(new ActionContext()); + + closeFlow.Should().BeFalse(); + api.Verify(a => a.ChangeQuery( + "server | host=0.0.0.0 | port: 8080", // default 8080 pre-filled + true), Times.Once); + } + + [Fact] + public void Action_LastPlaceholder_ExecutesItemAndClosesFlow() + { + var (repo, api, handler) = BuildHandler(); + var item = new Item("1", "fake_app_<>.xyz"); + repo.Setup(r => r.GetItemByAlias("server")).Returns(item); + + var state = new PromptModeState("server", Array.Empty<(string, string)>(), "port", "8080"); + var result = handler.BuildResults(state).Single(); + + var closeFlow = result.Action(new ActionContext()); + + closeFlow.Should().BeTrue(); + api.Verify(a => a.ChangeQuery(It.IsAny(), It.IsAny()), Times.Never); + repo.Verify(r => r.UpdateItem(item), Times.Once); + item.FileName.Should().Be("fake_app_<>.xyz"); // never mutated + } + + [Fact] + public void Action_MetaItemWithParameterizedLeaf_ExecutesChainOnLastPrompt() + { + var (repo, _, handler) = BuildHandler(); + var leaf = new Item("L", "fake_app_<>.xyz", new[] { "l" }); + var meta = new Item("M", "@l@"); + repo.Setup(r => r.GetItemByAlias("meta")).Returns(meta); + repo.Setup(r => r.GetItemByAlias("l")).Returns(leaf); + + var state = new PromptModeState("meta", Array.Empty<(string, string)>(), "port", "8080"); + var result = handler.BuildResults(state).Single(); + + var closeFlow = result.Action(new ActionContext()); + + closeFlow.Should().BeTrue(); + meta.ExecCount.Should().Be(1); + leaf.FileName.Should().Be("fake_app_<>.xyz"); // original preserved + } +} diff --git a/SlickFlow.Tests/Unit/Items/Parameters/PromptModeParserTests.cs b/SlickFlow.Tests/Unit/Items/Parameters/PromptModeParserTests.cs new file mode 100644 index 0000000..b64e8b9 --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/Parameters/PromptModeParserTests.cs @@ -0,0 +1,126 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Items.Parameters; + +namespace SlickFlow.Tests.Unit.Items.Parameters; + +public class PromptModeParserTests +{ + [Theory] + [InlineData("")] + [InlineData("server")] // no pipe yet, normal search + [InlineData(" ")] + [InlineData("server |")] // pipe but no current prompt + [InlineData("server | port")] // no colon + [InlineData("server | nokey | port: 8080")] // middle segment lacks '=' + [InlineData("| port: 8080")] // empty alias + public void TryParse_NotInPromptMode_ReturnsNull(string query) + { + PromptModeParser.TryParse(query).Should().BeNull(); + } + + [Fact] + public void TryParse_FirstPromptEmpty_ReturnsState() + { + var state = PromptModeParser.TryParse("server | port: "); + + state.Should().NotBeNull(); + state!.Alias.Should().Be("server"); + state.Filled.Should().BeEmpty(); + state.CurrentName.Should().Be("port"); + state.CurrentInput.Should().Be(""); + } + + [Fact] + public void TryParse_FirstPromptWithInput_ReturnsInput() + { + var state = PromptModeParser.TryParse("server | port: 8080"); + + state.Should().NotBeNull(); + state!.CurrentName.Should().Be("port"); + state.CurrentInput.Should().Be("8080"); + } + + [Fact] + public void TryParse_OneFilledPlusCurrent_ReturnsBoth() + { + var state = PromptModeParser.TryParse("server | port=8080 | host: 0.0.0.0"); + + state.Should().NotBeNull(); + state!.Alias.Should().Be("server"); + state.Filled.Should().HaveCount(1); + state.Filled[0].Should().Be(("port", "8080")); + state.CurrentName.Should().Be("host"); + state.CurrentInput.Should().Be("0.0.0.0"); + } + + [Fact] + public void TryParse_MultipleFilled_PreservesOrder() + { + var state = PromptModeParser.TryParse("server | a=1 | b=2 | c=3 | d: 4"); + + state.Should().NotBeNull(); + state!.Filled.Should().HaveCount(3); + state.Filled[0].Should().Be(("a", "1")); + state.Filled[1].Should().Be(("b", "2")); + state.Filled[2].Should().Be(("c", "3")); + state.CurrentName.Should().Be("d"); + state.CurrentInput.Should().Be("4"); + } + + [Fact] + public void TryParse_FilledValueWithEquals_SplitsOnFirstEqualsOnly() + { + var state = PromptModeParser.TryParse("server | conn=user=admin | host: x"); + + state.Should().NotBeNull(); + state!.Filled[0].Should().Be(("conn", "user=admin")); + } + + [Fact] + public void TryParse_CurrentInputWithColon_SplitsOnFirstColonOnly() + { + var state = PromptModeParser.TryParse("server | url: http://x:9000"); + + state.Should().NotBeNull(); + state!.CurrentName.Should().Be("url"); + state.CurrentInput.Should().Be("http://x:9000"); + } + + [Fact] + public void Format_NoFilled_BuildsFirstPromptString() + { + var formatted = PromptModeParser.Format("server", filled: Array.Empty<(string, string)>(), "port", "8080"); + + formatted.Should().Be("server | port: 8080"); + } + + [Fact] + public void Format_OneFilled_AppendsKeyValueThenCurrent() + { + var formatted = PromptModeParser.Format( + "server", + filled: new[] { ("port", "8080") }, + "host", + ""); + + formatted.Should().Be("server | port=8080 | host: "); + } + + [Fact] + public void Format_AndParse_RoundTrip() + { + var formatted = PromptModeParser.Format( + "server", + filled: new[] { ("port", "8080"), ("host", "0.0.0.0") }, + "user", + "admin"); + + var state = PromptModeParser.TryParse(formatted); + + state.Should().NotBeNull(); + state!.Alias.Should().Be("server"); + state.Filled.Should().BeEquivalentTo(new[] { ("port", "8080"), ("host", "0.0.0.0") }); + state.CurrentName.Should().Be("user"); + state.CurrentInput.Should().Be("admin"); + } +} From d49b4bd4a13ba508acced4e79c7503495beb882d Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Tue, 12 May 2026 23:07:19 +0300 Subject: [PATCH 18/19] fix: max-score prompt-mode result so it dominates other plugins Flow Launcher merges results across every loaded plugin. Without an explicit Score the prompt-mode Result defaulted to 0, letting global plugins like web search outrank it; the user pressed Enter on the active prompt and ended up triggering a Google search of the prompt-mode query text instead of advancing the prompt. Setting Score = int.MaxValue guarantees the active prompt is always the top result, which matches the intent: while filling placeholders, the prompt must be the only thing the user can act on. Co-Authored-By: Claude Opus 4.7 --- .../Items/Parameters/PromptModeHandler.cs | 4 ++++ .../Items/Parameters/PromptModeHandlerTests.cs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeHandler.cs b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeHandler.cs index 382e890..fb3f2a8 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeHandler.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeHandler.cs @@ -64,6 +64,10 @@ public List BuildResults(PromptModeState state) { new Result { + // Score must dominate any other plugin's result (web search, etc.) so + // the user lands on the prompt result on Enter. Without this, an empty + // Score (0) lets unrelated plugins outrank the active prompt. + Score = int.MaxValue, Title = title, SubTitle = subtitle, IcoPath = item.IconPath, diff --git a/SlickFlow.Tests/Unit/Items/Parameters/PromptModeHandlerTests.cs b/SlickFlow.Tests/Unit/Items/Parameters/PromptModeHandlerTests.cs index 0db811f..f70dfdc 100644 --- a/SlickFlow.Tests/Unit/Items/Parameters/PromptModeHandlerTests.cs +++ b/SlickFlow.Tests/Unit/Items/Parameters/PromptModeHandlerTests.cs @@ -68,6 +68,22 @@ public void BuildResults_FirstPromptShowsPreviewAndNextHint() results[0].SubTitle.Should().Contain("port"); // hints at next placeholder } + [Fact] + public void BuildResults_ResultHasMaxScore_SoItDominatesOtherPluginResults() + { + // Flow Launcher merges results across all plugins. Without a high Score, + // unrelated plugins (web search, etc.) outrank the active prompt and the + // user ends up triggering them instead of advancing the prompt. + var (repo, _, handler) = BuildHandler(); + repo.Setup(r => r.GetItemByAlias("server")).Returns( + new Item("1", "http://localhost:<>")); + + var state = new PromptModeState("server", Array.Empty<(string, string)>(), "port", "8080"); + var results = handler.BuildResults(state); + + results[0].Score.Should().Be(int.MaxValue); + } + [Fact] public void BuildResults_LastPromptShowsLaunchPrompt() { From b471453c80ebee2449b48361994c349d43eed904 Mon Sep 17 00:00:00 2001 From: Ofek Tal Date: Sat, 23 May 2026 21:31:51 +0300 Subject: [PATCH 19/19] Icon resolve fixes --- .../Utils/Icons/ExecutablePathResolver.cs | 86 ++++++++++ .../Utils/Icons/IconHelper.cs | 12 +- .../Unit/Utils/ExecutablePathResolverTests.cs | 147 ++++++++++++++++++ SlickFlow.Tests/Unit/Utils/IconHelperTests.cs | 53 +++++++ 4 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ExecutablePathResolver.cs create mode 100644 SlickFlow.Tests/Unit/Utils/ExecutablePathResolverTests.cs create mode 100644 SlickFlow.Tests/Unit/Utils/IconHelperTests.cs diff --git a/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ExecutablePathResolver.cs b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ExecutablePathResolver.cs new file mode 100644 index 0000000..9e6e357 --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/ExecutablePathResolver.cs @@ -0,0 +1,86 @@ +using System.IO; + +namespace Flow.Launcher.Plugin.SlickFlow.Utils.Icons; + +/// +/// Resolves a bare command (e.g. notepad) to an absolute file path by walking +/// PATH and trying each PATHEXT extension. Mirrors how where.exe +/// and Process.Start locate executables, so the icon code can find files that +/// alone would miss. +/// +internal static class ExecutablePathResolver +{ + private const string DefaultPathExt = ".COM;.EXE;.BAT;.CMD"; + + public static bool TryResolve(string command, out string fullPath) + => TryResolve( + command, + out fullPath, + Environment.GetEnvironmentVariable("PATH") ?? string.Empty, + Environment.GetEnvironmentVariable("PATHEXT") ?? DefaultPathExt); + + public static bool TryResolve(string command, out string fullPath, string pathEnv, string pathExtEnv) + { + fullPath = string.Empty; + + if (string.IsNullOrWhiteSpace(command)) + return false; + + // Anything that looks like a path is the caller's job to validate. + if (Path.IsPathRooted(command) || + command.Contains(Path.DirectorySeparatorChar) || + command.Contains(Path.AltDirectorySeparatorChar)) + { + return false; + } + + var dirs = (pathEnv ?? string.Empty).Split(Path.PathSeparator); + var exts = (pathExtEnv ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries); + var hasExt = Path.HasExtension(command); + + foreach (var rawDir in dirs) + { + var dir = rawDir?.Trim().Trim('"') ?? string.Empty; + if (string.IsNullOrEmpty(dir)) + continue; + + if (hasExt) + { + if (TryCandidate(dir, command, out fullPath)) + return true; + } + else + { + foreach (var ext in exts) + { + if (TryCandidate(dir, command + ext, out fullPath)) + return true; + } + } + } + + return false; + } + + private static bool TryCandidate(string dir, string fileName, out string fullPath) + { + fullPath = string.Empty; + try + { + var candidate = Path.Combine(dir, fileName); + if (!File.Exists(candidate)) + return false; + + // Windows is case-insensitive; recover the on-disk casing so callers + // that display the resolved path get the real filename. + var matches = Directory.GetFiles(dir, fileName); + fullPath = matches.Length > 0 ? matches[0] : candidate; + return true; + } + catch (ArgumentException) + { + // Illegal characters in the PATH entry; skip it. + } + return false; + } +} diff --git a/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/IconHelper.cs b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/IconHelper.cs index 65ccb3e..7aac5a5 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/IconHelper.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/IconHelper.cs @@ -74,9 +74,17 @@ public IconHelper(string iconFolder, Action? log = null) return (true, targetPath); } } - else if (File.Exists(pathOrUrl) || Directory.Exists(pathOrUrl)) + else { - if (TrySaveFromLocalPath(pathOrUrl, targetPath)) + string localPath = pathOrUrl; + if (!File.Exists(localPath) && !Directory.Exists(localPath) + && ExecutablePathResolver.TryResolve(pathOrUrl, out var resolved)) + { + localPath = resolved; + } + + if ((File.Exists(localPath) || Directory.Exists(localPath)) + && TrySaveFromLocalPath(localPath, targetPath)) { _attempts.TryRemove(attemptKey, out _); return (true, targetPath); diff --git a/SlickFlow.Tests/Unit/Utils/ExecutablePathResolverTests.cs b/SlickFlow.Tests/Unit/Utils/ExecutablePathResolverTests.cs new file mode 100644 index 0000000..c104ae5 --- /dev/null +++ b/SlickFlow.Tests/Unit/Utils/ExecutablePathResolverTests.cs @@ -0,0 +1,147 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Utils.Icons; + +namespace SlickFlow.Tests.Unit.Utils; + +public class ExecutablePathResolverTests : IDisposable +{ + private readonly List _tempDirs = new(); + + private string NewTempDir() + { + var dir = Path.Combine(Path.GetTempPath(), "SlickFlowResolverTests_" + Guid.NewGuid()); + Directory.CreateDirectory(dir); + _tempDirs.Add(dir); + return dir; + } + + public void Dispose() + { + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryResolve_NullOrWhitespace_ReturnsFalse(string? input) + { + ExecutablePathResolver.TryResolve(input!, out var resolved, "any", ".EXE") + .Should().BeFalse(); + resolved.Should().BeEmpty(); + } + + [Fact] + public void TryResolve_RootedPath_ReturnsFalse() + { + // Resolver intentionally defers rooted paths to the caller's File.Exists check. + ExecutablePathResolver.TryResolve(@"C:\Windows\notepad.exe", out var resolved, "any", ".EXE") + .Should().BeFalse(); + resolved.Should().BeEmpty(); + } + + [Fact] + public void TryResolve_PathWithSeparator_ReturnsFalse() + { + ExecutablePathResolver.TryResolve(@"subdir\notepad.exe", out _, "any", ".EXE") + .Should().BeFalse(); + ExecutablePathResolver.TryResolve("subdir/notepad.exe", out _, "any", ".EXE") + .Should().BeFalse(); + } + + [Fact] + public void TryResolve_BareCommand_FindsExecutableViaPathExt() + { + var dir = NewTempDir(); + var exePath = Path.Combine(dir, "fakebin.exe"); + File.WriteAllBytes(exePath, Array.Empty()); + + var ok = ExecutablePathResolver.TryResolve("fakebin", out var resolved, dir, ".EXE;.CMD"); + ok.Should().BeTrue(); + resolved.Should().Be(exePath); + } + + [Fact] + public void TryResolve_CommandWithExtension_DoesNotDoubleAppend() + { + var dir = NewTempDir(); + var exePath = Path.Combine(dir, "fakebin.exe"); + File.WriteAllBytes(exePath, Array.Empty()); + + var ok = ExecutablePathResolver.TryResolve("fakebin.exe", out var resolved, dir, ".EXE;.CMD"); + ok.Should().BeTrue(); + resolved.Should().Be(exePath); + } + + [Fact] + public void TryResolve_NotFound_ReturnsFalse() + { + var dir = NewTempDir(); + var ok = ExecutablePathResolver.TryResolve("doesnotexist_xyzzy", out var resolved, dir, ".EXE;.CMD"); + ok.Should().BeFalse(); + resolved.Should().BeEmpty(); + } + + [Fact] + public void TryResolve_MultipleDirs_ReturnsFirstHit() + { + var d1 = NewTempDir(); + var d2 = NewTempDir(); + + var firstHit = Path.Combine(d1, "tool.cmd"); + File.WriteAllBytes(firstHit, Array.Empty()); + File.WriteAllBytes(Path.Combine(d2, "tool.cmd"), Array.Empty()); + + var path = d1 + Path.PathSeparator + d2; + var ok = ExecutablePathResolver.TryResolve("tool", out var resolved, path, ".EXE;.CMD"); + + ok.Should().BeTrue(); + resolved.Should().Be(firstHit); + } + + [Fact] + public void TryResolve_PathExtOrderControlsExtensionPreference() + { + var dir = NewTempDir(); + // Both .bat and .exe exist; PATHEXT lists .EXE first, so .exe must win. + File.WriteAllBytes(Path.Combine(dir, "tool.bat"), Array.Empty()); + var exePath = Path.Combine(dir, "tool.exe"); + File.WriteAllBytes(exePath, Array.Empty()); + + var ok = ExecutablePathResolver.TryResolve("tool", out var resolved, dir, ".EXE;.BAT"); + + ok.Should().BeTrue(); + resolved.Should().Be(exePath); + } + + [Fact] + public void TryResolve_QuotedPathEntries_AreUnquoted() + { + var dir = NewTempDir(); + var exePath = Path.Combine(dir, "fakebin.exe"); + File.WriteAllBytes(exePath, Array.Empty()); + + var path = "\"" + dir + "\""; + var ok = ExecutablePathResolver.TryResolve("fakebin", out var resolved, path, ".EXE"); + + ok.Should().BeTrue(); + resolved.Should().Be(exePath); + } + + [Fact] + public void TryResolve_EmptyOrIllegalEntries_AreSkipped() + { + var dir = NewTempDir(); + var exePath = Path.Combine(dir, "fakebin.exe"); + File.WriteAllBytes(exePath, Array.Empty()); + + var path = Path.PathSeparator + " " + Path.PathSeparator + "" + Path.PathSeparator + dir; + var ok = ExecutablePathResolver.TryResolve("fakebin", out var resolved, path, ".EXE"); + + ok.Should().BeTrue(); + resolved.Should().Be(exePath); + } +} diff --git a/SlickFlow.Tests/Unit/Utils/IconHelperTests.cs b/SlickFlow.Tests/Unit/Utils/IconHelperTests.cs new file mode 100644 index 0000000..8df4abb --- /dev/null +++ b/SlickFlow.Tests/Unit/Utils/IconHelperTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using Flow.Launcher.Plugin.SlickFlow.Utils.Icons; + +namespace SlickFlow.Tests.Unit.Utils; + +public class IconHelperTests : IDisposable +{ + private readonly List _tempDirs = new(); + + private string NewTempDir(string prefix) + { + var dir = Path.Combine(Path.GetTempPath(), prefix + "_" + Guid.NewGuid()); + Directory.CreateDirectory(dir); + _tempDirs.Add(dir); + return dir; + } + + public void Dispose() + { + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + + [Fact] + public async Task TrySaveIconAsync_BareCommandResolvedOnPath_SavesIcon() + { + // Regression: items added with bare command names (e.g. "notepad") were + // never given icons because File.Exists("notepad") is false. The helper + // must resolve PATH+PATHEXT before giving up. + var iconDir = NewTempDir("SlickFlowIconHelperTests"); + var helper = new IconHelper(iconDir); + + var (ok, savedPath) = await helper.TrySaveIconAsync("cmd", "test-id-cmd"); + + ok.Should().BeTrue(); + savedPath.Should().NotBeEmpty(); + File.Exists(savedPath).Should().BeTrue(); + } + + [Fact] + public async Task TrySaveIconAsync_NonExistentCommand_ReturnsFalse() + { + var iconDir = NewTempDir("SlickFlowIconHelperTests"); + var helper = new IconHelper(iconDir); + + var (ok, savedPath) = await helper.TrySaveIconAsync("definitely_not_a_real_command_xyzzy", "test-id-missing"); + + ok.Should().BeFalse(); + savedPath.Should().BeEmpty(); + } +}