diff --git a/Flow.Launcher.Test/Plugins/ExplorerTest.cs b/Flow.Launcher.Test/Plugins/ExplorerTest.cs index 9ec952155..42983361d 100644 --- a/Flow.Launcher.Test/Plugins/ExplorerTest.cs +++ b/Flow.Launcher.Test/Plugins/ExplorerTest.cs @@ -39,8 +39,8 @@ namespace Flow.Launcher.Test.Plugins } [SupportedOSPlatform("windows7.0")] - [TestCase("C:\\", $"SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\' ORDER BY {QueryConstructor.OrderIdentifier}")] - [TestCase("C:\\SomeFolder\\", $"SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\SomeFolder\\' ORDER BY {QueryConstructor.OrderIdentifier}")] + [TestCase("C:\\", $"SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND directory='file:C:\\' ORDER BY {QueryConstructor.OrderIdentifier}")] + [TestCase("C:\\SomeFolder\\", $"SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND directory='file:C:\\SomeFolder\\' ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearch_WhenSearchTypeIsTopLevelDirectorySearch_ThenQueryShouldUseExpectedString(string folderPath, string expectedString) { // Given @@ -56,8 +56,7 @@ namespace Flow.Launcher.Test.Plugins } [SupportedOSPlatform("windows7.0")] - [TestCase("C:\\SomeFolder", "flow.launcher.sln", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType" + - " FROM SystemIndex WHERE directory='file:C:\\SomeFolder'" + + [TestCase("C:\\SomeFolder", "flow.launcher.sln", "SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND directory='file:C:\\SomeFolder'" + " AND (System.FileName LIKE 'flow.launcher.sln%' OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"'))" + $" ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearchTopLevelDirectory_WhenSearchingForSpecificItem_ThenQueryShouldUseExpectedString( @@ -87,8 +86,8 @@ namespace Flow.Launcher.Test.Plugins [SupportedOSPlatform("windows7.0")] [TestCase("flow.launcher.sln", "SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" " + "FROM \"SystemIndex\" WHERE (System.FileName LIKE 'flow.launcher.sln%' " + - $"OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"',1033)) AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")] - [TestCase("", $"SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")] + $"OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"',1033) RANK BY COERCION(ABSOLUTE, 1000)) AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")] + [TestCase("", $"SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND (scope='file:') ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearch_WhenSearchAllFoldersAndFiles_ThenQueryShouldUseExpectedString( string userSearchString, string expectedString) { @@ -104,7 +103,8 @@ namespace Flow.Launcher.Test.Plugins var resultString = queryConstructor.FilesAndFolders(userSearchString); // Then - ClassicAssert.AreEqual(expectedString, resultString); + ClassicAssert.AreEqual(expectedString, resultString, $"Expected string: {expectedString}{Environment.NewLine} " + + $"Actual string was: {resultString}{Environment.NewLine}"); } [SupportedOSPlatform("windows7.0")] @@ -125,8 +125,7 @@ namespace Flow.Launcher.Test.Plugins } [SupportedOSPlatform("windows7.0")] - [TestCase("some words", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType " + - $"FROM SystemIndex WHERE FREETEXT('some words') AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")] + [TestCase("some words", $"SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND FREETEXT('some words') AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearch_WhenSearchForFileContent_ThenQueryShouldUseExpectedString( string userSearchString, string expectedString) { diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Search/Constants.cs b/Plugins/Flow.Launcher.Plugin.Explorer/Search/Constants.cs index 4bddfd9b2..7e2a8e525 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Search/Constants.cs +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Search/Constants.cs @@ -36,6 +36,8 @@ namespace Flow.Launcher.Plugin.Explorer.Search internal const string WindowsIndexingOptions = "srchadmin.dll"; + internal const string ExcludedFileTypesSeparator = ","; + internal static string ExplorerIconImageFullPath => Directory.GetParent(Assembly.GetExecutingAssembly().Location.ToString()) + "\\" + ExplorerIconImagePath; } diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Search/Everything/EverythingSearchManager.cs b/Plugins/Flow.Launcher.Plugin.Explorer/Search/Everything/EverythingSearchManager.cs index 14c48b3c0..602be877a 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Search/Everything/EverythingSearchManager.cs +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Search/Everything/EverythingSearchManager.cs @@ -80,7 +80,7 @@ namespace Flow.Launcher.Plugin.Explorer.Search.Everything if (token.IsCancellationRequested) yield break; - var searchKeyword = BuildSearchKeywordWithTypeFilter(search, allowedResultTypes); + var searchKeyword = BuildSearchKeyword(search, allowedResultTypes); var option = new EverythingSearchOption(searchKeyword, Settings.SortOption, @@ -92,16 +92,35 @@ namespace Flow.Launcher.Plugin.Explorer.Search.Everything yield return result; } - private static string BuildSearchKeywordWithTypeFilter(string search, IEnumerable allowedResultTypes) + private string BuildSearchKeyword(string search, IEnumerable allowedResultTypes) + { + var filters = new List(); + + var typeFilter = BuildTypeFilter(allowedResultTypes); + if (!string.IsNullOrEmpty(typeFilter)) + filters.Add(typeFilter); + + var extensionFilter = BuildExtensionExclusionFilter(); + if (!string.IsNullOrEmpty(extensionFilter)) + filters.Add(extensionFilter); + + if (filters.Count == 0) + return search; + + var combinedFilters = string.Join(" ", filters); + return string.IsNullOrEmpty(search) ? combinedFilters : $"{combinedFilters} {search}"; + } + + private static string BuildTypeFilter(IEnumerable allowedResultTypes) { if (allowedResultTypes == null) - return search; + return ""; var hasFile = allowedResultTypes.Contains(ResultType.File); var hasFolder = allowedResultTypes.Contains(ResultType.Folder); var hasVolume = allowedResultTypes.Contains(ResultType.Volume); - var filter = (hasFile, hasFolder, hasVolume) switch + return (hasFile, hasFolder, hasVolume) switch { (true, false, false) => "file:", (false, true, false) => "folder:", @@ -109,10 +128,23 @@ namespace Flow.Launcher.Plugin.Explorer.Search.Everything (true, true, false) => "", (true, false, true) => "", (false, true, true) => "", - _ => null // No filtering needed when all allowed or unspecified + _ => "" // No filtering needed when all allowed or unspecified }; + } - return filter == null ? search : $"{filter} {search}"; + private string BuildExtensionExclusionFilter() + { + // Split extensions, remove whitespace, and add dot prefix + var extensions = Settings.ExcludedFileTypeList + .Where(ext => !string.IsNullOrWhiteSpace(ext)) + .Select(ext => $"!*.{ext}") + .ToArray(); + + if (extensions.Length == 0) + return ""; + + // Everything syntax: !*.ext1 !*.ext2 to exclude these extensions + return string.Join(" ", extensions); } public async IAsyncEnumerable ContentSearchAsync(string plainSearch, string contentSearch, @@ -137,7 +169,10 @@ namespace Flow.Launcher.Plugin.Explorer.Search.Everything if (token.IsCancellationRequested) yield break; - var option = new EverythingSearchOption(plainSearch, + // Apply excluded file types in content search + var searchKeyword = BuildSearchKeyword(plainSearch, new[] { ResultType.File }); + + var option = new EverythingSearchOption(searchKeyword, Settings.SortOption, IsContentSearch: true, ContentSearchKeyword: contentSearch, @@ -158,7 +193,10 @@ namespace Flow.Launcher.Plugin.Explorer.Search.Everything if (token.IsCancellationRequested) yield break; - var option = new EverythingSearchOption(search, + // Apply excluded file types in path enumeration + var searchKeyword = BuildSearchKeyword(search, null); + + var option = new EverythingSearchOption(searchKeyword, Settings.SortOption, ParentPath: path, IsRecursive: recursive, diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Search/SearchManager.cs b/Plugins/Flow.Launcher.Plugin.Explorer/Search/SearchManager.cs index a70a62db3..871a08bad 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Search/SearchManager.cs +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Search/SearchManager.cs @@ -148,12 +148,11 @@ namespace Flow.Launcher.Plugin.Explorer.Search { await foreach (var search in searchResults.WithCancellation(token).ConfigureAwait(false)) { + // TODO exclude in quick access if (search.Type == ResultType.File && IsExcludedFile(search)) continue; // TODO: Optimize filtering by action keyword at the provider level to reduce unnecessary searches. - // 1. Path search and content search may not need filtering as they are specific enough. - // 2. Index search can be optimized by passing allowed result types to the provider to limit the search scope. - // 3. Quick access link filtering is already handled separately. + // 3. Filter in quick access // if (IsResultTypeFilteredByActionKeyword(search.Type, actions)) continue; @@ -199,6 +198,13 @@ namespace Flow.Launcher.Plugin.Explorer.Search ]; } + /// + /// Path search logic. Don't apply filtering by file extensions as it's like ls command. + /// + /// + /// + /// + /// private async Task> PathSearchAsync(Query query, CancellationToken token = default) { var querySearch = query.Search; @@ -295,7 +301,8 @@ namespace Flow.Launcher.Plugin.Explorer.Search private bool IsExcludedFile(SearchResult result) { - string[] excludedFileTypes = Settings.ExcludedFileTypes.Split([','], StringSplitOptions.RemoveEmptyEntries); + // TODO may remove this function + string[] excludedFileTypes = Settings.ExcludedFileTypes.Split([Constants.ExcludedFileTypesSeparator], StringSplitOptions.RemoveEmptyEntries); string fileExtension = Path.GetExtension(result.FullPath).TrimStart('.'); return excludedFileTypes.Contains(fileExtension, StringComparer.OrdinalIgnoreCase); diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Search/WindowsIndex/QueryConstructor.cs b/Plugins/Flow.Launcher.Plugin.Explorer/Search/WindowsIndex/QueryConstructor.cs index def63f347..5837c81d7 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Search/WindowsIndex/QueryConstructor.cs +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Search/WindowsIndex/QueryConstructor.cs @@ -34,7 +34,7 @@ namespace Flow.Launcher.Plugin.Explorer.Search.WindowsIndex baseQuery.QueryContentProperties = "System.FileName"; // Set sorting order - //baseQuery.QuerySorting = "System.ItemType DESC"; + baseQuery.QuerySorting = OrderIdentifier; return baseQuery; } @@ -62,15 +62,15 @@ namespace Flow.Launcher.Plugin.Explorer.Search.WindowsIndex /// public string Directory(ReadOnlySpan path, ReadOnlySpan searchString = default, bool recursive = false) { - var queryConstraint = searchString.IsWhiteSpace() ? "" : $"AND (System.FileName LIKE '{searchString}%' OR CONTAINS(System.FileName,'\"{searchString}*\"'))"; + var queryConstraint = searchString.IsWhiteSpace() ? "" : $" AND (System.FileName LIKE '{searchString}%' OR CONTAINS(System.FileName,'\"{searchString}*\"'))"; var scopeConstraint = recursive ? RecursiveDirectoryConstraint(path) : TopLevelDirectoryConstraint(path); - var query = $"SELECT TOP {Settings.MaxResult} {CreateBaseQuery().QuerySelectColumns} FROM {SystemIndex} WHERE {scopeConstraint} {queryConstraint} ORDER BY {OrderIdentifier}"; - - return query; + var baseQueryHelper = CreateBaseQuery(); + baseQueryHelper.QueryWhereRestrictions = $"AND {scopeConstraint}{queryConstraint}"; + return baseQueryHelper.GenerateSQLFromUserQuery("*"); } /// @@ -84,17 +84,22 @@ namespace Flow.Launcher.Plugin.Explorer.Search.WindowsIndex // Remove any special characters that might cause issues with the query var replacedSearchString = ReplaceSpecialCharacterWithTwoSideWhiteSpace(userSearchString); - // Build the type filter constraint - var typeFilterConstraint = BuildTypeFilterConstraint(allowedResultTypes); + var constraints = new List + { + RestrictionsForAllFilesAndFoldersSearch + }; - // Generate SQL from constructed parameters, converting the userSearchString from AQS->WHERE clause - var baseQuery = $"{CreateBaseQuery().GenerateSQLFromUserQuery(replacedSearchString)} AND {RestrictionsForAllFilesAndFoldersSearch}"; - - // Append type filter if present - if (!string.IsNullOrEmpty(typeFilterConstraint)) - baseQuery += $" AND {typeFilterConstraint}"; - - return $"{baseQuery} ORDER BY {OrderIdentifier}"; + var typeConstraint = BuildTypeFilterConstraint(allowedResultTypes); + if (!string.IsNullOrEmpty(typeConstraint)) + constraints.Add(typeConstraint); + + var extensionConstraint = BuildExtensionExclusionConstraint(); + if (!string.IsNullOrEmpty(extensionConstraint)) + constraints.Add(extensionConstraint); + + var queryHelper = CreateBaseQuery(); + queryHelper.QueryWhereRestrictions = $"AND {string.Join(" AND ", constraints)}"; + return queryHelper.GenerateSQLFromUserQuery(replacedSearchString); } /// @@ -128,6 +133,22 @@ namespace Flow.Launcher.Plugin.Explorer.Search.WindowsIndex return null; } + /// + /// Build WHERE clause constraint to exclude specific file extensions. + /// + /// Comma or semicolon separated file extensions without dots (e.g., "queryHelper,log,bak") + private string BuildExtensionExclusionConstraint() + { + var extensions = Settings.ExcludedFileTypeList + .Select(ext => $"System.FileExtension NOT LIKE '.{ext}'") + .ToArray(); + + if (extensions.Length == 0) + return ""; + + return string.Join(" AND ", extensions); + } + /// /// If one special character have white space on one side, replace it with one white space. /// So command will not have "[special character]+*" which will cause OLEDB exception. @@ -174,10 +195,19 @@ namespace Flow.Launcher.Plugin.Explorer.Search.WindowsIndex /// public string FileContent(ReadOnlySpan userSearchString) { - string query = - $"SELECT TOP {Settings.MaxResult} {CreateBaseQuery().QuerySelectColumns} FROM {SystemIndex} WHERE {RestrictionsForFileContentSearch(userSearchString)} AND {RestrictionsForAllFilesAndFoldersSearch} ORDER BY {OrderIdentifier}"; + var constraints = new List + { + RestrictionsForFileContentSearch(userSearchString), + RestrictionsForAllFilesAndFoldersSearch + }; - return query; + var extensionConstraint = BuildExtensionExclusionConstraint(); + if (!string.IsNullOrEmpty(extensionConstraint)) + constraints.Add(extensionConstraint); + + var queryHelper = CreateBaseQuery(); + queryHelper.QueryWhereRestrictions = $"AND {string.Join(" AND ", constraints)}"; + return queryHelper.GenerateSQLFromUserQuery("*"); } /// diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Settings.cs b/Plugins/Flow.Launcher.Plugin.Explorer/Settings.cs index 1784980eb..9c27d367c 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Settings.cs +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Settings.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; +using System.Linq; using System.Text.Json.Serialization; using Flow.Launcher.Plugin.Explorer.Search; using Flow.Launcher.Plugin.Explorer.Search.Everything; @@ -25,7 +26,37 @@ namespace Flow.Launcher.Plugin.Explorer public string ShellPath { get; set; } = "cmd"; - public string ExcludedFileTypes { get; set; } = ""; + [JsonIgnore] + private string _excludedFileTypes = ""; + /// + /// File extensions, without dot prefix separated by comma. + /// + public string ExcludedFileTypes + { + get => _excludedFileTypes; + set + { + if (_excludedFileTypes == value) return; + _excludedFileTypes = value; + _excludedFileTypeList = ExcludedFileTypes.Split(Constants.ExcludedFileTypesSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToArray(); + } + } + + [JsonIgnore] + private string[] _excludedFileTypeList = null; + + [JsonIgnore] + public string[] ExcludedFileTypeList + { + get + { + if (_excludedFileTypeList == null) + { + _excludedFileTypeList = ExcludedFileTypes.Split(Constants.ExcludedFileTypesSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToArray(); + } + return _excludedFileTypeList; + } + } public bool UseLocationAsWorkingDir { get; set; } = false; diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/ViewModels/SettingsViewModel.cs b/Plugins/Flow.Launcher.Plugin.Explorer/ViewModels/SettingsViewModel.cs index 956c84db2..8cf12d8e6 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/ViewModels/SettingsViewModel.cs +++ b/Plugins/Flow.Launcher.Plugin.Explorer/ViewModels/SettingsViewModel.cs @@ -574,6 +574,8 @@ namespace Flow.Launcher.Plugin.Explorer.ViewModels get => Settings.ExcludedFileTypes; set { + if (value == Settings.ExcludedFileTypes) + return; // remove spaces and dots from the string before saving string sanitized = string.IsNullOrEmpty(value) ? "" : value.Replace(" ", "").Replace(".", ""); Settings.ExcludedFileTypes = sanitized;