Merge pull request #1817 from VictoriousRaptor/FixExplorer

Fix bugs in explorer plugin
This commit is contained in:
Jeremy Wu 2023-01-23 08:11:15 +11:00 committed by GitHub
commit db2b856a66
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 265 additions and 122 deletions

View file

@ -1,7 +1,9 @@
using System;
using System.Diagnostics;
using System.IO;
#pragma warning disable IDE0005
using System.Windows;
#pragma warning restore IDE0005
namespace Flow.Launcher.Plugin.SharedCommands
{
@ -206,22 +208,16 @@ namespace Flow.Launcher.Plugin.SharedCommands
///</summary>
public static string GetPreviousExistingDirectory(Func<string, bool> locationExists, string path)
{
var previousDirectoryPath = "";
var index = path.LastIndexOf('\\');
if (index > 0 && index < (path.Length - 1))
{
previousDirectoryPath = path.Substring(0, index + 1);
if (!locationExists(previousDirectoryPath))
{
return "";
}
string previousDirectoryPath = path.Substring(0, index + 1);
return locationExists(previousDirectoryPath) ? previousDirectoryPath : "";
}
else
{
return "";
}
return previousDirectoryPath;
}
///<summary>
@ -241,5 +237,33 @@ namespace Flow.Launcher.Plugin.SharedCommands
return path;
}
/// <summary>
/// Returns if <paramref name="parentPath"/> contains <paramref name="subPath"/>.
/// From https://stackoverflow.com/a/66877016
/// </summary>
/// <param name="parentPath">Parent path</param>
/// <param name="subPath">Sub path</param>
/// <param name="allowEqual">If <see langword="true"/>, when <paramref name="parentPath"/> and <paramref name="subPath"/> are equal, returns <see langword="true"/></param>
/// <returns></returns>
public static bool PathContains(string parentPath, string subPath, bool allowEqual = false)
{
var rel = Path.GetRelativePath(parentPath.EnsureTrailingSlash(), subPath);
return (rel != "." || allowEqual)
&& rel != ".."
&& !rel.StartsWith("../")
&& !rel.StartsWith(@"..\")
&& !Path.IsPathRooted(rel);
}
/// <summary>
/// Returns path ended with "\"
/// </summary>
/// <param name="path"></param>
/// <returns></returns>
public static string EnsureTrailingSlash(this string path)
{
return path.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
}
}
}

View file

@ -0,0 +1,53 @@
using Flow.Launcher.Plugin.SharedCommands;
using NUnit.Framework;
namespace Flow.Launcher.Test
{
[TestFixture]
public class FilesFoldersTest
{
// Testcases from https://stackoverflow.com/a/31941905/20703207
// Disk
[TestCase(@"c:", @"c:\foo", true)]
[TestCase(@"c:\", @"c:\foo", true)]
// Slash
[TestCase(@"c:\foo\bar\", @"c:\foo\", false)]
[TestCase(@"c:\foo\bar", @"c:\foo\", false)]
[TestCase(@"c:\foo", @"c:\foo\bar", true)]
[TestCase(@"c:\foo\", @"c:\foo\bar", true)]
// File
[TestCase(@"c:\foo", @"c:\foo\a.txt", true)]
[TestCase(@"c:\foo", @"c:/foo/a.txt", true)]
[TestCase(@"c:\FOO\a.txt", @"c:\foo", false)]
[TestCase(@"c:\foo\a.txt", @"c:\foo\", false)]
[TestCase(@"c:\foobar\a.txt", @"c:\foo", false)]
[TestCase(@"c:\foobar\a.txt", @"c:\foo\", false)]
[TestCase(@"c:\foo\", @"c:\foo.txt", false)]
// Prefix
[TestCase(@"c:\foo", @"c:\foobar", false)]
[TestCase(@"C:\Program", @"C:\Program Files\", false)]
[TestCase(@"c:\foobar", @"c:\foo\a.txt", false)]
[TestCase(@"c:\foobar\", @"c:\foo\a.txt", false)]
// Edge case
[TestCase(@"c:\foo", @"c:\foo\..\bar\baz", false)]
[TestCase(@"c:\bar", @"c:\foo\..\bar\baz", true)]
[TestCase(@"c:\barr", @"c:\foo\..\bar\baz", false)]
// Equality
[TestCase(@"c:\foo", @"c:\foo", false)]
[TestCase(@"c:\foo\", @"c:\foo", false)]
[TestCase(@"c:\foo", @"c:\foo\", false)]
public void GivenTwoPaths_WhenCheckPathContains_ThenShouldBeExpectedResult(string parentPath, string path, bool expectedResult)
{
Assert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path));
}
[TestCase(@"c:\foo", @"c:\foo", true)]
[TestCase(@"c:\foo\", @"c:\foo", true)]
[TestCase(@"c:\foo", @"c:\foo\", true)]
public void GivenTwoPathsAreTheSame_WhenCheckPathContains_ThenShouldBeTrue(string parentPath, string path, bool expectedResult)
{
Assert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path, true));
}
}
}

View file

@ -7,9 +7,11 @@ using Flow.Launcher.Plugin.SharedCommands;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using static Flow.Launcher.Plugin.Explorer.Search.SearchManager;
namespace Flow.Launcher.Test.Plugins
{
@ -176,7 +178,7 @@ namespace Flow.Launcher.Test.Plugins
var searchManager = new SearchManager(new Settings(), new PluginInitContext());
// When
var result = SearchManager.IsFileContentSearch(query.ActionKeyword);
var result = searchManager.IsFileContentSearch(query.ActionKeyword);
// Then
Assert.IsTrue(result,
@ -193,6 +195,7 @@ namespace Flow.Launcher.Test.Plugins
[TestCase(@"c:\>*", true)]
[TestCase(@"c:\>", true)]
[TestCase(@"c:\SomeLocation\SomeOtherLocation\>", true)]
[TestCase(@"c:\SomeLocation\SomeOtherLocation", true)]
public void WhenGivenQuerySearchString_ThenShouldIndicateIfIsLocationPathString(string querySearchString, bool expectedResult)
{
// When, Given
@ -393,5 +396,68 @@ namespace Flow.Launcher.Test.Plugins
// Then
Assert.AreEqual(result, expectedResult);
}
[TestCase(@"c:\foo", @"c:\foo", true)]
[TestCase(@"C:\Foo\", @"c:\foo\", true)]
[TestCase(@"c:\foo", @"c:\foo\", false)]
public void GivenTwoPaths_WhenCompared_ThenShouldBeExpectedSameOrDifferent(string path1, string path2, bool expectedResult)
{
// Given
var comparator = PathEqualityComparator.Instance;
var result1 = new Result
{
Title = Path.GetFileName(path1),
SubTitle = path1
};
var result2 = new Result
{
Title = Path.GetFileName(path2),
SubTitle = path2
};
// When, Then
Assert.AreEqual(expectedResult, comparator.Equals(result1, result2));
}
[TestCase(@"c:\foo\", @"c:\foo\")]
[TestCase(@"C:\Foo\", @"c:\foo\")]
public void GivenTwoPaths_WhenComparedHasCode_ThenShouldBeSame(string path1, string path2)
{
// Given
var comparator = PathEqualityComparator.Instance;
var result1 = new Result
{
Title = Path.GetFileName(path1),
SubTitle = path1
};
var result2 = new Result
{
Title = Path.GetFileName(path2),
SubTitle = path2
};
var hash1 = comparator.GetHashCode(result1);
var hash2 = comparator.GetHashCode(result2);
// When, Then
Assert.IsTrue(hash1 == hash2);
}
[TestCase(@"%appdata%", true)]
[TestCase(@"%appdata%\123", true)]
[TestCase(@"c:\foo %appdata%\", false)]
[TestCase(@"c:\users\%USERNAME%\downloads", true)]
[TestCase(@"c:\downloads", false)]
[TestCase(@"%", false)]
[TestCase(@"%%", false)]
[TestCase(@"%bla%blabla%", false)]
public void GivenPath_WhenHavingEnvironmentVariableOrNot_ThenShouldBeExpected(string path, bool expectedResult)
{
// When
var result = EnvironmentVariables.HasEnvironmentVar(path);
// Then
Assert.AreEqual(result, expectedResult);
}
}
}

View file

@ -3,8 +3,6 @@
using System;
using System.Threading.Tasks;
using System.Windows;
using Flow.Launcher.Plugin.Explorer.Search.IProvider;
using JetBrains.Annotations;
namespace Flow.Launcher.Plugin.Explorer.Exceptions;
@ -20,7 +18,7 @@ public class EngineNotAvailableException : Exception
string engineName,
string resolution,
string message,
Func<ActionContext, ValueTask<bool>> action = null) : base(message)
Func<ActionContext, ValueTask<bool>>? action = null) : base(message)
{
EngineName = engineName;
Resolution = resolution;
@ -40,6 +38,23 @@ public class EngineNotAvailableException : Exception
EngineName = engineName;
Resolution = resolution;
}
public EngineNotAvailableException(
string engineName,
string resolution,
string message,
string errorIconPath,
Func<ActionContext, ValueTask<bool>>? action = null) : base(message)
{
EngineName = engineName;
Resolution = resolution;
ErrorIcon = errorIconPath;
Action = action ?? (_ =>
{
Clipboard.SetDataObject(this.ToString());
return ValueTask.FromResult(true);
});
}
public override string ToString()
{

View file

@ -1,70 +1,75 @@
using System;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Linq;
using Flow.Launcher.Plugin.SharedCommands;
namespace Flow.Launcher.Plugin.Explorer.Search
{
public static class EnvironmentVariables
{
internal static bool IsEnvironmentVariableSearch(string search)
private static Dictionary<string, string> _envStringPaths = null;
private static Dictionary<string, string> EnvStringPaths
{
return search.StartsWith("%")
&& search != "%%"
&& !search.Contains("\\") &&
LoadEnvironmentStringPaths().Count > 0;
get
{
if (_envStringPaths == null)
{
LoadEnvironmentStringPaths();
}
return _envStringPaths;
}
}
internal static Dictionary<string, string> LoadEnvironmentStringPaths()
internal static bool IsEnvironmentVariableSearch(string search)
{
var envStringPaths = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase);
return search.StartsWith("%")
&& search != "%%"
&& !search.Contains('\\')
&& EnvStringPaths.Count > 0;
}
public static bool HasEnvironmentVar(string search)
{
// "c:\foo %appdata%\" returns false
var splited = search.Split(Path.DirectorySeparatorChar);
return splited.Any(dir => dir.StartsWith('%') &&
dir.EndsWith('%') &&
dir.Length > 2 &&
dir.Split('%').Length == 3);
}
private static void LoadEnvironmentStringPaths()
{
_envStringPaths = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase);
var homedrive = Environment.GetEnvironmentVariable("HOMEDRIVE")?.EnsureTrailingSlash() ?? "C:\\";
foreach (DictionaryEntry special in Environment.GetEnvironmentVariables())
{
var path = special.Value.ToString();
// we add a trailing slash to the path to make sure drive paths become valid absolute paths.
// for example, if %systemdrive% is C: we turn it to C:\
path = path.EnsureTrailingSlash();
// if we don't have an absolute path, we use Path.GetFullPath to get one.
// for example, if %homepath% is \Users\John we turn it to C:\Users\John
// Add basepath for GetFullPath() to parse %HOMEPATH% correctly
path = Path.IsPathFullyQualified(path) ? path : Path.GetFullPath(path, homedrive);
if (Directory.Exists(path))
{
// we add a trailing slash to the path to make sure drive paths become valid absolute paths.
// for example, if %systemdrive% is C: we turn it to C:\
path = path.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
// if we don't have an absolute path, we use Path.GetFullPath to get one.
// for example, if %homepath% is \Users\John we turn it to C:\Users\John
path = Path.IsPathFullyQualified(path) ? path : Path.GetFullPath(path);
// Variables are returned with a mixture of all upper/lower case.
// Call ToLower() to make the results look consistent
envStringPaths.Add(special.Key.ToString().ToLower(), path);
// Call ToUpper() to make the results look consistent
_envStringPaths.Add(special.Key.ToString().ToUpper(), path);
}
}
return envStringPaths;
}
internal static string TranslateEnvironmentVariablePath(string environmentVariablePath)
{
var envStringPaths = LoadEnvironmentStringPaths();
var splitSearch = environmentVariablePath.Substring(1).Split("%");
var exactEnvStringPath = splitSearch[0];
// if there are more than 2 % characters in the query, don't bother
if (splitSearch.Length == 2 && envStringPaths.ContainsKey(exactEnvStringPath))
{
var queryPartToReplace = $"%{exactEnvStringPath}%";
var expandedPath = envStringPaths[exactEnvStringPath];
// replace the %envstring% part of the query with its expanded equivalent
return environmentVariablePath.Replace(queryPartToReplace, expandedPath);
}
return environmentVariablePath;
}
internal static List<Result> GetEnvironmentStringPathSuggestions(string querySearch, Query query, PluginInitContext context)
{
var results = new List<Result>();
var environmentVariables = LoadEnvironmentStringPaths();
var search = querySearch;
if (querySearch.EndsWith("%") && search.Length > 1)
@ -72,12 +77,12 @@ namespace Flow.Launcher.Plugin.Explorer.Search
// query starts and ends with a %, find an exact match from env-string paths
search = querySearch.Substring(1, search.Length - 2);
if (environmentVariables.ContainsKey(search))
if (EnvStringPaths.ContainsKey(search))
{
var expandedPath = environmentVariables[search];
var expandedPath = EnvStringPaths[search];
results.Add(ResultManager.CreateFolderResult($"%{search}%", expandedPath, expandedPath, query));
return results;
}
}
@ -90,8 +95,8 @@ namespace Flow.Launcher.Plugin.Explorer.Search
{
search = search.Substring(1);
}
foreach (var p in environmentVariables)
foreach (var p in EnvStringPaths)
{
if (p.Key.StartsWith(search, StringComparison.InvariantCultureIgnoreCase))
{

View file

@ -27,20 +27,16 @@ namespace Flow.Launcher.Plugin.Explorer.Search.Everything
Enum.GetName(Settings.IndexSearchEngineOption.Everything)!,
Main.Context.API.GetTranslation("flowlauncher_plugin_everything_click_to_launch_or_install"),
Main.Context.API.GetTranslation("flowlauncher_plugin_everything_is_not_running"),
ClickToInstallEverythingAsync)
{
ErrorIcon = Constants.EverythingErrorImagePath
};
Constants.EverythingErrorImagePath,
ClickToInstallEverythingAsync);
}
catch (DllNotFoundException)
{
throw new EngineNotAvailableException(
Enum.GetName(Settings.IndexSearchEngineOption.Everything)!,
"Please check whether your system is x86 or x64",
Main.Context.API.GetTranslation("flowlauncher_plugin_everything_sdk_issue"))
{
ErrorIcon = Constants.GeneralSearchErrorImagePath
};
Constants.GeneralSearchErrorImagePath,
Main.Context.API.GetTranslation("flowlauncher_plugin_everything_sdk_issue"));
}
}
private async ValueTask<bool> ClickToInstallEverythingAsync(ActionContext _)
@ -72,16 +68,14 @@ namespace Flow.Launcher.Plugin.Explorer.Search.Everything
if (!Settings.EnableEverythingContentSearch)
{
throw new EngineNotAvailableException(Enum.GetName(Settings.IndexSearchEngineOption.Everything)!,
"Click to Enable Everything Content Search (only applicable to Everything 1.5+ with indexed content)",
"Everything Content Search is not enabled.",
Main.Context.API.GetTranslation("flowlauncher_plugin_everything_enable_content_search"),
Main.Context.API.GetTranslation("flowlauncher_plugin_everything_enable_content_search_tips"),
Constants.EverythingErrorImagePath,
_ =>
{
Settings.EnableEverythingContentSearch = true;
return ValueTask.FromResult(true);
})
{
ErrorIcon = Constants.EverythingErrorImagePath
};
});
}
if (token.IsCancellationRequested)
yield break;

View file

@ -71,7 +71,7 @@ namespace Flow.Launcher.Plugin.Explorer.Search
{
Title = title,
IcoPath = path,
SubTitle = Path.GetDirectoryName(path),
SubTitle = subtitle,
AutoCompleteText = GetAutoCompleteText(title, query, path, ResultType.Folder),
TitleHighlightData = StringMatcher.FuzzySearch(query.Search, title).MatchData,
CopyText = path,

View file

@ -13,9 +13,9 @@ namespace Flow.Launcher.Plugin.Explorer.Search
{
public class SearchManager
{
internal static PluginInitContext Context;
internal PluginInitContext Context;
internal static Settings Settings;
internal Settings Settings;
public SearchManager(Settings settings, PluginInitContext context)
{
@ -23,19 +23,23 @@ namespace Flow.Launcher.Plugin.Explorer.Search
Settings = settings;
}
private class PathEqualityComparator : IEqualityComparer<Result>
/// <summary>
/// Note: A path that ends with "\" and one that doesn't will not be regarded as equal.
/// </summary>
public class PathEqualityComparator : IEqualityComparer<Result>
{
private static PathEqualityComparator instance;
public static PathEqualityComparator Instance => instance ??= new PathEqualityComparator();
public bool Equals(Result x, Result y)
{
return x.Title == y.Title && x.SubTitle == y.SubTitle;
return x.Title.Equals(y.Title, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.SubTitle, y.SubTitle, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode(Result obj)
{
return HashCode.Combine(obj.Title.GetHashCode(), obj.SubTitle?.GetHashCode() ?? 0);
return HashCode.Combine(obj.Title.ToLowerInvariant(), obj.SubTitle?.ToLowerInvariant() ?? "");
}
}
@ -105,19 +109,21 @@ namespace Flow.Launcher.Plugin.Explorer.Search
await foreach (var search in searchResults.WithCancellation(token).ConfigureAwait(false))
results.Add(ResultManager.CreateResult(query, search));
}
catch (OperationCanceledException)
{
return new List<Result>();
}
catch (EngineNotAvailableException)
{
throw;
}
catch (Exception e)
{
if (e is OperationCanceledException)
return results.ToList();
if (e is EngineNotAvailableException)
throw;
throw new SearchException(engineName, e.Message, e);
}
results.RemoveWhere(r => Settings.IndexSearchExcludedSubdirectoryPaths.Any(
excludedPath => r.SubTitle.StartsWith(excludedPath.Path, StringComparison.OrdinalIgnoreCase)));
excludedPath => FilesFolders.PathContains(excludedPath.Path, r.SubTitle)));
return results.ToList();
}
@ -142,7 +148,7 @@ namespace Flow.Launcher.Plugin.Explorer.Search
};
}
private static List<Result> EverythingContentSearchResult(Query query)
private List<Result> EverythingContentSearchResult(Query query)
{
return new List<Result>()
{
@ -167,18 +173,12 @@ namespace Flow.Launcher.Plugin.Explorer.Search
var results = new HashSet<Result>(PathEqualityComparator.Instance);
var isEnvironmentVariable = EnvironmentVariables.IsEnvironmentVariableSearch(querySearch);
if (isEnvironmentVariable)
if (EnvironmentVariables.IsEnvironmentVariableSearch(querySearch))
return EnvironmentVariables.GetEnvironmentStringPathSuggestions(querySearch, query, Context);
// Query is a location path with a full environment variable, eg. %appdata%\somefolder\
var isEnvironmentVariablePath = querySearch[1..].Contains("%\\");
var locationPath = querySearch;
if (isEnvironmentVariablePath)
locationPath = EnvironmentVariables.TranslateEnvironmentVariablePath(locationPath);
// Query is a location path with a full environment variable, eg. %appdata%\somefolder\, c:\users\%USERNAME%\downloads
var needToExpand = EnvironmentVariables.HasEnvironmentVar(querySearch);
var locationPath = needToExpand ? Environment.ExpandEnvironmentVariables(querySearch) : querySearch;
// Check that actual location exists, otherwise directory search will throw directory not found exception
if (!FilesFolders.ReturnPreviousDirectoryIfIncompleteString(locationPath).LocationExists())
@ -234,7 +234,7 @@ namespace Flow.Launcher.Plugin.Explorer.Search
return results.ToList();
}
public static bool IsFileContentSearch(string actionKeyword) => actionKeyword == Settings.FileContentSearchActionKeyword;
public bool IsFileContentSearch(string actionKeyword) => actionKeyword == Settings.FileContentSearchActionKeyword;
private bool UseWindowsIndexForDirectorySearch(string locationPath)
@ -245,10 +245,10 @@ namespace Flow.Launcher.Plugin.Explorer.Search
x => FilesFolders.ReturnPreviousDirectoryIfIncompleteString(pathToDirectory).StartsWith(x.Path, StringComparison.OrdinalIgnoreCase))
&& WindowsIndex.WindowsIndex.PathIsIndexed(pathToDirectory);
}
internal static bool IsEnvironmentVariableSearch(string search)
{
return search.StartsWith("%")
return search.StartsWith("%")
&& search != "%%"
&& !search.Contains('\\');
}

View file

@ -97,27 +97,25 @@ namespace Flow.Launcher.Plugin.Explorer.Search.WindowsIndex
private IAsyncEnumerable<SearchResult> HandledEngineNotAvailableExceptionAsync()
{
if (!SearchManager.Settings.WarnWindowsSearchServiceOff)
if (!Settings.WarnWindowsSearchServiceOff)
return AsyncEnumerable.Empty<SearchResult>();
var api = SearchManager.Context.API;
var api = Main.Context.API;
throw new EngineNotAvailableException(
"Windows Index",
api.GetTranslation("plugin_explorer_windowsSearchServiceFix"),
api.GetTranslation("plugin_explorer_windowsSearchServiceNotRunning"),
Constants.WindowsIndexErrorImagePath,
c =>
{
SearchManager.Settings.WarnWindowsSearchServiceOff = false;
Settings.WarnWindowsSearchServiceOff = false;
// Clears the warning message so user is not mistaken that it has not worked
api.ChangeQuery(string.Empty);
return ValueTask.FromResult(false);
})
{
ErrorIcon = Constants.WindowsIndexErrorImagePath
};
});
}
}
}

View file

@ -470,8 +470,8 @@ namespace Flow.Launcher.Plugin.Program.Programs
}
var paths = pathEnv.Split(";", StringSplitOptions.RemoveEmptyEntries).DistinctBy(p => p.ToLowerInvariant());
var toFilter = paths.Where(x => commonParents.All(parent => !IsSubPathOf(x, parent)))
var toFilter = paths.Where(x => commonParents.All(parent => !FilesFolders.PathContains(parent, x)))
.AsParallel()
.SelectMany(p => EnumerateProgramsInDir(p, suffixes, recursive: false));
@ -763,17 +763,6 @@ namespace Flow.Launcher.Plugin.Program.Programs
}
}
// https://stackoverflow.com/a/66877016
private static bool IsSubPathOf(string subPath, string basePath)
{
var rel = Path.GetRelativePath(basePath, subPath);
return rel != "."
&& rel != ".."
&& !rel.StartsWith("../")
&& !rel.StartsWith(@"..\")
&& !Path.IsPathRooted(rel);
}
private static List<string> GetCommonParents(IEnumerable<ProgramSource> programSources)
{
// To avoid unnecessary io
@ -785,8 +774,7 @@ namespace Flow.Launcher.Plugin.Program.Programs
HashSet<ProgramSource> parents = group.ToHashSet();
foreach (var source in group)
{
if (parents.Any(p => IsSubPathOf(source.Location, p.Location) &&
source != p))
if (parents.Any(p => FilesFolders.PathContains(p.Location, source.Location)))
{
parents.Remove(source);
}