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/.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/Flow.Launcher.Plugin.SlickFlow/Assets/Graph.png b/Flow.Launcher.Plugin.SlickFlow/Assets/Graph.png index cb1018e..7fad06e 100644 Binary files a/Flow.Launcher.Plugin.SlickFlow/Assets/Graph.png and b/Flow.Launcher.Plugin.SlickFlow/Assets/Graph.png differ diff --git a/Flow.Launcher.Plugin.SlickFlow/Assets/IncognitoIcon.png b/Flow.Launcher.Plugin.SlickFlow/Assets/IncognitoIcon.png index 2cf8936..0a26df1 100644 Binary files a/Flow.Launcher.Plugin.SlickFlow/Assets/IncognitoIcon.png and b/Flow.Launcher.Plugin.SlickFlow/Assets/IncognitoIcon.png differ diff --git a/Flow.Launcher.Plugin.SlickFlow/Assets/Powershell.png b/Flow.Launcher.Plugin.SlickFlow/Assets/Powershell.png index 789bf72..dff1433 100644 Binary files a/Flow.Launcher.Plugin.SlickFlow/Assets/Powershell.png and b/Flow.Launcher.Plugin.SlickFlow/Assets/Powershell.png differ diff --git a/Flow.Launcher.Plugin.SlickFlow/Assets/Shield.png b/Flow.Launcher.Plugin.SlickFlow/Assets/Shield.png index 69b05e9..f0325da 100644 Binary files a/Flow.Launcher.Plugin.SlickFlow/Assets/Shield.png and b/Flow.Launcher.Plugin.SlickFlow/Assets/Shield.png differ diff --git a/Flow.Launcher.Plugin.SlickFlow/Assets/Tag.png b/Flow.Launcher.Plugin.SlickFlow/Assets/Tag.png index f926150..1820583 100644 Binary files a/Flow.Launcher.Plugin.SlickFlow/Assets/Tag.png and b/Flow.Launcher.Plugin.SlickFlow/Assets/Tag.png differ diff --git a/Flow.Launcher.Plugin.SlickFlow/Assets/Terminal.png b/Flow.Launcher.Plugin.SlickFlow/Assets/Terminal.png index 138d335..edbf5eb 100644 Binary files a/Flow.Launcher.Plugin.SlickFlow/Assets/Terminal.png and b/Flow.Launcher.Plugin.SlickFlow/Assets/Terminal.png differ 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..1849748 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/SetIconCommandHandler.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandHandlers/SetIconCommandHandler.cs @@ -1,14 +1,21 @@ 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; 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 +28,7 @@ public List Handle(string[] args) { Title = "Usage: seticon ", Score = int.MaxValue - 1000, - IcoPath = _plugin._slickFlowIcon + IcoPath = _slickFlowIcon }); return results; } @@ -29,11 +36,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 +49,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 +65,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..c33578f 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Commands/CommandProcessor.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Commands/CommandProcessor.cs @@ -1,4 +1,9 @@ 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; +using Flow.Launcher.Plugin.SlickFlow.Utils.Icons; namespace Flow.Launcher.Plugin.SlickFlow.Commands; @@ -6,16 +11,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 +32,4 @@ public List Process(string command, string[] args) } return new List(); } -} \ No newline at end of file +} 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/Flow.Launcher.Plugin.SlickFlow.csproj b/Flow.Launcher.Plugin.SlickFlow/Flow.Launcher.Plugin.SlickFlow.csproj index be311b6..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 @@ -39,21 +40,28 @@ + + + - + - + - + - + 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/Item.cs b/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs index 52ac71f..f7ce073 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Item.cs @@ -1,10 +1,13 @@ using System.Diagnostics; 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 { - 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; @@ -43,30 +46,66 @@ 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 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, 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 @@ -81,7 +120,86 @@ public void Execute(bool forceAdminExec = false) } 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, + 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(); + WalkMetaChain(itemRepo, new HashSet(), leaves.Add, metas.Add); + + foreach (var leaf in leaves) + leaf.Execute(forceAdminExec, itemRepo, values); + + foreach (var meta in metas) + meta.ExecCount++; + } + + /// + /// 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, + 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 + // 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 + { + if (!IsMetaItem) + { + onLeaf(this); + return; + } + + 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) + target.WalkMetaChain(itemRepo, visited, onLeaf, onMetaExit); + + onMetaExit?.Invoke(this); + } + finally + { + visited.Remove(this); } } public bool IsUrl(string fileName) 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(); 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/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/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..fb3f2a8 --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Items/Parameters/PromptModeHandler.cs @@ -0,0 +1,121 @@ +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 + { + // 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, + 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 e84fea4..64b7b36 100644 --- a/Flow.Launcher.Plugin.SlickFlow/Main.cs +++ b/Flow.Launcher.Plugin.SlickFlow/Main.cs @@ -5,8 +5,10 @@ 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; using Flow.Launcher.Plugin.SlickFlow.ViewModels.Settings; namespace Flow.Launcher.Plugin.SlickFlow; @@ -17,18 +19,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 @@ -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); + _itemValidator = new ItemValidator(_itemRepo, _slickFlowIcon); + _commandProcessor = new CommandProcessor(_itemRepo, _itemValidator, _iconHelper, _slickFlowIcon); _itemSearcher = new ItemSearcher(); - _itemValidator = new ItemValidator(this); _context.API.LogInfo("SlickFlow", "Plugin loaded successfully."); } @@ -60,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); @@ -88,7 +98,7 @@ public List LoadContextMenus(Result selectedResult) return new List(); var builder = new ContextMenuBuilder(); - return builder.Build(selectedResult, item); + return builder.Build(selectedResult, item, _itemRepo); } @@ -148,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.UpdateItem(item); + item.Execute(itemRepo: _itemRepo); + _itemRepo.UpdateItem(item); } catch (Exception ex) { @@ -162,7 +175,7 @@ private List GetResults(string query, List items) } }); - return true; + return true; } }); } @@ -183,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/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 } } 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/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/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..7aac5a5 --- /dev/null +++ b/Flow.Launcher.Plugin.SlickFlow/Utils/Icons/IconHelper.cs @@ -0,0 +1,177 @@ +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 + { + 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); + } + } + + 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/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..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()); } @@ -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; @@ -161,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/Settings.xaml b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/Settings.xaml index 78290b2..28504bb 100644 --- a/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/Settings.xaml +++ b/Flow.Launcher.Plugin.SlickFlow/ViewModels/Settings/Settings.xaml @@ -73,8 +73,14 @@ 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(); @@ -41,7 +56,7 @@ public SettingsViewModel(ItemRepository repo) #region DB File Properties - private string _dbFilePath; + private string _dbFilePath = string.Empty; public string DbFilePath { get => _dbFilePath; @@ -53,7 +68,7 @@ public string DbFilePath } } - private string _savedDbPath; + private string _savedDbPath = string.Empty; public string SavedDbPath { get => _savedDbPath; @@ -69,7 +84,7 @@ private set #region Icon Directory Properties - private string _iconDirPath; + private string _iconDirPath = string.Empty; public string IconDirPath { get => _iconDirPath; @@ -81,7 +96,7 @@ public string IconDirPath } } - private string _savedIconDirPath; + private string _savedIconDirPath = string.Empty; public string SavedIconDirPath { get => _savedIconDirPath; @@ -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() 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 new file mode 100644 index 0000000..d4a7c3b --- /dev/null +++ b/SlickFlow.Tests/SlickFlow.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0-windows + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + 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..c7226f9 --- /dev/null +++ b/SlickFlow.Tests/Unit/Commands/CommandProcessorTests.cs @@ -0,0 +1,53 @@ +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; +using Flow.Launcher.Plugin.SlickFlow.Utils.Icons; + +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..9a3b408 --- /dev/null +++ b/SlickFlow.Tests/Unit/Commands/SetIconCommandHandlerTests.cs @@ -0,0 +1,49 @@ +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; +using Flow.Launcher.Plugin.SlickFlow.Utils.Icons; + +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/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/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/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/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/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/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/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); + } +} 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); + } +} 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..f70dfdc --- /dev/null +++ b/SlickFlow.Tests/Unit/Items/Parameters/PromptModeHandlerTests.cs @@ -0,0 +1,169 @@ +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_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() + { + 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"); + } +} 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(); + } +} 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(); + } +} 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 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"