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
+187 -75

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,40 +2,72 @@ namespace AIStudio.Tools.Rust;
/// <summary> /// <summary>
/// Represents a file type that can optionally contain child file types. /// 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> /// </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="FilterName">Display name of the type (e.g., "Document").</param>
/// <param name="FilterExtensions">File extensions belonging to this type (without dot).</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> /// <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> /// <summary>
/// Factory for a leaf node. /// Factory for a leaf node.
/// Example: <c>FileType.Leaf(".NET", "cs", "razor")</c> /// Example: <c>FileTypeFilter.Leaf(".NET", "cs", "razor")</c>
/// </summary> /// </summary>
public static FileTypeFilter Leaf(string name, params string[] extensions) => public static FileTypeFilter Leaf(string name, params string[] extensions) =>
new(name, extensions, []); new(name, extensions, [], [], []);
/// <summary> /// <summary>
/// Factory for a parent node that only has children. /// 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> /// </summary>
public static FileTypeFilter Parent(string name, params FileTypeFilter[]? children) => public static FileTypeFilter Parent(string name, params FileTypeFilter[]? children) =>
new(name, [], children ?? []); new(name, [], children ?? [], [], []);
/// <summary> /// <summary>
/// Factory for a composite node that has its own extensions in addition to children. /// Factory for a composite node that has its own extensions in addition to children.
/// </summary> /// </summary>
public static FileTypeFilter Composite(string name, string[] extensions, params FileTypeFilter[] children) => 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> /// <summary>
/// Collects all extensions for this type, including children. /// Collects all extensions for this type, including children.
/// </summary> /// </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 return select(this)
.Concat(this.Children.SelectMany(child => child.FlattenExtensions())) .Concat(this.Children.SelectMany(child => child.Flatten(select)))
.Distinct(StringComparer.OrdinalIgnoreCase); .Distinct(StringComparer.OrdinalIgnoreCase);
} }
+22 -14
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)); 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", "Dockerfile", "Containerfile", "Jenkinsfile", "Makefile", "GNUmakefile", "Procfile", "Vagrantfile",
"Tiltfile", "Justfile", "Brewfile", "Caddyfile", "Gemfile", "Podfile", "Fastfile", "Appfile", "Rakefile", "Dangerfile", "Tiltfile", "Justfile", "Brewfile", "Caddyfile", "Gemfile", "Podfile", "Fastfile", "Appfile", "Rakefile", "Dangerfile",
"BUILD", "WORKSPACE", "BUCK"); "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"); "Dockerfile", "Containerfile", "Jenkinsfile", "Procfile", "Caddyfile");
// Source code hierarchy: SourceCode -> (.NET, Java, Python, Web, C/C++, Config, ...) // 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> /// <returns>True, when reading the file needs Pandoc.</returns>
public static bool RequiresPandoc(string filePath) => IsAllowedPath(filePath, PANDOC_CONVERTED); 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) if (types == null || types.Length == 0)
return null; return null;
if (types.Length == 1) var extensions = OnlyAllowTypes(types);
return types[0]; if (extensions.Length == 0)
return null;
return FileTypeFilter.Composite(TB("Custom"), OnlyAllowTypes(types)); return new FileDialogFilter(types.Length == 1 ? types[0].FilterName : TB("Custom"), extensions);
} }
public static string[] OnlyAllowTypes(params FileTypeFilter[] types) public static string[] OnlyAllowTypes(params FileTypeFilter[] types)
@@ -155,7 +169,6 @@ public static class FileTypes
return []; return [];
return types return types
.Where(t => t != SOURCE_LIKE_FILE_NAMES && t != SOURCE_LIKE_FILE_NAME_PREFIXES)
.SelectMany(t => t.FlattenExtensions()) .SelectMany(t => t.FlattenExtensions())
.Select(ext => ext.ToLowerInvariant()) .Select(ext => ext.ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
@@ -192,16 +205,11 @@ public static class FileTypes
if (string.IsNullOrWhiteSpace(fileName)) if (string.IsNullOrWhiteSpace(fileName))
return false; return false;
if (types.Any(t => t.ContainsType(SOURCE_LIKE_FILE_NAMES))) if (types.SelectMany(t => t.FlattenFileNames()).Contains(fileName, StringComparer.OrdinalIgnoreCase))
{
if (SOURCE_LIKE_FILE_NAMES.FilterExtensions.Contains(fileName, StringComparer.OrdinalIgnoreCase))
return true; return true;
}
if (types.Any(t => t.ContainsType(SOURCE_LIKE_FILE_NAME_PREFIXES))){ if (types.SelectMany(t => t.FlattenFileNamePrefixes()).Any(prefix => fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
if (SOURCE_LIKE_FILE_NAME_PREFIXES.FilterExtensions.Any(prefix => fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
return true; return true;
}
if (types.Any(type => type.ContainsType(READABLE_TEXT)) && ReadableTextDetection.IsReadableTextFile(filePath)) if (types.Any(type => type.ContainsType(READABLE_TEXT)) && ReadableTextDetection.IsReadableTextFile(filePath))
return true; return true;
@@ -6,5 +6,5 @@ public class SaveFileOptions
public PreviousFile? PreviousFile { get; init; } 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 PreviousFile? PreviousFile { get; init; }
public FileTypeFilter? Filter { get; init; } public FileDialogFilter? Filter { get; init; }
} }
@@ -77,7 +77,7 @@ public sealed class FileEnumerationResult
return extension; return extension;
var fileName = Path.GetFileName(filePath); 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)) .FirstOrDefault(knownFileName => string.Equals(knownFileName, fileName, StringComparison.OrdinalIgnoreCase))
?? string.Empty; ?? string.Empty;
} }
@@ -58,7 +58,7 @@ public sealed partial class RustService
{ {
Title = title, Title = title,
PreviousFile = initialFile is null ? null : new (initialFile), 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); var result = await this.http.PostAsJsonAsync("/select/file", payload, this.jsonRustSerializerOptions);
@@ -81,7 +81,7 @@ public sealed partial class RustService
{ {
Title = title, Title = title,
PreviousFile = initialFile is null ? null : new (initialFile), 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); var result = await this.http.PostAsJsonAsync("/select/files", payload, this.jsonRustSerializerOptions);
@@ -112,7 +112,7 @@ public sealed partial class RustService
{ {
Title = title, Title = title,
PreviousFile = initialFile is null ? null : new (initialFile), 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); var result = await this.http.PostAsJsonAsync("/save/file", payload, this.jsonRustSerializerOptions);
+104 -4
View File
@@ -1,4 +1,6 @@
using System.Reflection;
using System.Text; using System.Text;
using System.Text.Json;
using AIStudio.Tools.Rust; using AIStudio.Tools.Rust;
using AIStudio.Tools.Validation; using AIStudio.Tools.Validation;
@@ -26,6 +28,105 @@ public class FileTypesTests
Assert.That(FileTypes.IsAllowedPath(path, FileTypes.DOCUMENT), Is.True); Assert.That(FileTypes.IsAllowedPath(path, FileTypes.DOCUMENT), Is.True);
} }
[TestCase("build")]
[TestCase("dockerfile")]
[TestCase("makefile")]
[TestCase("workspace")]
public void FileNamesAreNoExtensions(string extension)
{
Assert.Multiple(() =>
{
Assert.That(FileTypes.IsAllowedExtension(extension, FileTypes.DOCUMENT), Is.False);
Assert.That(FileTypes.IsAllowedExtension(extension, FileTypes.SOURCE_CODE), Is.False);
});
}
[Test]
public void FlattenedExtensionsHoldNoFileNames()
{
var fileNames = FileTypes.SOURCE_LIKE_FILE_NAMES.FileNames.Concat(FileTypes.SOURCE_LIKE_FILE_NAME_PREFIXES.FileNamePrefixes);
Assert.That(FileTypes.OnlyAllowTypes(FileTypes.DOCUMENT).Intersect(fileNames, StringComparer.OrdinalIgnoreCase), Is.Empty);
}
[Test]
public void EveryExtensionIsLowercaseWithoutDot()
{
var extensions = typeof(FileTypes)
.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
.Where(field => field.FieldType == typeof(FileTypeFilter))
.SelectMany(field => ((FileTypeFilter)field.GetValue(null)!).FlattenExtensions())
.ToArray();
Assert.That(extensions, Is.Not.Empty);
Assert.That(extensions, Has.All.Matches<string>(extension => extension == extension.ToLowerInvariant() && !extension.Contains('.')));
}
[Test]
public void DialogFilterOfAParentHoldsTheExtensionsOfItsChildren()
{
var filter = FileTypes.ToFileDialogFilter(FileTypes.DOCUMENT);
var fileNames = FileTypes.SOURCE_LIKE_FILE_NAMES.FileNames.Concat(FileTypes.SOURCE_LIKE_FILE_NAME_PREFIXES.FileNamePrefixes);
Assert.That(filter, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(filter!.FilterName, Is.EqualTo(FileTypes.DOCUMENT.FilterName));
Assert.That(filter.FilterExtensions, Is.SupersetOf(new[] { "pdf", "docx", "xlsx", "cs", "csv", "txt", "tex" }));
Assert.That(filter.FilterExtensions.Intersect(fileNames, StringComparer.OrdinalIgnoreCase), Is.Empty);
});
}
[Test]
public void DialogFilterOfTextHoldsOnlyItsExtensions()
{
var filter = FileTypes.ToFileDialogFilter(FileTypes.TEXT);
Assert.That(filter, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(filter!.FilterName, Is.EqualTo(FileTypes.TEXT.FilterName));
Assert.That(filter.FilterExtensions, Is.EquivalentTo(new[] { "txt", "md", "rtf" }));
});
}
[Test]
public void DialogFilterOfOneLeafKeepsItsName()
{
var filter = FileTypes.ToFileDialogFilter(FileTypes.JSON);
var combinedFilter = FileTypes.ToFileDialogFilter(FileTypes.JSON, FileTypes.CSV);
Assert.That(filter, Is.Not.Null);
Assert.That(combinedFilter, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(filter!.FilterName, Is.EqualTo("JSON"));
Assert.That(filter.FilterExtensions, Is.EqualTo(new[] { "json" }));
Assert.That(combinedFilter!.FilterExtensions, Is.EquivalentTo(new[] { "json", "csv" }));
});
}
[Test]
public void TypesWithoutExtensionsGiveNoDialogFilter()
{
Assert.Multiple(() =>
{
Assert.That(FileTypes.ToFileDialogFilter(FileTypes.SOURCE_LIKE_FILE_NAMES), Is.Null);
Assert.That(FileTypes.ToFileDialogFilter(FileTypes.SOURCE_LIKE_FILE_NAME_PREFIXES), Is.Null);
Assert.That(FileTypes.ToFileDialogFilter(), Is.Null);
Assert.That(FileTypes.ToFileDialogFilter(null), Is.Null);
});
}
[Test]
public void DialogFilterMatchesTheRuntimeContract()
{
// The struct FileTypeFilter in runtime/src/file_actions.rs knows exactly these two fields:
var json = JsonSerializer.Serialize(FileTypes.ToFileDialogFilter(FileTypes.JSON), new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower });
Assert.That(json, Is.EqualTo("""{"filter_name":"JSON","filter_extensions":["json"]}"""));
}
[Test] [Test]
public void OnlyFiltersWithReadableTextLookAtTheContent() public void OnlyFiltersWithReadableTextLookAtTheContent()
{ {
@@ -46,14 +147,13 @@ public class FileTypesTests
[Test] [Test]
public void ReadableTextDoesNotChangeExtensionFilters() public void ReadableTextDoesNotChangeExtensionFilters()
{ {
var combinedFilter = FileTypes.AsOneFileType(FileTypes.JSON, FileTypes.READABLE_TEXT); var combinedFilter = FileTypes.ToFileDialogFilter(FileTypes.JSON, FileTypes.READABLE_TEXT);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(FileTypes.AsOneFileType(FileTypes.READABLE_TEXT), Is.SameAs(FileTypes.READABLE_TEXT)); Assert.That(FileTypes.ToFileDialogFilter(FileTypes.READABLE_TEXT), Is.Null);
Assert.That(combinedFilter, Is.Not.Null); Assert.That(combinedFilter, Is.Not.Null);
Assert.That(combinedFilter!.FilterExtensions, Is.EqualTo(new[] { "json" })); Assert.That(combinedFilter!.FilterExtensions, Is.EqualTo(new[] { "json" }));
Assert.That(FileTypes.AsOneFileType(FileTypes.JSON), Is.SameAs(FileTypes.JSON));
}); });
} }
@@ -66,7 +166,7 @@ public class FileTypesTests
Assert.That(FileTypes.TEXT.ContainsType(FileTypes.READABLE_TEXT), Is.True); Assert.That(FileTypes.TEXT.ContainsType(FileTypes.READABLE_TEXT), Is.True);
Assert.That(FileTypes.DOCUMENT.Children, Does.Not.Contain(FileTypes.READABLE_TEXT)); Assert.That(FileTypes.DOCUMENT.Children, Does.Not.Contain(FileTypes.READABLE_TEXT));
Assert.That(FileTypes.DOCUMENT.ContainsType(FileTypes.READABLE_TEXT), Is.True); Assert.That(FileTypes.DOCUMENT.ContainsType(FileTypes.READABLE_TEXT), Is.True);
Assert.That(FileTypes.AsOneFileType(FileTypes.DOCUMENT), Is.Not.Null); Assert.That(FileTypes.ToFileDialogFilter(FileTypes.DOCUMENT), Is.Not.Null);
}); });
AssertPath("resource \"example\" \"main\" {}", "infrastructure.tf", Encoding.UTF8, true, FileTypes.DOCUMENT); AssertPath("resource \"example\" \"main\" {}", "infrastructure.tf", Encoding.UTF8, true, FileTypes.DOCUMENT);