Fixed the file filters of the native file dialog for nested file types (#1079)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-17 20:56:52 +02:00
1 parent 274d718314
commit 055cce80a0
9 files changed
+193 -81

No files matched your search

@@ -0,0 +1,13 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// The file type filter of a native file dialog, as the runtime takes it.
/// </summary>
/// <remarks>
/// This mirrors the struct FileTypeFilter in runtime/src/file_actions.rs, which knows one name and
/// one flat list of extensions. A tree of file types never reaches the runtime: FileTypes.ToFileDialogFilter
/// flattens it first, because the runtime would drop the children.
/// </remarks>
/// <param name="FilterName">The name of the filter, as some file dialogs show it.</param>
/// <param name="FilterExtensions">The file extensions to show, without dot.</param>
public sealed record FileDialogFilter(string FilterName, string[] FilterExtensions);
@@ -1,41 +0,0 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// Represents a file type that can optionally contain child file types.
/// Use the static helpers <see cref="Leaf"/>, <see cref="Parent"/> and <see cref="Composite"/> to build readable trees.
/// </summary>
/// <param name="FilterName">Display name of the type (e.g., "Document").</param>
/// <param name="FilterExtensions">File extensions belonging to this type (without dot).</param>
/// <param name="Children">Nested file types that are included when this type is selected.</param>
public sealed record FileType(string FilterName, string[] FilterExtensions, IReadOnlyList<FileType> Children)
{
/// <summary>
/// Factory for a leaf node.
/// Example: <c>FileType.Leaf(".NET", "cs", "razor")</c>
/// </summary>
public static FileType Leaf(string name, params string[] extensions) =>
new(name, extensions, []);
/// <summary>
/// Factory for a parent node that only has children.
/// Example: <c>FileType.Parent("Source Code", dotnet, java)</c>
/// </summary>
public static FileType Parent(string name, params FileType[]? children) =>
new(name, [], children ?? []);
/// <summary>
/// Factory for a composite node that has its own extensions in addition to children.
/// </summary>
public static FileType Composite(string name, string[] extensions, params FileType[] children) =>
new(name, extensions, children);
/// <summary>
/// Collects all extensions for this type, including children.
/// </summary>
public IEnumerable<string> FlattenExtensions()
{
return this.FilterExtensions
.Concat(this.Children.SelectMany(child => child.FlattenExtensions()))
.Distinct(StringComparer.OrdinalIgnoreCase);
}
}
@@ -2,43 +2,75 @@ namespace AIStudio.Tools.Rust;
/// <summary>
/// Represents a file type that can optionally contain child file types.
/// Use the static helpers <see cref="Leaf"/>, <see cref="Parent"/> and <see cref="Composite"/> to build readable trees.
/// Use the static helpers Leaf, Parent, Composite, ByFileName, and ByFileNamePrefix to build readable trees.
/// </summary>
/// <remarks>
/// File names are kept apart from the extensions: a native file picker filters by extension only, and
/// a file name such as Dockerfile is no extension of any file.
/// </remarks>
/// <param name="FilterName">Display name of the type (e.g., "Document").</param>
/// <param name="FilterExtensions">File extensions belonging to this type (without dot).</param>
/// <param name="Children">Nested file types that are included when this type is selected.</param>
public sealed record FileTypeFilter(string FilterName, string[] FilterExtensions, IReadOnlyList<FileTypeFilter> Children)
/// <param name="FileNames">Complete file names belonging to this type (e.g., "Makefile").</param>
/// <param name="FileNamePrefixes">Beginnings of file names belonging to this type (e.g., "Dockerfile" for "Dockerfile.development").</param>
public sealed record FileTypeFilter(string FilterName, string[] FilterExtensions, IReadOnlyList<FileTypeFilter> Children, string[] FileNames, string[] FileNamePrefixes)
{
/// <summary>
/// Factory for a leaf node.
/// Example: <c>FileType.Leaf(".NET", "cs", "razor")</c>
/// Example: <c>FileTypeFilter.Leaf(".NET", "cs", "razor")</c>
/// </summary>
public static FileTypeFilter Leaf(string name, params string[] extensions) =>
new(name, extensions, []);
new(name, extensions, [], [], []);
/// <summary>
/// Factory for a parent node that only has children.
/// Example: <c>FileType.Parent("Source Code", dotnet, java)</c>
/// Example: <c>FileTypeFilter.Parent("Source Code", dotnet, java)</c>
/// </summary>
public static FileTypeFilter Parent(string name, params FileTypeFilter[]? children) =>
new(name, [], children ?? []);
new(name, [], children ?? [], [], []);
/// <summary>
/// Factory for a composite node that has its own extensions in addition to children.
/// </summary>
public static FileTypeFilter Composite(string name, string[] extensions, params FileTypeFilter[] children) =>
new(name, extensions, children);
new(name, extensions, children, [], []);
/// <summary>
/// Factory for a leaf node that matches complete file names instead of extensions.
/// Example: <c>FileTypeFilter.ByFileName("Build files", "Makefile", "BUILD")</c>
/// </summary>
public static FileTypeFilter ByFileName(string name, params string[] fileNames) =>
new(name, [], [], fileNames, []);
/// <summary>
/// Factory for a leaf node that matches the beginning of file names instead of extensions.
/// Example: <c>FileTypeFilter.ByFileNamePrefix("Container files", "Dockerfile", "Containerfile")</c>
/// </summary>
public static FileTypeFilter ByFileNamePrefix(string name, params string[] prefixes) =>
new(name, [], [], [], prefixes);
/// <summary>
/// Collects all extensions for this type, including children.
/// </summary>
public IEnumerable<string> FlattenExtensions()
public IEnumerable<string> FlattenExtensions() => this.Flatten(type => type.FilterExtensions);
/// <summary>
/// Collects all complete file names for this type, including children.
/// </summary>
public IEnumerable<string> FlattenFileNames() => this.Flatten(type => type.FileNames);
/// <summary>
/// Collects all file name prefixes for this type, including children.
/// </summary>
public IEnumerable<string> FlattenFileNamePrefixes() => this.Flatten(type => type.FileNamePrefixes);
private IEnumerable<string> Flatten(Func<FileTypeFilter, string[]> select)
{
return this.FilterExtensions
.Concat(this.Children.SelectMany(child => child.FlattenExtensions()))
return select(this)
.Concat(this.Children.SelectMany(child => child.Flatten(select)))
.Distinct(StringComparer.OrdinalIgnoreCase);
}
public bool ContainsType(FileTypeFilter target)
{
if (this == target)
+26 -18
View File
@@ -13,12 +13,12 @@ public static class FileTypes
{
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(FileTypes).Namespace, nameof(FileTypes));
public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAMES = FileTypeFilter.Leaf(TB("Source like"),
public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAMES = FileTypeFilter.ByFileName(TB("Source like"),
"Dockerfile", "Containerfile", "Jenkinsfile", "Makefile", "GNUmakefile", "Procfile", "Vagrantfile",
"Tiltfile", "Justfile", "Brewfile", "Caddyfile", "Gemfile", "Podfile", "Fastfile", "Appfile", "Rakefile", "Dangerfile",
"BUILD", "WORKSPACE", "BUCK");
public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAME_PREFIXES = FileTypeFilter.Leaf(TB("Source like prefix"),
public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAME_PREFIXES = FileTypeFilter.ByFileNamePrefix(TB("Source like prefix"),
"Dockerfile", "Containerfile", "Jenkinsfile", "Procfile", "Caddyfile");
// Source code hierarchy: SourceCode -> (.NET, Java, Python, Web, C/C++, Config, ...)
@@ -138,15 +138,29 @@ public static class FileTypes
/// <returns>True, when reading the file needs Pandoc.</returns>
public static bool RequiresPandoc(string filePath) => IsAllowedPath(filePath, PANDOC_CONVERTED);
public static FileTypeFilter? AsOneFileType(params FileTypeFilter[]? types)
/// <summary>
/// Turns the given file types into the one flat filter a native file dialog takes.
/// </summary>
/// <remarks>
/// The filter holds the extensions of all given types and of their children. It is named after
/// the type when there is only one. Files recognized by their name or content only, such as
/// Dockerfile or LICENSE, cannot be expressed by extension: the dialog does not offer them once
/// other types narrow the list, whereas drag and drop accepts them. When no extension remains at
/// all, there is no filter, the dialog shows every file, and the caller checks the selection
/// with IsAllowedPath.
/// </remarks>
/// <param name="types">The file types to show, or none for all files.</param>
/// <returns>The filter, or null when the dialog should show all files.</returns>
public static FileDialogFilter? ToFileDialogFilter(params FileTypeFilter[]? types)
{
if (types == null || types.Length == 0)
return null;
if (types.Length == 1)
return types[0];
return FileTypeFilter.Composite(TB("Custom"), OnlyAllowTypes(types));
var extensions = OnlyAllowTypes(types);
if (extensions.Length == 0)
return null;
return new FileDialogFilter(types.Length == 1 ? types[0].FilterName : TB("Custom"), extensions);
}
public static string[] OnlyAllowTypes(params FileTypeFilter[] types)
@@ -155,7 +169,6 @@ public static class FileTypes
return [];
return types
.Where(t => t != SOURCE_LIKE_FILE_NAMES && t != SOURCE_LIKE_FILE_NAME_PREFIXES)
.SelectMany(t => t.FlattenExtensions())
.Select(ext => ext.ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
@@ -192,16 +205,11 @@ public static class FileTypes
if (string.IsNullOrWhiteSpace(fileName))
return false;
if (types.Any(t => t.ContainsType(SOURCE_LIKE_FILE_NAMES)))
{
if (SOURCE_LIKE_FILE_NAMES.FilterExtensions.Contains(fileName, StringComparer.OrdinalIgnoreCase))
return true;
}
if (types.Any(t => t.ContainsType(SOURCE_LIKE_FILE_NAME_PREFIXES))){
if (SOURCE_LIKE_FILE_NAME_PREFIXES.FilterExtensions.Any(prefix => fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
return true;
}
if (types.SelectMany(t => t.FlattenFileNames()).Contains(fileName, StringComparer.OrdinalIgnoreCase))
return true;
if (types.SelectMany(t => t.FlattenFileNamePrefixes()).Any(prefix => fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
return true;
if (types.Any(type => type.ContainsType(READABLE_TEXT)) && ReadableTextDetection.IsReadableTextFile(filePath))
return true;
@@ -6,5 +6,5 @@ public class SaveFileOptions
public PreviousFile? PreviousFile { get; init; }
public FileTypeFilter? Filter { get; init; }
public FileDialogFilter? Filter { get; init; }
}
@@ -6,5 +6,5 @@ public sealed class SelectFileOptions
public PreviousFile? PreviousFile { get; init; }
public FileTypeFilter? Filter { get; init; }
public FileDialogFilter? Filter { get; init; }
}
@@ -77,7 +77,7 @@ public sealed class FileEnumerationResult
return extension;
var fileName = Path.GetFileName(filePath);
return FileTypes.SOURCE_LIKE_FILE_NAMES.FilterExtensions
return FileTypes.SOURCE_LIKE_FILE_NAMES.FileNames
.FirstOrDefault(knownFileName => string.Equals(knownFileName, fileName, StringComparison.OrdinalIgnoreCase))
?? string.Empty;
}
@@ -58,7 +58,7 @@ public sealed partial class RustService
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = FileTypes.AsOneFileType(filter)
Filter = FileTypes.ToFileDialogFilter(filter)
};
var result = await this.http.PostAsJsonAsync("/select/file", payload, this.jsonRustSerializerOptions);
@@ -81,7 +81,7 @@ public sealed partial class RustService
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = FileTypes.AsOneFileType(filter)
Filter = FileTypes.ToFileDialogFilter(filter)
};
var result = await this.http.PostAsJsonAsync("/select/files", payload, this.jsonRustSerializerOptions);
@@ -112,7 +112,7 @@ public sealed partial class RustService
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = FileTypes.AsOneFileType(filter)
Filter = FileTypes.ToFileDialogFilter(filter)
};
var result = await this.http.PostAsJsonAsync("/save/file", payload, this.jsonRustSerializerOptions);