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);