From 055cce80a048b50c0ff212620b9e096de602fb93 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 17 Oct 2026 20:56:52 +0200 Subject: [PATCH] Fixed the file filters of the native file dialog for nested file types (#1079) --- .../Tools/Rust/FileDialogFilter.cs | 13 +++ app/MindWork AI Studio/Tools/Rust/FileType.cs | 41 ------- .../Tools/Rust/FileTypeFilter.cs | 56 +++++++-- .../Tools/Rust/FileTypes.cs | 44 ++++--- .../Tools/Rust/SaveFileOptions.cs | 2 +- .../Tools/Rust/SelectFileOptions.cs | 2 +- .../Tools/Services/FileEnumerationResult.cs | 2 +- .../Tools/Services/RustService.FileSystem.cs | 6 +- app/Tests/Tools/FileTypesTests.cs | 108 +++++++++++++++++- 9 files changed, 193 insertions(+), 81 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/Rust/FileDialogFilter.cs delete mode 100644 app/MindWork AI Studio/Tools/Rust/FileType.cs diff --git a/app/MindWork AI Studio/Tools/Rust/FileDialogFilter.cs b/app/MindWork AI Studio/Tools/Rust/FileDialogFilter.cs new file mode 100644 index 000000000..c537e4a8f --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/FileDialogFilter.cs @@ -0,0 +1,13 @@ +namespace AIStudio.Tools.Rust; + +/// +/// The file type filter of a native file dialog, as the runtime takes it. +/// +/// +/// 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. +/// +/// The name of the filter, as some file dialogs show it. +/// The file extensions to show, without dot. +public sealed record FileDialogFilter(string FilterName, string[] FilterExtensions); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/FileType.cs b/app/MindWork AI Studio/Tools/Rust/FileType.cs deleted file mode 100644 index c333a6913..000000000 --- a/app/MindWork AI Studio/Tools/Rust/FileType.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace AIStudio.Tools.Rust; - -/// -/// Represents a file type that can optionally contain child file types. -/// Use the static helpers , and to build readable trees. -/// -/// Display name of the type (e.g., "Document"). -/// File extensions belonging to this type (without dot). -/// Nested file types that are included when this type is selected. -public sealed record FileType(string FilterName, string[] FilterExtensions, IReadOnlyList Children) -{ - /// - /// Factory for a leaf node. - /// Example: FileType.Leaf(".NET", "cs", "razor") - /// - public static FileType Leaf(string name, params string[] extensions) => - new(name, extensions, []); - - /// - /// Factory for a parent node that only has children. - /// Example: FileType.Parent("Source Code", dotnet, java) - /// - public static FileType Parent(string name, params FileType[]? children) => - new(name, [], children ?? []); - - /// - /// Factory for a composite node that has its own extensions in addition to children. - /// - public static FileType Composite(string name, string[] extensions, params FileType[] children) => - new(name, extensions, children); - - /// - /// Collects all extensions for this type, including children. - /// - public IEnumerable FlattenExtensions() - { - return this.FilterExtensions - .Concat(this.Children.SelectMany(child => child.FlattenExtensions())) - .Distinct(StringComparer.OrdinalIgnoreCase); - } -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/FileTypeFilter.cs b/app/MindWork AI Studio/Tools/Rust/FileTypeFilter.cs index f4cd1c7e5..bf3202124 100644 --- a/app/MindWork AI Studio/Tools/Rust/FileTypeFilter.cs +++ b/app/MindWork AI Studio/Tools/Rust/FileTypeFilter.cs @@ -2,43 +2,75 @@ namespace AIStudio.Tools.Rust; /// /// Represents a file type that can optionally contain child file types. -/// Use the static helpers , and to build readable trees. +/// Use the static helpers Leaf, Parent, Composite, ByFileName, and ByFileNamePrefix to build readable trees. /// +/// +/// 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. +/// /// Display name of the type (e.g., "Document"). /// File extensions belonging to this type (without dot). /// Nested file types that are included when this type is selected. -public sealed record FileTypeFilter(string FilterName, string[] FilterExtensions, IReadOnlyList Children) +/// Complete file names belonging to this type (e.g., "Makefile"). +/// Beginnings of file names belonging to this type (e.g., "Dockerfile" for "Dockerfile.development"). +public sealed record FileTypeFilter(string FilterName, string[] FilterExtensions, IReadOnlyList Children, string[] FileNames, string[] FileNamePrefixes) { /// /// Factory for a leaf node. - /// Example: FileType.Leaf(".NET", "cs", "razor") + /// Example: FileTypeFilter.Leaf(".NET", "cs", "razor") /// public static FileTypeFilter Leaf(string name, params string[] extensions) => - new(name, extensions, []); + new(name, extensions, [], [], []); /// /// Factory for a parent node that only has children. - /// Example: FileType.Parent("Source Code", dotnet, java) + /// Example: FileTypeFilter.Parent("Source Code", dotnet, java) /// public static FileTypeFilter Parent(string name, params FileTypeFilter[]? children) => - new(name, [], children ?? []); + new(name, [], children ?? [], [], []); /// /// Factory for a composite node that has its own extensions in addition to children. /// public static FileTypeFilter Composite(string name, string[] extensions, params FileTypeFilter[] children) => - new(name, extensions, children); - + new(name, extensions, children, [], []); + + /// + /// Factory for a leaf node that matches complete file names instead of extensions. + /// Example: FileTypeFilter.ByFileName("Build files", "Makefile", "BUILD") + /// + public static FileTypeFilter ByFileName(string name, params string[] fileNames) => + new(name, [], [], fileNames, []); + + /// + /// Factory for a leaf node that matches the beginning of file names instead of extensions. + /// Example: FileTypeFilter.ByFileNamePrefix("Container files", "Dockerfile", "Containerfile") + /// + public static FileTypeFilter ByFileNamePrefix(string name, params string[] prefixes) => + new(name, [], [], [], prefixes); + /// /// Collects all extensions for this type, including children. /// - public IEnumerable FlattenExtensions() + public IEnumerable FlattenExtensions() => this.Flatten(type => type.FilterExtensions); + + /// + /// Collects all complete file names for this type, including children. + /// + public IEnumerable FlattenFileNames() => this.Flatten(type => type.FileNames); + + /// + /// Collects all file name prefixes for this type, including children. + /// + public IEnumerable FlattenFileNamePrefixes() => this.Flatten(type => type.FileNamePrefixes); + + private IEnumerable Flatten(Func 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) diff --git a/app/MindWork AI Studio/Tools/Rust/FileTypes.cs b/app/MindWork AI Studio/Tools/Rust/FileTypes.cs index d69e54362..75560e361 100644 --- a/app/MindWork AI Studio/Tools/Rust/FileTypes.cs +++ b/app/MindWork AI Studio/Tools/Rust/FileTypes.cs @@ -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 /// True, when reading the file needs Pandoc. public static bool RequiresPandoc(string filePath) => IsAllowedPath(filePath, PANDOC_CONVERTED); - public static FileTypeFilter? AsOneFileType(params FileTypeFilter[]? types) + /// + /// Turns the given file types into the one flat filter a native file dialog takes. + /// + /// + /// 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. + /// + /// The file types to show, or none for all files. + /// The filter, or null when the dialog should show all files. + 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; diff --git a/app/MindWork AI Studio/Tools/Rust/SaveFileOptions.cs b/app/MindWork AI Studio/Tools/Rust/SaveFileOptions.cs index 107e581a7..8e437dbc6 100644 --- a/app/MindWork AI Studio/Tools/Rust/SaveFileOptions.cs +++ b/app/MindWork AI Studio/Tools/Rust/SaveFileOptions.cs @@ -6,5 +6,5 @@ public class SaveFileOptions public PreviousFile? PreviousFile { get; init; } - public FileTypeFilter? Filter { get; init; } + public FileDialogFilter? Filter { get; init; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/SelectFileOptions.cs b/app/MindWork AI Studio/Tools/Rust/SelectFileOptions.cs index 28d16809a..e359d279e 100644 --- a/app/MindWork AI Studio/Tools/Rust/SelectFileOptions.cs +++ b/app/MindWork AI Studio/Tools/Rust/SelectFileOptions.cs @@ -6,5 +6,5 @@ public sealed class SelectFileOptions public PreviousFile? PreviousFile { get; init; } - public FileTypeFilter? Filter { get; init; } + public FileDialogFilter? Filter { get; init; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/FileEnumerationResult.cs b/app/MindWork AI Studio/Tools/Services/FileEnumerationResult.cs index ecbb2f86e..fc4b5215e 100644 --- a/app/MindWork AI Studio/Tools/Services/FileEnumerationResult.cs +++ b/app/MindWork AI Studio/Tools/Services/FileEnumerationResult.cs @@ -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; } diff --git a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs index c5a662332..4ac56cf8d 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs @@ -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); diff --git a/app/Tests/Tools/FileTypesTests.cs b/app/Tests/Tools/FileTypesTests.cs index 8602973b8..954562bb0 100644 --- a/app/Tests/Tools/FileTypesTests.cs +++ b/app/Tests/Tools/FileTypesTests.cs @@ -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(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);