This commit is contained in:
VictoriousRaptor 2026-03-09 21:03:33 +01:00 committed by GitHub
commit 994052f3a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 243 additions and 33 deletions

View file

@ -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,7 +86,7 @@ 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}")]
$"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)
{

View file

@ -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;
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
@ -72,14 +73,16 @@ namespace Flow.Launcher.Plugin.Explorer.Search.Everything
}
}
public async IAsyncEnumerable<SearchResult> SearchAsync(string search, [EnumeratorCancellation] CancellationToken token)
public async IAsyncEnumerable<SearchResult> SearchAsync(string search, [EnumeratorCancellation] CancellationToken token, IEnumerable<ResultType> allowedResultTypes = null)
{
await ThrowIfEverythingNotAvailableAsync(token);
if (token.IsCancellationRequested)
yield break;
var option = new EverythingSearchOption(search,
var searchKeyword = BuildSearchKeyword(search, allowedResultTypes);
var option = new EverythingSearchOption(searchKeyword,
Settings.SortOption,
MaxCount: Settings.MaxResult,
IsFullPathSearch: Settings.EverythingSearchFullPath,
@ -89,6 +92,61 @@ namespace Flow.Launcher.Plugin.Explorer.Search.Everything
yield return result;
}
private string BuildSearchKeyword(string search, IEnumerable<ResultType> allowedResultTypes)
{
var filters = new List<string>();
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<ResultType> allowedResultTypes)
{
if (allowedResultTypes == null)
return "";
var hasFile = allowedResultTypes.Contains(ResultType.File);
var hasFolder = allowedResultTypes.Contains(ResultType.Folder);
var hasVolume = allowedResultTypes.Contains(ResultType.Volume);
return (hasFile, hasFolder, hasVolume) switch
{
(true, false, false) => "file:",
(false, true, false) => "folder:",
(false, false, true) => "volume:",
(true, true, false) => "<file:|folder:>",
(true, false, true) => "<file:|volume:>",
(false, true, true) => "<folder:|volume:>",
_ => "" // No filtering needed when all allowed or unspecified
};
}
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<SearchResult> ContentSearchAsync(string plainSearch, string contentSearch,
[EnumeratorCancellation] CancellationToken token)
{
@ -111,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,
@ -132,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,

View file

@ -3,8 +3,18 @@ using System.Threading;
namespace Flow.Launcher.Plugin.Explorer.Search.IProvider
{
/// <summary>
/// Provides functionality for searching indexed items.
/// </summary>
public interface IIndexProvider
{
public IAsyncEnumerable<SearchResult> SearchAsync(string search, CancellationToken token);
/// <summary>
/// Asynchronously searches for items matching the specified search criteria.
/// </summary>
/// <param name="search">The search query string.</param>
/// <param name="token">The cancellation token to cancel the search operation.</param>
/// <param name="allowedResultTypes">Optional collection of result types to filter the search results. If null, all result types are included.</param>
/// <returns>An asynchronous enumerable of <see cref="SearchResult"/> objects matching the search criteria.</returns>
public IAsyncEnumerable<SearchResult> SearchAsync(string search, CancellationToken token, IEnumerable<ResultType> allowedResultTypes = null);
}
}

View file

@ -132,7 +132,7 @@ namespace Flow.Launcher.Plugin.Explorer.Search
case false
when CanUseIndexSearchByActionKeywords(activeActionKeywords):
searchResults = Settings.IndexProvider.SearchAsync(query.Search, token);
searchResults = Settings.IndexProvider.SearchAsync(query.Search, token, GetAllowedResultTypeByUsedActionKeyword(activeActionKeywords));
engineName = Enum.GetName(Settings.IndexSearchEngine);
break;
default:
@ -148,9 +148,12 @@ 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.
// 3. Filter in quick access
//
if (IsResultTypeFilteredByActionKeyword(search.Type, actions))
continue;
@ -195,6 +198,13 @@ namespace Flow.Launcher.Plugin.Explorer.Search
];
}
/// <summary>
/// Path search logic. Don't apply filtering by file extensions as it's like ls command.
/// </summary>
/// <param name="query"></param>
/// <param name="token"></param>
/// <returns></returns>
/// <exception cref="SearchException"></exception>
private async Task<List<Result>> PathSearchAsync(Query query, CancellationToken token = default)
{
var querySearch = query.Search;
@ -291,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);
@ -326,6 +337,20 @@ namespace Flow.Launcher.Plugin.Explorer.Search
return true;
}
private List<ResultType> GetAllowedResultTypeByUsedActionKeyword(Dictionary<ActionKeyword, string> activeActionKeywords)
{
List<ActionKeyword> activeKeywords = activeActionKeywords.Keys.ToList();
var allowedResultTypes = new List<ResultType>();
foreach (var actionKeyword in activeKeywords)
{
if (_allowedTypesByActionKeyword.TryGetValue(actionKeyword, out var resultTypes))
{
allowedResultTypes.AddRange(resultTypes);
}
}
return allowedResultTypes.Distinct().ToList();
}
private bool CanUseIndexSearchByActionKeywords(Dictionary<ActionKeyword, string> actions)
{
var keysToUseIndexSearch = new[]

View file

@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.Search.Interop;
@ -32,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;
}
@ -60,21 +62,21 @@ namespace Flow.Launcher.Plugin.Explorer.Search.WindowsIndex
///</summary>
public string Directory(ReadOnlySpan<char> path, ReadOnlySpan<char> 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("*");
}
///<summary>
/// Search will be performed on all folders and files based on user's search keywords.
///</summary>
public string FilesAndFolders(ReadOnlySpan<char> userSearchString)
public string FilesAndFolders(ReadOnlySpan<char> userSearchString, IEnumerable<ResultType> allowedResultTypes = null)
{
if (userSearchString.IsWhiteSpace())
userSearchString = "*";
@ -82,8 +84,69 @@ namespace Flow.Launcher.Plugin.Explorer.Search.WindowsIndex
// Remove any special characters that might cause issues with the query
var replacedSearchString = ReplaceSpecialCharacterWithTwoSideWhiteSpace(userSearchString);
// Generate SQL from constructed parameters, converting the userSearchString from AQS->WHERE clause
return $"{CreateBaseQuery().GenerateSQLFromUserQuery(replacedSearchString)} AND {RestrictionsForAllFilesAndFoldersSearch} ORDER BY {OrderIdentifier}";
var constraints = new List<string>
{
RestrictionsForAllFilesAndFoldersSearch
};
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);
}
/// <summary>
/// Build WHERE clause constraint to filter by result types (File, Folder, Volume).
/// </summary>
/// <remarks>
/// System.ItemType values:
/// - ".directory" for folders
/// - Specific file extensions (e.g., ".txt", ".pdf") for files
/// - Drive volumes are identified by their path structure
/// </remarks>
private static string BuildTypeFilterConstraint(IEnumerable<ResultType> allowedResultTypes)
{
if (allowedResultTypes == null)
return null;
var typesList = allowedResultTypes as IList<ResultType> ?? allowedResultTypes.ToList();
var hasFile = typesList.Contains(ResultType.File);
var hasFolder = typesList.Contains(ResultType.Folder) || typesList.Contains(ResultType.Volume); // Folder and volume are merged in Folder action keyword so treat them as same
// No filtering needed if empty or all types are allowed
if (hasFile && hasFolder || !hasFile && !hasFolder)
return null;
if (hasFolder)
return "System.ItemType = '.directory'";
if (hasFile)
return "System.ItemType <> '.directory'";
return null;
}
/// <summary>
/// Build WHERE clause constraint to exclude specific file extensions.
/// </summary>
/// <param name="excludedFileTypes">Comma or semicolon separated file extensions without dots (e.g., "queryHelper,log,bak")</param>
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);
}
/// <summary>
@ -132,10 +195,19 @@ namespace Flow.Launcher.Plugin.Explorer.Search.WindowsIndex
///</summary>
public string FileContent(ReadOnlySpan<char> userSearchString)
{
string query =
$"SELECT TOP {Settings.MaxResult} {CreateBaseQuery().QuerySelectColumns} FROM {SystemIndex} WHERE {RestrictionsForFileContentSearch(userSearchString)} AND {RestrictionsForAllFilesAndFoldersSearch} ORDER BY {OrderIdentifier}";
var constraints = new List<string>
{
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("*");
}
///<summary>

View file

@ -45,13 +45,14 @@ namespace Flow.Launcher.Plugin.Explorer.Search.WindowsIndex
private IAsyncEnumerable<SearchResult> WindowsIndexFilesAndFoldersSearchAsync(
ReadOnlySpan<char> querySearchString,
IEnumerable<ResultType> allowedResultTypes = null,
CancellationToken token = default)
{
try
{
return WindowsIndex.WindowsIndexSearchAsync(
QueryConstructor.CreateQueryHelper().ConnectionString,
QueryConstructor.FilesAndFolders(querySearchString),
QueryConstructor.FilesAndFolders(querySearchString, allowedResultTypes),
token);
}
catch (COMException)
@ -83,9 +84,9 @@ namespace Flow.Launcher.Plugin.Explorer.Search.WindowsIndex
}
}
public IAsyncEnumerable<SearchResult> SearchAsync(string search, CancellationToken token)
public IAsyncEnumerable<SearchResult> SearchAsync(string search, CancellationToken token, IEnumerable<ResultType> allowedResultTypes = null)
{
return WindowsIndexFilesAndFoldersSearchAsync(search, token: token);
return WindowsIndexFilesAndFoldersSearchAsync(search, allowedResultTypes, token);
}
public IAsyncEnumerable<SearchResult> ContentSearchAsync(string plainSearch, string contentSearch, CancellationToken token)

View file

@ -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 = "";
/// <summary>
/// File extensions, without dot prefix separated by comma.
/// </summary>
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;
@ -82,12 +113,16 @@ namespace Flow.Launcher.Plugin.Explorer
public string PreviewPanelTimeFormat { get; set; } = "HH:mm";
private EverythingSearchManager _everythingManagerInstance;
private WindowsIndexSearchManager _windowsIndexSearchManager;
#region SearchEngine
[JsonIgnore]
private EverythingSearchManager _everythingManagerInstance;
[JsonIgnore]
private WindowsIndexSearchManager _windowsIndexSearchManager;
[JsonIgnore]
private EverythingSearchManager EverythingManagerInstance => _everythingManagerInstance ??= new EverythingSearchManager(this);
[JsonIgnore]
private WindowsIndexSearchManager WindowsIndexSearchManager => _windowsIndexSearchManager ??= new WindowsIndexSearchManager(this);
public IndexSearchEngineOption IndexSearchEngine { get; set; } = IndexSearchEngineOption.WindowsIndex;

View file

@ -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;