Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)

This commit is contained in:
WpyQwq
2026-09-19 11:54:03 +08:00
commit 5780fde61a
60 changed files with 15035 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
using System.Globalization;
using System.Text;
namespace FluidExplorer.Services.Search;
/// <summary>
/// Everything 风格的查询:空格 = AND,| = OR,"引号" = 精确短语,!term = 排除,
/// 支持 ext: size: dm: dc: folder: file: path: 以及 * ? 通配符。
/// </summary>
public sealed class SearchQuery
{
public string RawText { get; init; } = string.Empty;
public List<string> IncludeTerms { get; } = [];
public List<string> ExcludeTerms { get; } = [];
public List<string> IncludeRegexLike { get; } = []; // 含通配符的项
public List<string> Extensions { get; } = [];
public List<string> ExcludeExtensions { get; } = [];
public bool DirectoriesOnly { get; set; }
public bool FilesOnly { get; set; }
public long? MinSize { get; set; }
public long? MaxSize { get; set; }
public DateTime? ModifiedAfter { get; set; }
public DateTime? ModifiedBefore { get; set; }
public DateTime? CreatedAfter { get; set; }
public string? PathFilter { get; set; }
public bool MatchWholePath { get; set; }
public bool IsEmpty => IncludeTerms.Count == 0 && IncludeRegexLike.Count == 0 && Extensions.Count == 0
&& !DirectoriesOnly && !FilesOnly && MinSize is null && MaxSize is null
&& ModifiedAfter is null && ModifiedBefore is null && CreatedAfter is null;
}
public static class SearchQueryParser
{
public static SearchQuery Parse(string? text)
{
var q = new SearchQuery { RawText = text ?? string.Empty };
if (string.IsNullOrWhiteSpace(text)) return q;
if (text.StartsWith('*') && text.EndsWith('*') && text.Length > 2)
{
q.IncludeTerms.Add(text.Trim('*'));
return q;
}
foreach (var token in Tokenize(text))
{
if (token.Length == 0) continue;
var span = token.AsSpan();
if (span[0] == '!')
{
var t = token[1..].Trim();
if (t.Length == 0) continue;
if (TryExt(t, out var ex)) q.ExcludeExtensions.Add(ex);
else q.ExcludeTerms.Add(t.Trim('"'));
continue;
}
if (TryPrefix(span, "ext:", out var extValue))
{
foreach (var e in extValue.Split([';', ','], StringSplitOptions.RemoveEmptyEntries))
q.Extensions.Add(e.Trim().TrimStart('.').ToLowerInvariant());
continue;
}
if (TryPrefix(span, "size:", out var sizeValue))
{
ParseSize(sizeValue, q);
continue;
}
if (TryPrefix(span, "dm:", out var dm))
{
if (TryParseDate(dm, out var d)) q.ModifiedAfter = d;
continue;
}
if (TryPrefix(span, "dc:", out var dc))
{
if (TryParseDate(dc, out var d)) q.CreatedAfter = d;
continue;
}
if (TryPrefix(span, "folder:", out _) || token.Equals("folder:", StringComparison.OrdinalIgnoreCase))
{
q.DirectoriesOnly = true;
continue;
}
if (token.Equals("file:", StringComparison.OrdinalIgnoreCase))
{
q.FilesOnly = true;
continue;
}
if (TryPrefix(span, "path:", out var pathValue))
{
q.PathFilter = pathValue.Trim('"');
q.MatchWholePath = true;
continue;
}
if (token.Equals("dm:today", StringComparison.OrdinalIgnoreCase)) { q.ModifiedAfter = DateTime.Today; continue; }
var cleaned = token.Trim('"');
if (cleaned.Length == 0) continue;
if (cleaned.IndexOfAny(['*', '?']) >= 0) q.IncludeRegexLike.Add(cleaned);
else q.IncludeTerms.Add(cleaned);
}
return q;
}
private static bool TryExt(string token, out string ext)
{
ext = string.Empty;
if (!TryPrefix(token.AsSpan(), "ext:", out var v)) return false;
ext = v.Trim().TrimStart('.').ToLowerInvariant();
return ext.Length > 0;
}
private static bool TryPrefix(ReadOnlySpan<char> token, string prefix, out string value)
{
value = string.Empty;
if (token.Length <= prefix.Length) return false;
if (!token.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
value = token[prefix.Length..].ToString();
return true;
}
private static void ParseSize(string value, SearchQuery q)
{
value = value.Trim();
if (value.Length == 0) return;
var op = '=';
if (value[0] is '>' or '<' or '=') { op = value[0]; value = value[1..]; }
else if (value.StartsWith(">=", StringComparison.Ordinal)) { op = '>'; value = value[2..]; }
else if (value.StartsWith("<=", StringComparison.Ordinal)) { op = '<'; value = value[2..]; }
double mul = 1;
var lower = value.ToLowerInvariant();
foreach (var (suffix, factor) in SizeSuffixes)
{
if (lower.EndsWith(suffix, StringComparison.Ordinal))
{
mul = factor;
value = value[..^suffix.Length];
break;
}
}
if (!double.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var num)) return;
var bytes = (long)(num * mul);
switch (op)
{
case '>': q.MinSize = bytes; break;
case '<': q.MaxSize = bytes; break;
default: q.MinSize = bytes; q.MaxSize = bytes; break;
}
}
private static readonly (string, double)[] SizeSuffixes =
[
("kb", 1024d), ("mb", 1024d * 1024), ("gb", 1024d * 1024 * 1024), ("tb", 1024d * 1024 * 1024 * 1024),
("k", 1024d), ("m", 1024d * 1024), ("g", 1024d * 1024 * 1024), ("b", 1d)
];
private static bool TryParseDate(string value, out DateTime date)
{
date = default;
var v = value.Trim().ToLowerInvariant();
var now = DateTime.Now;
switch (v)
{
case "today": date = now.Date; return true;
case "yesterday": date = now.Date.AddDays(-1); return true;
case "thisweek": date = now.Date.AddDays(-(int)now.DayOfWeek); return true;
case "thismonth": date = new DateTime(now.Year, now.Month, 1); return true;
case "thisyear": date = new DateTime(now.Year, 1, 1); return true;
}
if (v.EndsWith('d') && int.TryParse(v[..^1], out var days)) { date = now.AddDays(-days); return true; }
if (v.EndsWith('h') && int.TryParse(v[..^1], out var hours)) { date = now.AddHours(-hours); return true; }
if (v.EndsWith('w') && int.TryParse(v[..^1], out var weeks)) { date = now.AddDays(-7 * weeks); return true; }
return DateTime.TryParse(v, CultureInfo.CurrentCulture, DateTimeStyles.None, out date);
}
/// <summary>按空格切词,但保留引号内的空格。</summary>
private static IEnumerable<string> Tokenize(string text)
{
var sb = new StringBuilder();
bool inQuotes = false;
foreach (var ch in text)
{
if (ch == '"')
{
inQuotes = !inQuotes;
sb.Append(ch);
continue;
}
if (!inQuotes && char.IsWhiteSpace(ch))
{
if (sb.Length > 0) { yield return sb.ToString(); sb.Clear(); }
continue;
}
sb.Append(ch);
}
if (sb.Length > 0) yield return sb.ToString();
}
}