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);
+104 -4
View File
@@ -1,4 +1,6 @@
using System.Reflection;
using System.Text;
using System.Text.Json;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Validation;
@@ -26,6 +28,105 @@ public class FileTypesTests
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]
public void OnlyFiltersWithReadableTextLookAtTheContent()
{
@@ -46,14 +147,13 @@ public class FileTypesTests
[Test]
public void ReadableTextDoesNotChangeExtensionFilters()
{
var combinedFilter = FileTypes.AsOneFileType(FileTypes.JSON, FileTypes.READABLE_TEXT);
var combinedFilter = FileTypes.ToFileDialogFilter(FileTypes.JSON, FileTypes.READABLE_TEXT);
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!.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.DOCUMENT.Children, Does.Not.Contain(FileTypes.READABLE_TEXT));
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);