diff --git a/.pipelines/ci/templates/build-powertoys-steps.yml b/.pipelines/ci/templates/build-powertoys-steps.yml index 4102ac53c1..730c16e8fd 100644 --- a/.pipelines/ci/templates/build-powertoys-steps.yml +++ b/.pipelines/ci/templates/build-powertoys-steps.yml @@ -81,6 +81,7 @@ steps: configuration: '$(BuildConfiguration)' testSelector: 'testAssemblies' testAssemblyVer2: | + **\Microsoft.Plugin.Folder.UnitTest.dll **\Microsoft.Plugin.Program.UnitTests.dll **\Microsoft.Plugin.Calculator.UnitTest.dll **\Microsoft.Plugin.Uri.UnitTests.dll diff --git a/PowerToys.sln b/PowerToys.sln index e16a3fac32..ce3c636352 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -267,6 +267,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Setting EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Calculator.UnitTest", "src\modules\launcher\Plugins\Microsoft.Plugin.Calculator.UnitTest\Microsoft.Plugin.Calculator.UnitTest.csproj", "{632BBE62-5421-49EA-835A-7FFA4F499BD6}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Folder.UnitTests", "src\modules\launcher\Plugins\Microsoft.Plugin.Folder.UnitTests\Microsoft.Plugin.Folder.UnitTests.csproj", "{4FA206A5-F69F-4193-BF8F-F6EEB496734C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -537,6 +539,10 @@ Global {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Debug|x64.Build.0 = Debug|x64 {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Release|x64.ActiveCfg = Release|x64 {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Release|x64.Build.0 = Release|x64 + {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Debug|x64.ActiveCfg = Debug|x64 + {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Debug|x64.Build.0 = Debug|x64 + {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Release|x64.ActiveCfg = Release|x64 + {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -614,6 +620,7 @@ Global {B81FB7B6-D30E-428F-908A-41422EFC1172} = {4AFC9975-2456-4C70-94A4-84073C1CED93} {0F85E674-34AE-443D-954C-8321EB8B93B1} = {C3081D9A-1586-441A-B5F4-ED815B3719C1} {632BBE62-5421-49EA-835A-7FFA4F499BD6} = {4AFC9975-2456-4C70-94A4-84073C1CED93} + {4FA206A5-F69F-4193-BF8F-F6EEB496734C} = {4AFC9975-2456-4C70-94A4-84073C1CED93} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/DriveOrSharedFolderTests.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/DriveOrSharedFolderTests.cs new file mode 100644 index 0000000000..a7c8cf0028 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/DriveOrSharedFolderTests.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using Microsoft.Plugin.Folder.Sources; +using Microsoft.Plugin.Folder.Sources.Result; +using Moq; +using NUnit.Framework; + +namespace Microsoft.Plugin.Folder.UnitTests +{ + public class DriveOrSharedFolderTests + { + [TestCase(@"\\test-server\testdir", true)] + [TestCase(@"c:", true)] + [TestCase(@"c:\", true)] + [TestCase(@"C:\", true)] + [TestCase(@"d:\", true)] + [TestCase(@"z:\", false)] + [TestCase(@"nope.exe", false)] + [TestCase(@"win32\test.dll", false)] + [TestCase(@"a\b\c\d", false)] + [TestCase(@"D", false)] + [TestCase(@"ZZ:\test", false)] + public void IsDriveOrSharedFolder_WhenCalled(string search, bool expectedSuccess) + { + // Setup + var driveInformationMock = new Mock(); + + driveInformationMock.Setup(r => r.GetDriveNames()) + .Returns(() => new[] { "c:", "d:" }); + + var folderLinksMock = new Mock(); + var folderHelper = new FolderHelper(driveInformationMock.Object, folderLinksMock.Object); + + // Act + var isDriveOrSharedFolder = folderHelper.IsDriveOrSharedFolder(search); + + // Assert + Assert.AreEqual(expectedSuccess, isDriveOrSharedFolder); + } + + [TestCase('A', true)] + [TestCase('C', true)] + [TestCase('c', true)] + [TestCase('Z', true)] + [TestCase('z', true)] + [TestCase('ª', false)] + [TestCase('α', false)] + [TestCase('Ω', false)] + [TestCase('ɀ', false)] + public void ValidDriveLetter_WhenCalled(char letter, bool expectedSuccess) + { + // Setup + // Act + var isDriveOrSharedFolder = FolderHelper.ValidDriveLetter(letter); + + // Assert + Assert.AreEqual(expectedSuccess, isDriveOrSharedFolder); + } + + [TestCase("C:", true)] + [TestCase("C:\test", true)] + [TestCase("D:", false)] + [TestCase("INVALID", false)] + public void GenerateMaxFiles_WhenCalled(string search, bool hasValues) + { + // Setup + var folderHelperMock = new Mock(); + folderHelperMock.Setup(r => r.IsDriveOrSharedFolder(It.IsAny())) + .Returns(s => s.StartsWith("C:", StringComparison.CurrentCultureIgnoreCase)); + + var itemResultMock = new Mock(); + + var internalDirectoryMock = new Mock(); + internalDirectoryMock.Setup(r => r.Query(It.IsAny())) + .Returns(new List() { itemResultMock.Object }); + + var processor = new InternalDirectoryProcessor(folderHelperMock.Object, internalDirectoryMock.Object); + + // Act + var results = processor.Results(string.Empty, search); + + // Assert + if (hasValues) + { + CollectionAssert.IsNotEmpty(results); + } + else + { + CollectionAssert.IsEmpty(results); + } + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/InternalQueryFolderTests.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/InternalQueryFolderTests.cs new file mode 100644 index 0000000000..4fa9307d94 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/InternalQueryFolderTests.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Plugin.Folder.Sources; +using Microsoft.Plugin.Folder.Sources.Result; +using Moq; +using NUnit.Framework; + +namespace Microsoft.Plugin.Folder.UnitTests +{ + [TestFixture] + public class InternalQueryFolderTests + { + private static readonly HashSet DirectoryExist = new HashSet() + { + @"c:", + @"c:\", + @"c:\Test\", + @"c:\Test\A\", + @"c:\Test\b\", + }; + + private static readonly HashSet FilesExist = new HashSet() + { + @"c:\bla.txt", + @"c:\Test\test.txt", + @"c:\Test\more-test.png", + }; + + private static Mock _queryFileSystemInfoMock; + + [SetUp] + public void SetupMock() + { + var queryFileSystemInfoMock = new Mock(); + queryFileSystemInfoMock.Setup(r => r.Exists(It.IsAny())) + .Returns(path => ContainsDirectory(path)); + + queryFileSystemInfoMock.Setup(r => r.MatchFileSystemInfo(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(MatchFileSystemInfo); + + _queryFileSystemInfoMock = queryFileSystemInfoMock; + } + + // Windows supports C:\\\\\ => C:\ + private static bool ContainsDirectory(string path) + { + return DirectoryExist.Contains(TrimDirectoryEnd(path)); + } + + private static string TrimDirectoryEnd(string path) + { + var trimEnd = path.TrimEnd('\\'); + + if (path.EndsWith('\\')) + { + trimEnd += '\\'; + } + + return trimEnd; + } + + private static IEnumerable MatchFileSystemInfo(string search, string incompleteName, SearchOption searchOption) + { + Func folderSearchFunc; + Func fileSearchFunc; + switch (searchOption) + { + case SearchOption.TopDirectoryOnly: + folderSearchFunc = s => s.Equals(search, StringComparison.CurrentCultureIgnoreCase); + + var regexSearch = TrimDirectoryEnd(search); + + fileSearchFunc = s => Regex.IsMatch(s, $"^{Regex.Escape(regexSearch)}[^\\\\]*$"); + break; + case SearchOption.AllDirectories: + folderSearchFunc = s => s.StartsWith(search, StringComparison.CurrentCultureIgnoreCase); + fileSearchFunc = s => s.StartsWith(search, StringComparison.CurrentCultureIgnoreCase); + break; + default: + throw new ArgumentOutOfRangeException(nameof(searchOption), searchOption, null); + } + + var directories = DirectoryExist.Where(s => folderSearchFunc(s)) + .Select(dir => new DisplayFileInfo() + { + Type = DisplayType.Directory, + FullName = dir, + }); + + var files = FilesExist.Where(s => fileSearchFunc(s)) + .Select(file => new DisplayFileInfo() + { + Type = DisplayType.File, + FullName = file, + }); + + return directories.Concat(files); + } + + [Test] + public void Query_ThrowsException_WhenCalledNull() + { + // Setup + var queryInternalDirectory = new QueryInternalDirectory(new FolderSettings(), _queryFileSystemInfoMock.Object); + + // Act & Assert + Assert.Throws(() => queryInternalDirectory.Query(null).ToArray()); + } + + [TestCase(@"c", 0, 0, false, Reason = "String empty is nothing")] + [TestCase(@"c:", 1, 1, false, Reason = "Root without \\")] + [TestCase(@"c:\", 1, 1, false, Reason = "Normal root")] + [TestCase(@"c:\Test", 1, 2, false, Reason = "Select yourself")] + [TestCase(@"c:\>", 2, 2, true, Reason = "Max Folder test recursive")] + [TestCase(@"c:\Test>", 2, 2, true, Reason = "2 Folders recursive")] + [TestCase(@"c:\not-exist", 1, 1, false, Reason = "Folder not exist, return root")] + [TestCase(@"c:\not-exist>", 2, 2, true, Reason = "Folder not exist, return root recursive")] + [TestCase(@"c:\not-exist\not-exist2", 0, 0, false, Reason = "Folder not exist, return root")] + [TestCase(@"c:\not-exist\not-exist2>", 0, 0, false, Reason = "Folder not exist, return root recursive")] + [TestCase(@"c:\bla.t", 1, 1, false, Reason = "Partial match file")] + public void Query_WhenCalled(string search, int folders, int files, bool truncated) + { + const int maxFolderSetting = 2; + + // Setup + var folderSettings = new FolderSettings() + { + MaxFileResults = maxFolderSetting, + MaxFolderResults = maxFolderSetting, + }; + + var queryInternalDirectory = new QueryInternalDirectory(folderSettings, _queryFileSystemInfoMock.Object); + + // Act + var isDriveOrSharedFolder = queryInternalDirectory.Query(search) + .ToLookup(r => r.GetType()); + + // Assert + Assert.AreEqual(files, isDriveOrSharedFolder[typeof(FileItemResult)].Count(), "File count doesn't match"); + Assert.AreEqual(folders, isDriveOrSharedFolder[typeof(FolderItemResult)].Count(), "folder count doesn't match"); + + // Always check if there is less than max folders + Assert.LessOrEqual(isDriveOrSharedFolder[typeof(FileItemResult)].Count(), maxFolderSetting, "Files are not limited"); + Assert.LessOrEqual(isDriveOrSharedFolder[typeof(FolderItemResult)].Count(), maxFolderSetting, "Folders are not limited"); + + // Checks if CreateOpenCurrentFolder is displayed + Assert.AreEqual(Math.Min(folders + files, 1), isDriveOrSharedFolder[typeof(CreateOpenCurrentFolderResult)].Count(), "CreateOpenCurrentFolder displaying is incorrect"); + + Assert.AreEqual(truncated, isDriveOrSharedFolder[typeof(TruncatedItemResult)].Count() == 1, "CreateOpenCurrentFolder displaying is incorrect"); + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/Microsoft.Plugin.Folder.UnitTests.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/Microsoft.Plugin.Folder.UnitTests.csproj new file mode 100644 index 0000000000..81f699b12e --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/Microsoft.Plugin.Folder.UnitTests.csproj @@ -0,0 +1,39 @@ + + + + netcoreapp3.1 + + false + x64 + Microsoft.Plugin.Folder.UnitTests + true + + + + + + + + + + + + + + + + + GlobalSuppressions.cs + + + StyleCop.json + + + + + 1.1.118 + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/IFolderProcessor.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/IFolderProcessor.cs new file mode 100644 index 0000000000..99260f9695 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/IFolderProcessor.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.Plugin.Folder.Sources; +using Microsoft.Plugin.Folder.Sources.Result; +using Wox.Plugin; + +namespace Microsoft.Plugin.Folder +{ + internal interface IFolderProcessor + { + IEnumerable Results(string actionKeyword, string search); + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/InternalDirectoryProcessor.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/InternalDirectoryProcessor.cs new file mode 100644 index 0000000000..6fdc0c8ce8 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/InternalDirectoryProcessor.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Plugin.Folder.Sources; +using Microsoft.Plugin.Folder.Sources.Result; +using Wox.Plugin; + +namespace Microsoft.Plugin.Folder +{ + public class InternalDirectoryProcessor : IFolderProcessor + { + private readonly IFolderHelper _folderHelper; + private readonly IQueryInternalDirectory _internalDirectory; + + public InternalDirectoryProcessor(IFolderHelper folderHelper, IQueryInternalDirectory internalDirectory) + { + _folderHelper = folderHelper; + _internalDirectory = internalDirectory; + } + + public IEnumerable Results(string actionKeyword, string search) + { + if (!_folderHelper.IsDriveOrSharedFolder(search)) + { + return Enumerable.Empty(); + } + + return _internalDirectory.Query(search); + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Main.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Main.cs index 977d2356d6..3d59271c3f 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Main.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Main.cs @@ -4,20 +4,10 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; using System.Linq; -using System.Reflection; -using System.Text.RegularExpressions; -using System.Windows; using System.Windows.Controls; -using System.Windows.Media; +using Microsoft.Plugin.Folder.Sources; using Microsoft.PowerToys.Settings.UI.Lib; -using Microsoft.VisualBasic; -using Newtonsoft.Json; -using Wox.Infrastructure; -using Wox.Infrastructure.Logger; using Wox.Infrastructure.Storage; using Wox.Plugin; @@ -30,13 +20,19 @@ namespace Microsoft.Plugin.Folder public const string DeleteFileFolderImagePath = "Images\\delete.dark.png"; public const string CopyImagePath = "Images\\copy.dark.png"; - private const string _fileExplorerProgramName = "explorer"; private static readonly PluginJsonStorage _storage = new PluginJsonStorage(); private static readonly FolderSettings _settings = _storage.Load(); - private static List _driverNames; + private static readonly IQueryInternalDirectory _internalDirectory = new QueryInternalDirectory(_settings, new QueryFileSystemInfo()); + private static readonly FolderHelper _folderHelper = new FolderHelper(new DriveInformation(), new FolderLinksSettings(_settings)); + + private static readonly ICollection _processors = new IFolderProcessor[] + { + new UserFolderProcessor(_folderHelper), + new InternalDirectoryProcessor(_folderHelper, _internalDirectory), + }; + private static PluginInitContext _context; private IContextMenu _contextMenuLoader; - private static string warningIconPath; private bool _disposed; public void Save() @@ -49,35 +45,6 @@ namespace Microsoft.Plugin.Folder throw new NotImplementedException(); } - public void Init(PluginInitContext context) - { - _context = context ?? throw new ArgumentNullException(nameof(context)); - _contextMenuLoader = new ContextMenuLoader(context); - InitialDriverList(); - - _context.API.ThemeChanged += OnThemeChanged; - UpdateIconPath(_context.API.GetCurrentTheme()); - } - - private static void UpdateIconPath(Theme theme) - { - if (theme == Theme.Light || theme == Theme.HighContrastWhite) - { - warningIconPath = "Images/Warning.light.png"; - } - else - { - warningIconPath = "Images/Warning.dark.png"; - } - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1313:Parameter names should begin with lower-case letter", Justification = "The parameter is unused")] - private void OnThemeChanged(Theme _, Theme newTheme) - { - UpdateIconPath(newTheme); - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Do not want to change the behavior of the application, but want to enforce static analysis")] public List Query(Query query) { if (query == null) @@ -85,283 +52,55 @@ namespace Microsoft.Plugin.Folder throw new ArgumentNullException(paramName: nameof(query)); } - var results = GetFolderPluginResults(query); + var expandedName = FolderHelper.Expand(query.Search); - // todo why was this hack here? - foreach (var result in results) - { - result.Score += 10; - } - - return results; + return _processors.SelectMany(processor => processor.Results(query.ActionKeyword, expandedName)) + .Select(res => res.Create(_context.API)) + .Select(AddScore) + .ToList(); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Do not want to change the behavior of the application, but want to enforce static analysis")] - public static List GetFolderPluginResults(Query query) + public void Init(PluginInitContext context) { - var results = GetUserFolderResults(query); - string search = query.Search.ToLower(CultureInfo.InvariantCulture); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _contextMenuLoader = new ContextMenuLoader(context); - if (!IsDriveOrSharedFolder(search)) - { - return results; - } - - results.AddRange(QueryInternalDirectoryExists(query)); - return results; + _context.API.ThemeChanged += OnThemeChanged; + UpdateIconPath(_context.API.GetCurrentTheme()); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "We want to keep the process alive and instead inform the user of the error")] - private static bool OpenFileOrFolder(string program, string path) - { - try - { - Process.Start(program, path); - } - catch (Exception e) - { - string messageBoxTitle = string.Format(CultureInfo.InvariantCulture, "{0} {1}", Properties.Resources.wox_plugin_folder_select_folder_OpenFileOrFolder_error_message, path); - Log.Exception($"Failed to open {path} in explorer, {e.Message}", e, MethodBase.GetCurrentMethod().DeclaringType); - _context.API.ShowMsg(messageBoxTitle, e.Message); - } - - return true; - } - - private static bool IsDriveOrSharedFolder(string search) - { - if (search == null) - { - throw new ArgumentNullException(nameof(search)); - } - - if (search.StartsWith(@"\\", StringComparison.InvariantCulture)) - { // share folder - return true; - } - - if (_driverNames != null && _driverNames.Any(search.StartsWith)) - { // normal drive letter - return true; - } - - if (_driverNames == null && search.Length > 2 && char.IsLetter(search[0]) && search[1] == ':') - { // when we don't have the drive letters we can try... - return true; // we don't know so let's give it the possibility - } - - return false; - } - - private static Result CreateFolderResult(string title, string subtitle, string path, Query query) - { - return new Result - { - Title = title, - IcoPath = path, - SubTitle = string.Format(CultureInfo.InvariantCulture, "{0}: {1}", Properties.Resources.wox_plugin_folder_plugin_name, subtitle), - QueryTextDisplay = path, - TitleHighlightData = StringMatcher.FuzzySearch(query.Search, title).MatchData, - ContextData = new SearchResult { Type = ResultType.Folder, FullPath = path }, - Action = c => - { - return OpenFileOrFolder(_fileExplorerProgramName, path); - }, - }; - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Do not want to change the behavior of the application, but want to enforce static analysis")] - private static List GetUserFolderResults(Query query) + public static IEnumerable GetFolderPluginResults(Query query) { if (query == null) { throw new ArgumentNullException(paramName: nameof(query)); } - string search = query.Search.ToLower(CultureInfo.InvariantCulture); - var userFolderLinks = _settings.FolderLinks.Where( - x => x.Nickname.StartsWith(search, StringComparison.OrdinalIgnoreCase)); - var results = userFolderLinks.Select(item => - CreateFolderResult(item.Nickname, item.Path, item.Path, query)).ToList(); - return results; + var expandedName = FolderHelper.Expand(query.Search); + + return _processors.SelectMany(processor => processor.Results(query.ActionKeyword, expandedName)) + .Select(res => res.Create(_context.API)) + .Select(AddScore); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Do not want to change the behavior of the application, but want to enforce static analysis")] - private static void InitialDriverList() + private static void UpdateIconPath(Theme theme) { - if (_driverNames == null) - { - _driverNames = new List(); - var allDrives = DriveInfo.GetDrives(); - foreach (DriveInfo driver in allDrives) - { - _driverNames.Add(driver.Name.ToLower(CultureInfo.InvariantCulture).TrimEnd('\\')); - } - } + QueryInternalDirectory.SetWarningIcon(theme); } - private static readonly char[] _specialSearchChars = new char[] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1313:Parameter names should begin with lower-case letter", Justification = "The parameter is unused")] + private static void OnThemeChanged(Theme _, Theme newTheme) { - '?', '*', '>', - }; - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Do not want to change the behavior of the application, but want to enforce static analysis")] - private static List QueryInternalDirectoryExists(Query query) - { - var search = query.Search; - var results = new List(); - var hasSpecial = search.IndexOfAny(_specialSearchChars) >= 0; - string incompleteName = string.Empty; - if (hasSpecial || !Directory.Exists(search + "\\")) - { - // if folder doesn't exist, we want to take the last part and use it afterwards to help the user - // find the right folder. - int index = search.LastIndexOf('\\'); - if (index > 0 && index < (search.Length - 1)) - { - incompleteName = search.Substring(index + 1).ToLower(CultureInfo.InvariantCulture); - search = search.Substring(0, index + 1); - if (!Directory.Exists(search)) - { - return results; - } - } - else - { - return results; - } - } - else - { - // folder exist, add \ at the end of doesn't exist - if (!search.EndsWith("\\", StringComparison.InvariantCulture)) - { - search += "\\"; - } - } - - results.Add(CreateOpenCurrentFolderResult(search)); - - var searchOption = SearchOption.TopDirectoryOnly; - incompleteName += "*"; - - // give the ability to search all folder when starting with > - if (incompleteName.StartsWith(">", StringComparison.InvariantCulture)) - { - searchOption = SearchOption.AllDirectories; - - // match everything before and after search term using supported wildcard '*', ie. *searchterm* - incompleteName = "*" + incompleteName.Substring(1); - } - - var folderList = new List(); - var fileList = new List(); - - try - { - // search folder and add results - var directoryInfo = new DirectoryInfo(search); - var fileSystemInfos = directoryInfo.GetFileSystemInfos(incompleteName, searchOption); - - foreach (var fileSystemInfo in fileSystemInfos) - { - if ((fileSystemInfo.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) - { - continue; - } - - if (fileSystemInfo is DirectoryInfo) - { - var folderSubtitleString = fileSystemInfo.FullName; - - folderList.Add(CreateFolderResult(fileSystemInfo.Name, folderSubtitleString, fileSystemInfo.FullName, query)); - } - else - { - fileList.Add(CreateFileResult(fileSystemInfo.FullName, query)); - } - } - } - catch (Exception e) - { - if (e is UnauthorizedAccessException || e is ArgumentException) - { - results.Add(new Result { Title = e.Message, Score = 501 }); - - return results; - } - - throw; - } - - results = results.Concat(folderList.OrderBy(x => x.Title).Take(_settings.MaxFolderResults)).Concat(fileList.OrderBy(x => x.Title).Take(_settings.MaxFileResults)).ToList(); - - // Show warning message if result has been truncated - if (folderList.Count > _settings.MaxFolderResults || fileList.Count > _settings.MaxFileResults) - { - var preTruncationCount = folderList.Count + fileList.Count; - var postTruncationCount = Math.Min(folderList.Count, _settings.MaxFolderResults) + Math.Min(fileList.Count, _settings.MaxFileResults); - results.Add(CreateTruncatedItemsResult(search, preTruncationCount, postTruncationCount)); - } - - return results.ToList(); + UpdateIconPath(newTheme); } - private static Result CreateTruncatedItemsResult(string search, int preTruncationCount, int postTruncationCount) + // todo why was this hack here? + private static Result AddScore(Result result) { - return new Result - { - Title = Properties.Resources.Microsoft_plugin_folder_truncation_warning_title, - QueryTextDisplay = search, - SubTitle = string.Format(CultureInfo.InvariantCulture, Properties.Resources.Microsoft_plugin_folder_truncation_warning_subtitle, postTruncationCount, preTruncationCount), - IcoPath = warningIconPath, - }; - } - - private static Result CreateFileResult(string filePath, Query query) - { - var result = new Result - { - Title = Path.GetFileName(filePath), - SubTitle = string.Format(CultureInfo.InvariantCulture, "{0}: {1}", Properties.Resources.wox_plugin_folder_plugin_name, filePath), - IcoPath = filePath, - ContextData = new SearchResult { Type = ResultType.File, FullPath = filePath }, - TitleHighlightData = StringMatcher.FuzzySearch(query.Search, Path.GetFileName(filePath)).MatchData, - Action = c => - { - return OpenFileOrFolder(_fileExplorerProgramName, filePath); - }, - }; + result.Score += 10; return result; } - private static Result CreateOpenCurrentFolderResult(string search) - { - var firstResult = string.Format(CultureInfo.InvariantCulture, "{0} {1}", Properties.Resources.wox_plugin_folder_select_folder_first_result_title, search); - var folderName = search.TrimEnd('\\').Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.None).Last(); - var sanitizedPath = Regex.Replace(search, @"[\/\\]+", "\\"); - - // A network path must start with \\ - if (sanitizedPath.StartsWith("\\", StringComparison.InvariantCulture)) - { - sanitizedPath = sanitizedPath.Insert(0, "\\"); - } - - return new Result - { - Title = firstResult, - QueryTextDisplay = search, - SubTitle = string.Format(CultureInfo.InvariantCulture, "{0}: {1}", Properties.Resources.wox_plugin_folder_plugin_name, Properties.Resources.wox_plugin_folder_select_folder_first_result_subtitle), - IcoPath = search, - Score = 500, - ContextData = new SearchResult { Type = ResultType.Folder, FullPath = search }, - Action = c => - { - return OpenFileOrFolder(_fileExplorerProgramName, search); - }, - }; - } - public string GetTranslatedPluginTitle() { return Properties.Resources.wox_plugin_folder_plugin_name; diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/ExplorerAction.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/ExplorerAction.cs new file mode 100644 index 0000000000..69c2562eab --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/ExplorerAction.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics; +using System.Globalization; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Windows; +using Wox.Infrastructure.Logger; +using Wox.Plugin; + +namespace Microsoft.Plugin.Folder.Sources +{ + public class ExplorerAction : IExplorerAction + { + private const string FileExplorerProgramName = "explorer"; + + public bool Execute(string path, IPublicAPI contextApi) + { + if (contextApi == null) + { + throw new ArgumentNullException(nameof(contextApi)); + } + + return OpenFileOrFolder(FileExplorerProgramName, path, contextApi); + } + + public bool ExecuteSanitized(string search, IPublicAPI contextApi) + { + if (contextApi == null) + { + throw new ArgumentNullException(nameof(contextApi)); + } + + return Execute(SanitizedPath(search), contextApi); + } + + private static string SanitizedPath(string search) + { + var sanitizedPath = Regex.Replace(search, @"[\/\\]+", "\\"); + + // A network path must start with \\ + if (!sanitizedPath.StartsWith("\\", StringComparison.InvariantCulture)) + { + return sanitizedPath; + } + + return sanitizedPath.Insert(0, "\\"); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "We want to keep the process alive and instead inform the user of the error")] + private static bool OpenFileOrFolder(string program, string path, IPublicAPI contextApi) + { + try + { + Process.Start(program, path); + } + catch (Exception e) + { + string messageBoxTitle = string.Format(CultureInfo.InvariantCulture, "{0} {1}", Properties.Resources.wox_plugin_folder_select_folder_OpenFileOrFolder_error_message, path); + Log.Exception($"Failed to open {path} in {FileExplorerProgramName}, {e.Message}", e, MethodBase.GetCurrentMethod().DeclaringType); + contextApi.ShowMsg(messageBoxTitle, e.Message); + } + + return true; + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/FolderLinksSettings.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/FolderLinksSettings.cs new file mode 100644 index 0000000000..342143dab0 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/FolderLinksSettings.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Wox.Infrastructure.Storage; + +namespace Microsoft.Plugin.Folder.Sources +{ + internal class FolderLinksSettings : IFolderLinks + { + private readonly FolderSettings _settings; + + public FolderLinksSettings(FolderSettings settings) + { + _settings = settings; + } + + public IEnumerable FolderLinks() + { + return _settings.FolderLinks; + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IExplorerAction.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IExplorerAction.cs new file mode 100644 index 0000000000..5617256d75 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IExplorerAction.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Wox.Plugin; + +namespace Microsoft.Plugin.Folder.Sources +{ + public interface IExplorerAction + { + bool Execute(string sanitizedPath, IPublicAPI contextApi); + + bool ExecuteSanitized(string search, IPublicAPI contextApi); + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IFolderLinks.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IFolderLinks.cs new file mode 100644 index 0000000000..4c3dc31ced --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IFolderLinks.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace Microsoft.Plugin.Folder.Sources +{ + public interface IFolderLinks + { + IEnumerable FolderLinks(); + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IQueryFileSystemInfo.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IQueryFileSystemInfo.cs new file mode 100644 index 0000000000..45db2cafd9 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IQueryFileSystemInfo.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.IO; +using Wox.Infrastructure.FileSystemHelper; + +namespace Microsoft.Plugin.Folder.Sources +{ + public interface IQueryFileSystemInfo : IDirectoryWrapper + { + IEnumerable MatchFileSystemInfo(string search, string incompleteName, SearchOption searchOption); + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IQueryInternalDirectory.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IQueryInternalDirectory.cs new file mode 100644 index 0000000000..39e3f1a8f9 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/IQueryInternalDirectory.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.Plugin.Folder.Sources.Result; + +namespace Microsoft.Plugin.Folder.Sources +{ + public interface IQueryInternalDirectory + { + IEnumerable Query(string search); + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/DisplayFileInfo.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/DisplayFileInfo.cs new file mode 100644 index 0000000000..755b277ee8 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/DisplayFileInfo.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.Plugin.Folder.Sources +{ + public struct DisplayFileInfo : IEquatable + { + public string Name { get; set; } + + public string FullName { get; set; } + + public DisplayType Type { get; set; } + + public override bool Equals(object obj) + { + return obj is DisplayFileInfo other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Name, FullName, (int)Type); + } + + public bool Equals(DisplayFileInfo other) + { + return Name == other.Name && FullName == other.FullName && Type == other.Type; + } + + public static bool operator ==(DisplayFileInfo a, DisplayFileInfo b) + { + return a.Equals(b); + } + + public static bool operator !=(DisplayFileInfo a, DisplayFileInfo b) + { + return !(a == b); + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/DisplayType.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/DisplayType.cs new file mode 100644 index 0000000000..fc949f6ca5 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/DisplayType.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Plugin.Folder.Sources +{ + public enum DisplayType + { + Directory, + File, + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/DriveInformation.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/DriveInformation.cs new file mode 100644 index 0000000000..5c0f534f11 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/DriveInformation.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; + +namespace Microsoft.Plugin.Folder.Sources +{ + internal class DriveInformation : IDriveInformation + { + private static readonly List DriverNames = InitialDriverList().ToList(); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Do not want to change the behavior of the application, but want to enforce static analysis")] + private static IEnumerable InitialDriverList() + { + var directorySeparatorChar = System.IO.Path.DirectorySeparatorChar; + return DriveInfo.GetDrives() + .Select(driver => driver.Name.ToLower(CultureInfo.InvariantCulture).TrimEnd(directorySeparatorChar)); + } + + public IEnumerable GetDriveNames() => DriverNames; + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/FolderHelper.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/FolderHelper.cs new file mode 100644 index 0000000000..02bb17505c --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/FolderHelper.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Microsoft.Plugin.Folder.Sources +{ + public class FolderHelper : IFolderHelper + { + private readonly IDriveInformation _driveInformation; + private readonly IFolderLinks _folderLinks; + + public FolderHelper(IDriveInformation driveInformation, IFolderLinks folderLinks) + { + _driveInformation = driveInformation; + _folderLinks = folderLinks; + } + + public IEnumerable GetUserFolderResults(string query) + { + if (query == null) + { + throw new ArgumentNullException(paramName: nameof(query)); + } + + return _folderLinks.FolderLinks() + .Where(x => x.Nickname.StartsWith(query, StringComparison.OrdinalIgnoreCase)); + } + + public bool IsDriveOrSharedFolder(string search) + { + if (search == null) + { + throw new ArgumentNullException(nameof(search)); + } + + if (search.StartsWith(@"\\", StringComparison.InvariantCulture)) + { // share folder + return true; + } + + var driverNames = _driveInformation.GetDriveNames() + .ToImmutableArray(); + + if (driverNames.Any()) + { + if (driverNames.Any(dn => search.StartsWith(dn, StringComparison.InvariantCultureIgnoreCase))) + { + // normal drive letter + return true; + } + } + else + { + if (search.Length > 2 && ValidDriveLetter(search[0]) && search[1] == ':') + { // when we don't have the drive letters we can try... + return true; // we don't know so let's give it the possibility + } + } + + return false; + } + + /// + /// This check is needed because char.IsLetter accepts more than [A-z] + /// + public static bool ValidDriveLetter(char c) + { + return c <= 122 && char.IsLetter(c); + } + + public static string Expand(string search) + { + return Environment.ExpandEnvironmentVariables(search); + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/IDriveInformation.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/IDriveInformation.cs new file mode 100644 index 0000000000..a41dd3a6fe --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/IDriveInformation.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. +using System.Collections.Generic; + +namespace Microsoft.Plugin.Folder.Sources +{ + public interface IDriveInformation + { + IEnumerable GetDriveNames(); + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/IFolderHelper.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/IFolderHelper.cs new file mode 100644 index 0000000000..f127fe9b32 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Path/IFolderHelper.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Plugin.Folder.Sources +{ + public interface IFolderHelper + { + bool IsDriveOrSharedFolder(string search); + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/QueryFileSystemInfo.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/QueryFileSystemInfo.cs new file mode 100644 index 0000000000..72c81e3c54 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/QueryFileSystemInfo.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Wox.Infrastructure.FileSystemHelper; + +namespace Microsoft.Plugin.Folder.Sources +{ + public class QueryFileSystemInfo : DirectoryWrapper, IQueryFileSystemInfo + { + public IEnumerable MatchFileSystemInfo(string search, string incompleteName, SearchOption searchOption) + { + // search folder and add results + var directoryInfo = new DirectoryInfo(search); + var fileSystemInfos = directoryInfo.EnumerateFileSystemInfos(incompleteName, searchOption); + + return SafeEnumerateFileSystemInfos(fileSystemInfos) + .Where(fileSystemInfo => (fileSystemInfo.Attributes & FileAttributes.Hidden) != FileAttributes.Hidden) + .Select(CreateDisplayFileInfo); + } + + private static IEnumerable SafeEnumerateFileSystemInfos(IEnumerable fileSystemInfos) + { + using (var enumerator = fileSystemInfos.GetEnumerator()) + { + while (true) + { + FileSystemInfo currentFileSystemInfo; + try + { + if (!enumerator.MoveNext()) + { + break; + } + + currentFileSystemInfo = enumerator.Current; + } + catch (UnauthorizedAccessException) + { + continue; + } + + yield return currentFileSystemInfo; + } + } + } + + private static DisplayFileInfo CreateDisplayFileInfo(FileSystemInfo fileSystemInfo) + { + return new DisplayFileInfo() + { + Name = fileSystemInfo.Name, + FullName = fileSystemInfo.FullName, + Type = GetDisplayType(fileSystemInfo), + }; + } + + private static DisplayType GetDisplayType(FileSystemInfo fileSystemInfo) + { + if (fileSystemInfo is DirectoryInfo) + { + return DisplayType.Directory; + } + else + { + return DisplayType.File; + } + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/QueryInternalDirectory.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/QueryInternalDirectory.cs new file mode 100644 index 0000000000..2272927120 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/QueryInternalDirectory.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Linq; +using Microsoft.Plugin.Folder.Sources.Result; +using Wox.Plugin; + +namespace Microsoft.Plugin.Folder.Sources +{ + public class QueryInternalDirectory : IQueryInternalDirectory + { + private readonly FolderSettings _settings; + private readonly IQueryFileSystemInfo _queryFileSystemInfo; + + private static readonly HashSet SpecialSearchChars = new HashSet + { + '?', '*', '>', + }; + + private static string _warningIconPath; + + public QueryInternalDirectory(FolderSettings folderSettings, IQueryFileSystemInfo queryFileSystemInfo) + { + _settings = folderSettings; + _queryFileSystemInfo = queryFileSystemInfo; + } + + private static bool HasSpecialChars(string search) + { + return search.Any(c => SpecialSearchChars.Contains(c)); + } + + public static SearchOption GetSearchOptions(string query) + { + // give the ability to search all folder when it contains a > + if (query.Any(c => c.Equals('>'))) + { + return SearchOption.AllDirectories; + } + + return SearchOption.TopDirectoryOnly; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Do not want to change the behavior of the application, but want to enforce static analysis")] + private (string search, string incompleteName) Process(string search) + { + string incompleteName = string.Empty; + if (HasSpecialChars(search) || !_queryFileSystemInfo.Exists($@"{search}\")) + { + // if folder doesn't exist, we want to take the last part and use it afterwards to help the user + // find the right folder. + int index = search.LastIndexOf('\\'); + + // No slashes found, so probably not a folder + if (index <= 0 || index >= search.Length - 1) + { + return default; + } + + // Remove everything after the last \ and add * + incompleteName = search.Substring(index + 1) + .ToLower(CultureInfo.InvariantCulture) + "*"; + search = search.Substring(0, index + 1); + if (!_queryFileSystemInfo.Exists(search)) + { + return default; + } + } + else + { + // folder exist, add \ at the end of doesn't exist + if (!search.EndsWith(@"\", StringComparison.InvariantCulture)) + { + search += @"\"; + } + } + + return (search, incompleteName); + } + + public IEnumerable Query(string querySearch) + { + if (querySearch == null) + { + throw new ArgumentNullException(nameof(querySearch)); + } + + var processed = Process(querySearch); + + if (processed == default) + { + yield break; + } + + var (search, incompleteName) = processed; + var searchOption = GetSearchOptions(incompleteName); + + if (searchOption == SearchOption.AllDirectories) + { + // match everything before and after search term using supported wildcard '*', ie. *searchterm* + if (string.IsNullOrEmpty(incompleteName)) + { + incompleteName = "*"; + } + else + { + incompleteName = "*" + incompleteName.Substring(1); + } + } + + yield return new CreateOpenCurrentFolderResult(search); + + // Note: Take 1000 is so that you don't search the whole system before you discard + var lookup = _queryFileSystemInfo.MatchFileSystemInfo(search, incompleteName, searchOption) + .Take(1000) + .ToLookup(r => r.Type); + + var folderList = lookup[DisplayType.Directory].ToImmutableArray(); + var fileList = lookup[DisplayType.File].ToImmutableArray(); + + var fileSystemResult = GenerateFolderResults(search, folderList) + .Concat(GenerateFileResults(search, fileList)) + .ToImmutableArray(); + + foreach (var result in fileSystemResult) + { + yield return result; + } + + // Show warning message if result has been truncated + if (folderList.Length > _settings.MaxFolderResults || fileList.Length > _settings.MaxFileResults) + { + yield return GenerateTruncatedItemResult(folderList.Length + fileList.Length, fileSystemResult.Length); + } + } + + private IEnumerable GenerateFileResults(string search, IEnumerable fileList) + { + return fileList + .Select(fileSystemInfo => new FileItemResult() + { + FilePath = fileSystemInfo.FullName, + Search = search, + }) + .OrderBy(x => x.Title) + .Take(_settings.MaxFileResults); + } + + private IEnumerable GenerateFolderResults(string search, IEnumerable folderList) + { + return folderList + .Select(fileSystemInfo => new FolderItemResult(fileSystemInfo) + { + Search = search, + }) + .OrderBy(x => x.Title) + .Take(_settings.MaxFolderResults); + } + + private static TruncatedItemResult GenerateTruncatedItemResult(int preTruncationCount, int postTruncationCount) + { + return new TruncatedItemResult() + { + PreTruncationCount = preTruncationCount, + PostTruncationCount = postTruncationCount, + WarningIconPath = _warningIconPath, + }; + } + + public static void SetWarningIcon(Theme theme) + { + if (theme == Theme.Light || theme == Theme.HighContrastWhite) + { + _warningIconPath = "Images/Warning.light.png"; + } + else + { + _warningIconPath = "Images/Warning.dark.png"; + } + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/CreateOpenCurrentFolderResult.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/CreateOpenCurrentFolderResult.cs new file mode 100644 index 0000000000..9fc916537d --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/CreateOpenCurrentFolderResult.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Wox.Plugin; + +namespace Microsoft.Plugin.Folder.Sources.Result +{ + public class CreateOpenCurrentFolderResult : IItemResult + { + private readonly IExplorerAction _explorerAction; + + public string Search { get; set; } + + public CreateOpenCurrentFolderResult(string search) + : this(search, new ExplorerAction()) + { + } + + public CreateOpenCurrentFolderResult(string search, IExplorerAction explorerAction) + { + Search = search; + _explorerAction = explorerAction; + } + + public Wox.Plugin.Result Create(IPublicAPI contextApi) + { + return new Wox.Plugin.Result + { + Title = $"Open {Search}", + QueryTextDisplay = Search, + SubTitle = $"Folder: Use > to search within the directory. Use * to search for file extensions. Or use both >*.", + IcoPath = Search, + Score = 500, + Action = c => _explorerAction.ExecuteSanitized(Search, contextApi), + }; + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/FileItemResult.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/FileItemResult.cs new file mode 100644 index 0000000000..784358d3d1 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/FileItemResult.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.IO; +using Wox.Infrastructure; +using Wox.Plugin; + +namespace Microsoft.Plugin.Folder.Sources.Result +{ + public class FileItemResult : IItemResult + { + private static readonly IExplorerAction ExplorerAction = new ExplorerAction(); + + public string FilePath { get; set; } + + public string Title => Path.GetFileName(FilePath); + + public string Search { get; set; } + + public Wox.Plugin.Result Create(IPublicAPI contextApi) + { + var result = new Wox.Plugin.Result + { + Title = Title, + SubTitle = "Folder: " + FilePath, + IcoPath = FilePath, + TitleHighlightData = StringMatcher.FuzzySearch(Search, Path.GetFileName(FilePath)).MatchData, + Action = c => ExplorerAction.Execute(FilePath, contextApi), + ContextData = new SearchResult { Type = ResultType.File, FullPath = FilePath }, + }; + return result; + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/FileSystemResult.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/FileSystemResult.cs new file mode 100644 index 0000000000..f7516da7e7 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/FileSystemResult.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace Microsoft.Plugin.Folder.Sources.Result +{ + public class FileSystemResult : List + { + public FileSystemResult() + { + } + + public FileSystemResult([NotNull] IEnumerable collection) + : base(collection) + { + } + + public FileSystemResult(int capacity) + : base(capacity) + { + } + + public static FileSystemResult Error(Exception exception) + { + return new FileSystemResult { Exception = exception }; + } + + public Exception Exception { get; private set; } + + public bool HasException() => Exception != null; + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/FolderItemResult.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/FolderItemResult.cs new file mode 100644 index 0000000000..d42da8cc3f --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/FolderItemResult.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Wox.Infrastructure; +using Wox.Plugin; + +namespace Microsoft.Plugin.Folder.Sources.Result +{ + public class FolderItemResult : IItemResult + { + private static readonly IExplorerAction ExplorerAction = new ExplorerAction(); + + public FolderItemResult() + { + } + + public FolderItemResult(DisplayFileInfo fileSystemInfo) + { + Title = fileSystemInfo.Name; + Subtitle = fileSystemInfo.FullName; + Path = fileSystemInfo.FullName; + } + + public string Title { get; set; } + + public string Subtitle { get; set; } + + public string Path { get; set; } + + public string Search { get; set; } + + public Wox.Plugin.Result Create(IPublicAPI contextApi) + { + return new Wox.Plugin.Result + { + Title = Title, + IcoPath = Path, + SubTitle = "Folder: " + Subtitle, + QueryTextDisplay = Path, + TitleHighlightData = StringMatcher.FuzzySearch(Search, Title).MatchData, + ContextData = new SearchResult { Type = ResultType.Folder, FullPath = Path }, + Action = c => ExplorerAction.Execute(Path, contextApi), + }; + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/IItemResult.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/IItemResult.cs new file mode 100644 index 0000000000..e9d8f7012e --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/IItemResult.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Wox.Plugin; + +namespace Microsoft.Plugin.Folder.Sources.Result +{ + public interface IItemResult + { + string Search { get; set; } + + Wox.Plugin.Result Create(IPublicAPI contextApi); + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/TruncatedItemResult.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/TruncatedItemResult.cs new file mode 100644 index 0000000000..c09a68dc29 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/Result/TruncatedItemResult.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; +using Wox.Plugin; + +namespace Microsoft.Plugin.Folder.Sources.Result +{ + public class TruncatedItemResult : IItemResult + { + public int PreTruncationCount { get; set; } + + public int PostTruncationCount { get; set; } + + public string WarningIconPath { get; set; } + + public string Search { get; set; } + + public Wox.Plugin.Result Create(IPublicAPI contextApi) + { + return new Wox.Plugin.Result + { + Title = Properties.Resources.Microsoft_plugin_folder_truncation_warning_title, + QueryTextDisplay = Search, + SubTitle = string.Format(CultureInfo.InvariantCulture, Properties.Resources.Microsoft_plugin_folder_truncation_warning_subtitle, PostTruncationCount, PreTruncationCount), + IcoPath = WarningIconPath, + }; + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/UserFolderProcessor.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/UserFolderProcessor.cs new file mode 100644 index 0000000000..c8c21d3081 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/UserFolderProcessor.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Plugin.Folder.Sources; +using Microsoft.Plugin.Folder.Sources.Result; + +namespace Microsoft.Plugin.Folder +{ + internal class UserFolderProcessor : IFolderProcessor + { + private readonly FolderHelper _folderHelper; + + public UserFolderProcessor(FolderHelper folderHelper) + { + _folderHelper = folderHelper; + } + + public IEnumerable Results(string actionKeyword, string search) + { + return _folderHelper.GetUserFolderResults(search) + .Select(item => CreateFolderResult(item.Nickname, item.Path, item.Path, search)); + } + + private static IItemResult CreateFolderResult(string title, string subtitle, string path, string search) + { + return new UserFolderResult + { + Search = search, + Title = title, + Subtitle = subtitle, + Path = path, + }; + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/UserFolderResult.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/UserFolderResult.cs new file mode 100644 index 0000000000..47ae19f545 --- /dev/null +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/UserFolderResult.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Plugin.Folder.Sources; +using Microsoft.Plugin.Folder.Sources.Result; +using Wox.Infrastructure; +using Wox.Plugin; + +namespace Microsoft.Plugin.Folder +{ + public class UserFolderResult : IItemResult + { + private readonly IExplorerAction _explorerAction = new ExplorerAction(); + + public string Search { get; set; } + + public string Title { get; set; } + + public string Path { get; set; } + + public string Subtitle { get; set; } + + public Result Create(IPublicAPI contextApi) + { + return new Result + { + Title = Title, + IcoPath = Path, + SubTitle = $"Folder: {Subtitle}", + QueryTextDisplay = Path, + TitleHighlightData = StringMatcher.FuzzySearch(Search, Title).MatchData, + ContextData = new SearchResult { Type = ResultType.Folder, FullPath = Path }, + Action = c => _explorerAction.Execute(Path, contextApi), + }; + } + } +} diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Main.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Main.cs index a698ffbcc7..eb90389ffa 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Main.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Main.cs @@ -63,7 +63,7 @@ namespace Microsoft.Plugin.Shell try { - List folderPluginResults = Folder.Main.GetFolderPluginResults(query); + IEnumerable folderPluginResults = Folder.Main.GetFolderPluginResults(query); results.AddRange(folderPluginResults); } catch (Exception e)