Browse Source

Add Impl for SaveFilePickerWithResultAsync API (#19783)

* Add new SaveFilePickerWithResultAsync API to the base

* Add SaveFilePickerWithResultAsync stub impl

* Control Catalog SaveFilePickerWithResultAsync sample with XML and JSON types

* Make SaveFilePickerResult a struct

* Add managed picker implementation

* Make SaveFilePickerResult a readonly struct

* Windows implementation of SaveFilePickerWithResultAsync

* Test impl for dbus

* Reuse the file type object (FTO) so StorageFile consumer can match exactly the right FTO when receiving the SaveFilePicker's result.

* Add Gtk impl

* Avalonia.Native: surface selected save dialog filters

* macOS: report selected NSSavePanel filter

* Modify the conditional in case there's duplicate descriptions of FTO's in DBusSystemDialog.cs

* Instantiate FPFT as fallback

* Pass the mime/pattern to instantiated FPFT

* Update API diff

* Fix review comments

---------

Co-authored-by: Max Katz <[email protected]>
Jumar Macato 6 days ago
parent
commit
2c540873ec
25 changed files with 389 additions and 102 deletions
  1. 38 2
      api/Avalonia.nupkg.xml
  2. 12 2
      native/Avalonia.Native/src/OSX/StorageProvider.mm
  3. 1 0
      samples/ControlCatalog/Pages/DialogsPage.xaml
  4. 51 4
      samples/ControlCatalog/Pages/DialogsPage.xaml.cs
  5. 6 0
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs
  6. 5 1
      src/Avalonia.Base/Platform/Storage/FallbackStorageProvider.cs
  7. 1 0
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs
  8. 2 1
      src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs
  9. 5 7
      src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs
  10. 14 0
      src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs
  11. 6 0
      src/Avalonia.Base/Platform/Storage/IStorageProvider.cs
  12. 5 0
      src/Avalonia.Base/Platform/Storage/NoopStorageProvider.cs
  13. 22 0
      src/Avalonia.Base/Platform/Storage/SaveFilePickerResult.cs
  14. 5 0
      src/Avalonia.DesignerSupport/Remote/Stubs.cs
  15. 7 1
      src/Avalonia.Dialogs/Internal/ManagedFileChooserFilterViewModel.cs
  16. 4 2
      src/Avalonia.Dialogs/Internal/ManagedFileChooserViewModel.cs
  17. 13 0
      src/Avalonia.Dialogs/ManagedStorageProvider.cs
  18. 46 22
      src/Avalonia.FreeDesktop/DBusSystemDialog.cs
  19. 40 17
      src/Avalonia.Native/StorageProviderApi.cs
  20. 9 2
      src/Avalonia.Native/StorageProviderImpl.cs
  21. 1 0
      src/Avalonia.Native/avn.idl
  22. 48 17
      src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs
  23. 6 0
      src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs
  24. 36 24
      src/Windows/Avalonia.Win32/Win32StorageProvider.cs
  25. 6 0
      src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs

+ 38 - 2
api/Avalonia.nupkg.xml

@@ -1,6 +1,24 @@
-<?xml version="1.0" encoding="utf-8"?>
+<?xml version="1.0" encoding="utf-8"?>
 <!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
 <Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
+  <Suppression>
+    <DiagnosticId>CP0002</DiagnosticId>
+    <Target>M:Avalonia.Dialogs.Internal.ManagedFileChooserFilterViewModel.#ctor(Avalonia.Platform.Storage.FilePickerFileType)</Target>
+    <Left>baseline/Avalonia/lib/net6.0/Avalonia.Dialogs.dll</Left>
+    <Right>current/Avalonia/lib/net6.0/Avalonia.Dialogs.dll</Right>
+  </Suppression>
+  <Suppression>
+    <DiagnosticId>CP0002</DiagnosticId>
+    <Target>M:Avalonia.Dialogs.Internal.ManagedFileChooserFilterViewModel.#ctor(Avalonia.Platform.Storage.FilePickerFileType)</Target>
+    <Left>baseline/Avalonia/lib/net8.0/Avalonia.Dialogs.dll</Left>
+    <Right>current/Avalonia/lib/net8.0/Avalonia.Dialogs.dll</Right>
+  </Suppression>
+  <Suppression>
+    <DiagnosticId>CP0002</DiagnosticId>
+    <Target>M:Avalonia.Dialogs.Internal.ManagedFileChooserFilterViewModel.#ctor(Avalonia.Platform.Storage.FilePickerFileType)</Target>
+    <Left>baseline/Avalonia/lib/netstandard2.0/Avalonia.Dialogs.dll</Left>
+    <Right>current/Avalonia/lib/netstandard2.0/Avalonia.Dialogs.dll</Right>
+  </Suppression>
   <Suppression>
     <DiagnosticId>CP0006</DiagnosticId>
     <Target>M:Avalonia.Input.Platform.IClipboard.SetDataAsync(Avalonia.Input.IAsyncDataTransfer)</Target>
@@ -31,6 +49,12 @@
     <Left>baseline/Avalonia/lib/net6.0/Avalonia.Base.dll</Left>
     <Right>current/Avalonia/lib/net6.0/Avalonia.Base.dll</Right>
   </Suppression>
+  <Suppression>
+    <DiagnosticId>CP0006</DiagnosticId>
+    <Target>M:Avalonia.Platform.Storage.IStorageProvider.SaveFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerSaveOptions)</Target>
+    <Left>baseline/Avalonia/lib/net6.0/Avalonia.Base.dll</Left>
+    <Right>current/Avalonia/lib/net6.0/Avalonia.Base.dll</Right>
+  </Suppression>
   <Suppression>
     <DiagnosticId>CP0006</DiagnosticId>
     <Target>M:Avalonia.OpenGL.IGlExternalSemaphore.SignalTimelineSemaphore(Avalonia.OpenGL.IGlExternalImageTexture,System.UInt64)</Target>
@@ -79,6 +103,12 @@
     <Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
     <Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
   </Suppression>
+  <Suppression>
+    <DiagnosticId>CP0006</DiagnosticId>
+    <Target>M:Avalonia.Platform.Storage.IStorageProvider.SaveFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerSaveOptions)</Target>
+    <Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
+    <Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
+  </Suppression>
   <Suppression>
     <DiagnosticId>CP0006</DiagnosticId>
     <Target>M:Avalonia.OpenGL.IGlExternalSemaphore.SignalTimelineSemaphore(Avalonia.OpenGL.IGlExternalImageTexture,System.UInt64)</Target>
@@ -127,6 +157,12 @@
     <Left>baseline/Avalonia/lib/netstandard2.0/Avalonia.Base.dll</Left>
     <Right>current/Avalonia/lib/netstandard2.0/Avalonia.Base.dll</Right>
   </Suppression>
+  <Suppression>
+    <DiagnosticId>CP0006</DiagnosticId>
+    <Target>M:Avalonia.Platform.Storage.IStorageProvider.SaveFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerSaveOptions)</Target>
+    <Left>baseline/Avalonia/lib/netstandard2.0/Avalonia.Base.dll</Left>
+    <Right>current/Avalonia/lib/netstandard2.0/Avalonia.Base.dll</Right>
+  </Suppression>
   <Suppression>
     <DiagnosticId>CP0006</DiagnosticId>
     <Target>M:Avalonia.OpenGL.IGlExternalSemaphore.SignalTimelineSemaphore(Avalonia.OpenGL.IGlExternalImageTexture,System.UInt64)</Target>
@@ -145,4 +181,4 @@
     <Left>baseline/Avalonia/lib/netstandard2.0/Avalonia.OpenGL.dll</Left>
     <Right>current/Avalonia/lib/netstandard2.0/Avalonia.OpenGL.dll</Right>
   </Suppression>
-</Suppressions>
+</Suppressions>

+ 12 - 2
native/Avalonia.Native/src/OSX/StorageProvider.mm

@@ -320,12 +320,22 @@ public:
             }
             
             auto handler = ^(NSModalResponse result) {
+                int selectedIndex = -1;
+                if (panel.accessoryView != nil)
+                {
+                    auto popup = [panel.accessoryView viewWithTag:kFileTypePopupTag];
+                    if ([popup isKindOfClass:[NSPopUpButton class]])
+                    {
+                        selectedIndex = (int)[(NSPopUpButton*)popup indexOfSelectedItem];
+                    }
+                }
+
                 if(result == NSFileHandlingPanelOKButton)
                 {
                     auto url = [panel URL];
                     auto urls = [NSArray<NSURL*> arrayWithObject:url];
                     auto uriStrings = CreateAvnStringArray(urls);
-                    events->OnCompleted(uriStrings);
+                    events->OnCompletedWithFilter(uriStrings, selectedIndex);
 
                     [panel orderOut:panel];
                     
@@ -338,7 +348,7 @@ public:
                     return;
                 }
                 
-                events->OnCompleted(nullptr);
+                events->OnCompletedWithFilter(nullptr, selectedIndex);
                 
             };
             

+ 1 - 0
samples/ControlCatalog/Pages/DialogsPage.xaml

@@ -38,6 +38,7 @@
         <Button Name="OpenFolderPicker">Select Fo_lder</Button>
         <Button Name="OpenFilePicker">_Open File</Button>
         <Button Name="SaveFilePicker">_Save File</Button>
+        <Button Name="SaveFilePickerWithResult">Save File XML or JSON</Button>
         <Button Name="OpenFileFromBookmark">Open File Bookmark</Button>
         <Button Name="OpenFolderFromBookmark">Open Folder Bookmark</Button>
       </StackPanel>

+ 51 - 4
samples/ControlCatalog/Pages/DialogsPage.xaml.cs

@@ -276,6 +276,48 @@ namespace ControlCatalog.Pages
 
                 await SetPickerResult(file is null ? null : new[] { file });
             };
+            this.Get<Button>("SaveFilePickerWithResult").Click += async delegate
+            {
+                var result = await GetStorageProvider().SaveFilePickerWithResultAsync(new FilePickerSaveOptions()
+                {
+                    Title = "Save file",
+                    FileTypeChoices = [FilePickerFileTypes.Json, FilePickerFileTypes.Xml],
+                    SuggestedStartLocation = lastSelectedDirectory,
+                    SuggestedFileName = "FileName",
+                    ShowOverwritePrompt = true
+                });
+
+                try
+                {
+                    if (result.File is { } file)
+                    {
+                        // Sync disposal of StreamWriter is not supported on WASM
+#if NET6_0_OR_GREATER
+                        await using var stream = await file.OpenWriteAsync();
+                        await using var writer = new System.IO.StreamWriter(stream);
+#else
+                        using var stream = await file.OpenWriteAsync();
+                        using var writer = new System.IO.StreamWriter(stream);
+#endif
+                        if (result.SelectedFileType == FilePickerFileTypes.Xml)
+                        {
+                            await writer.WriteLineAsync("<sample>Test</sample>");
+                        }
+                        else
+                        {
+                            await writer.WriteLineAsync("""{ "sample": "Test" }""");
+                        }
+
+                        SetFolder(await result.File.GetParentAsync());
+                    }
+                }
+                catch (Exception ex)
+                {
+                    openedFileContent.Text = ex.ToString();
+                }
+
+                await SetPickerResult(result.File is null ? null : new[] { result.File }, result.SelectedFileType);
+            };
             this.Get<Button>("OpenFolderPicker").Click += async delegate
             {
                 var folders = await GetStorageProvider().OpenFolderPickerAsync(new FolderPickerOpenOptions()
@@ -341,15 +383,16 @@ namespace ControlCatalog.Pages
                 currentFolderBox.Text = folder?.Path is { IsAbsoluteUri: true } abs ? abs.LocalPath : folder?.Path?.ToString();
                 ignoreTextChanged = false;
             }
-            async Task SetPickerResult(IReadOnlyCollection<IStorageItem>? items)
+            async Task SetPickerResult(IReadOnlyCollection<IStorageItem>? items, FilePickerFileType? selectedType = null)
             {
                 items ??= Array.Empty<IStorageItem>();
                 bookmarkContainer.Text = items.FirstOrDefault(f => f.CanBookmark) is { } f ? await f.SaveBookmarkAsync() : "Can't bookmark";
                 var mappedResults = new List<string>();
 
+                string resultText = "";
                 if (items.FirstOrDefault() is IStorageItem item)
                 {
-                    var resultText = item is IStorageFile ? "File:" : "Folder:";
+                    resultText += item is IStorageFile ? "File:" : "Folder:";
                     resultText += Environment.NewLine;
 
                     var props = await item.GetBasicPropertiesAsync();
@@ -374,8 +417,6 @@ namespace ControlCatalog.Pages
                         }
                     }
 
-                    openedFileContent.Text = resultText;
-
                     if (item is IStorageFolder storageFolder)
                     {
                         SetFolder(storageFolder);
@@ -404,6 +445,12 @@ namespace ControlCatalog.Pages
                     lastSelectedItem = item;
                 }
 
+                if (selectedType is not null)
+                {
+                    resultText += Environment.NewLine + "Selected type: " + selectedType.Name;
+                }
+
+                openedFileContent.Text = resultText;
                 results.ItemsSource = mappedResults;
                 resultsVisible.IsVisible = mappedResults.Any();
             }

+ 6 - 0
src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs

@@ -199,6 +199,12 @@ internal class AndroidStorageProvider : IStorageProvider
         return uris.Select(u => new AndroidStorageFile(_activity, u)).FirstOrDefault();
     }
 
+    public async Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
+    {
+        var file = await SaveFilePickerAsync(options).ConfigureAwait(false);
+        return new SaveFilePickerResult(file);
+    }
+
     public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
     {
         var intent = new Intent(Intent.ActionOpenDocumentTree)

+ 5 - 1
src/Avalonia.Base/Platform/Storage/FallbackStorageProvider.cs

@@ -58,6 +58,10 @@ internal class FallbackStorageProvider : IStorageProvider
         return await (await GetFor(p => p.CanSave)).SaveFilePickerAsync(options);
     }
 
+    public async Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
+    {
+        return await (await GetFor(p => p.CanSave)).SaveFilePickerWithResultAsync(options);
+    }
 
     public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
     {
@@ -92,4 +96,4 @@ internal class FallbackStorageProvider : IStorageProvider
     public Task<IStorageFolder?> TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder) =>
         FirstNotNull(wellKnownFolder, (p, a) => p.TryGetWellKnownFolderAsync(a));
     
-}
+}

+ 1 - 0
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs

@@ -15,6 +15,7 @@ internal abstract class BclStorageProvider : IStorageProvider
 
     public abstract bool CanSave { get; }
     public abstract Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options);
+    public abstract Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options);
 
     public abstract bool CanPickFolder { get; }
     public abstract Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options);

+ 2 - 1
src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs

@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.Diagnostics.CodeAnalysis;
 using System.IO;
 using System.Linq;
@@ -60,7 +61,7 @@ internal static class StorageProviderHelpers
             return null;
         }
     }
-    
+
     [return: NotNullIfNotNull(nameof(path))]
     public static string? NameWithExtension(string? path, string? defaultExtension, FilePickerFileType? filter)
     {

+ 5 - 7
src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs

@@ -7,17 +7,12 @@ namespace Avalonia.Platform.Storage;
 /// <summary>
 /// Represents a name mapped to the associated file types (extensions).
 /// </summary>
-public sealed class FilePickerFileType
+public sealed class FilePickerFileType(string? name)
 {
-    public FilePickerFileType(string? name)
-    {
-        Name = name ?? string.Empty;
-    }
-
     /// <summary>
     /// File type name.
     /// </summary>
-    public string Name { get; }
+    public string Name { get; } = name ?? string.Empty;
 
     /// <summary>
     /// List of extensions in GLOB format. I.e. "*.png" or "*.*".
@@ -54,4 +49,7 @@ public sealed class FilePickerFileType
             .Select(e => e!.TrimStart('.'))
             .ToArray()!;
     }
+
+    /// <inheritdoc />
+    public override string ToString() => Name;
 }

+ 14 - 0
src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs

@@ -53,4 +53,18 @@ public static class FilePickerFileTypes
         AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" },
         MimeTypes = new[] { "application/pdf" }
     };
+
+    public static FilePickerFileType Json { get; } = new("JSON document")
+    {
+        Patterns = new[] { "*.json" },
+        AppleUniformTypeIdentifiers = new[] { "public.json" },
+        MimeTypes = new[] { "application/json" }
+    };
+
+    public static FilePickerFileType Xml { get; } = new("XML document")
+    {
+        Patterns = new[] { "*.xml" },
+        AppleUniformTypeIdentifiers = new[] { "public.xml" },
+        MimeTypes = new[] { "application/xml", "text/xml" }
+    };
 }

+ 6 - 0
src/Avalonia.Base/Platform/Storage/IStorageProvider.cs

@@ -31,6 +31,12 @@ public interface IStorageProvider
     /// <returns>Saved <see cref="IStorageFile"/> or null if user canceled the dialog.</returns>
     Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options);
 
+    /// <summary>
+    /// Opens save file picker dialog and returns additional information about the result.
+    /// </summary>
+    /// <returns><see cref="SaveFilePickerResult"/> with saved file and additional dialog information such as selected file type.</returns>
+    Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options);
+
     /// <summary>
     /// Returns true if it's possible to open folder picker on the current platform. 
     /// </summary>

+ 5 - 0
src/Avalonia.Base/Platform/Storage/NoopStorageProvider.cs

@@ -19,6 +19,11 @@ internal class NoopStorageProvider : BclStorageProvider
         return Task.FromResult<IStorageFile?>(null);
     }
 
+    public override Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
+    {
+        return Task.FromResult(new SaveFilePickerResult(null));
+    }
+
     public override bool CanPickFolder => false;
     public override Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
     {

+ 22 - 0
src/Avalonia.Base/Platform/Storage/SaveFilePickerResult.cs

@@ -0,0 +1,22 @@
+namespace Avalonia.Platform.Storage;
+
+/// <summary>
+/// Extended result of the <see cref="IStorageProvider.SaveFilePickerWithResultAsync(FilePickerSaveOptions)"/> operation.
+/// </summary>
+public readonly struct SaveFilePickerResult
+{
+    internal SaveFilePickerResult(IStorageFile? file)
+    {
+        File = file;
+    }
+
+    /// <summary>
+    /// Saved <see cref="IStorageFile"/> or null if user canceled the dialog.
+    /// </summary>
+    public IStorageFile? File { get; init; }
+
+    /// <summary>
+    /// Selected file type or null if not supported.
+    /// </summary>
+    public FilePickerFileType? SelectedFileType { get; init; }
+}

+ 5 - 0
src/Avalonia.DesignerSupport/Remote/Stubs.cs

@@ -307,6 +307,11 @@ namespace Avalonia.DesignerSupport.Remote
             return Task.FromResult<IStorageFile?>(null);
         }
 
+        public override Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
+        {
+            return Task.FromResult<SaveFilePickerResult>(new SaveFilePickerResult(null));
+        }
+
         public override bool CanPickFolder => false;
         public override Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
         {

+ 7 - 1
src/Avalonia.Dialogs/Internal/ManagedFileChooserFilterViewModel.cs

@@ -8,9 +8,15 @@ namespace Avalonia.Dialogs.Internal
     {
         private readonly Regex[]? _patterns;
         public string Name { get; }
+        internal int Index { get; }
 
-        public ManagedFileChooserFilterViewModel(FilePickerFileType filter)
+        public ManagedFileChooserFilterViewModel(FilePickerFileType filter) : this(filter, 0)
         {
+        }
+        
+        public ManagedFileChooserFilterViewModel(FilePickerFileType filter, int index)
+        {
+            Index = index;
             Name = filter.Name;
 
             if (filter.Patterns?.Contains("*.*") == true)

+ 4 - 2
src/Avalonia.Dialogs/Internal/ManagedFileChooserViewModel.cs

@@ -141,7 +141,8 @@ namespace Avalonia.Dialogs.Internal
 
             if (filePickerOpen.FileTypeFilter?.Count > 0)
             {
-                Filters.AddRange(filePickerOpen.FileTypeFilter.Select(f => new ManagedFileChooserFilterViewModel(f)));
+                Filters.AddRange(filePickerOpen.FileTypeFilter.Select((f, i)
+                    => new ManagedFileChooserFilterViewModel(f, i)));
                 _selectedFilter = Filters[0];
                 ShowFilters = true;
             }
@@ -161,7 +162,8 @@ namespace Avalonia.Dialogs.Internal
 
             if (filePickerSave.FileTypeChoices?.Count > 0)
             {
-                Filters.AddRange(filePickerSave.FileTypeChoices.Select(f => new ManagedFileChooserFilterViewModel(f)));
+                Filters.AddRange(filePickerSave.FileTypeChoices.Select((f, i)
+                    => new ManagedFileChooserFilterViewModel(f, i)));
                 _selectedFilter = Filters[0];
                 ShowFilters = true;
             }

+ 13 - 0
src/Avalonia.Dialogs/ManagedStorageProvider.cs

@@ -46,6 +46,19 @@ internal class ManagedStorageProvider : BclStorageProvider
             : null;
     }
 
+    public override async Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
+    {
+        var model = new ManagedFileChooserViewModel(options, _managedOptions);
+        var results = await Show(model);
+
+        var file = results.FirstOrDefault() is { } result ? new BclStorageFile(new FileInfo(result)) : null;
+        var filterType = model.SelectedFilter?.Index is { } index && index < options.FileTypeChoices?.Count ?
+            options.FileTypeChoices[index] :
+            null;
+
+        return new SaveFilePickerResult(file) { SelectedFileType = filterType };
+    }
+
     public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
     {
         var model = new ManagedFileChooserViewModel(options, _managedOptions);

+ 46 - 22
src/Avalonia.FreeDesktop/DBusSystemDialog.cs

@@ -17,12 +17,13 @@ namespace Avalonia.FreeDesktop
     {
         internal static async Task<IStorageProvider?> TryCreateAsync(IPlatformHandle handle)
         {
-            if (DBusHelper.DefaultConnection is not {} conn)
+            if (DBusHelper.DefaultConnection is not { } conn)
                 return null;
 
             using var restoreContext = AvaloniaSynchronizationContext.Ensure(DispatcherPriority.Input);
 
-            var dbusFileChooser = new OrgFreedesktopPortalFileChooserProxy(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop");
+            var dbusFileChooser = new OrgFreedesktopPortalFileChooserProxy(conn, "org.freedesktop.portal.Desktop",
+                "/org/freedesktop/portal/desktop");
             uint version;
             try
             {
@@ -41,7 +42,8 @@ namespace Avalonia.FreeDesktop
         private readonly IPlatformHandle _handle;
         private readonly uint _version;
 
-        private DBusSystemDialog(Connection connection, IPlatformHandle handle, OrgFreedesktopPortalFileChooserProxy fileChooser, uint version)
+        private DBusSystemDialog(Connection connection, IPlatformHandle handle,
+            OrgFreedesktopPortalFileChooserProxy fileChooser, uint version)
         {
             _connection = connection;
             _fileChooser = fileChooser;
@@ -64,14 +66,15 @@ namespace Avalonia.FreeDesktop
             if (TryParseFilters(options.FileTypeFilter, out var filters))
                 chooserOptions.Add("filters", filters);
 
-            if (options.SuggestedStartLocation?.TryGetLocalPath()  is { } folderPath)
+            if (options.SuggestedStartLocation?.TryGetLocalPath() is { } folderPath)
                 chooserOptions.Add("current_folder", VariantValue.Array(Encoding.UTF8.GetBytes(folderPath + "\0")));
 
             chooserOptions.Add("multiple", VariantValue.Bool(options.AllowMultiple));
 
             objectPath = await _fileChooser.OpenFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions);
 
-            var request = new OrgFreedesktopPortalRequestProxy(_connection, "org.freedesktop.portal.Desktop", objectPath);
+            var request =
+                new OrgFreedesktopPortalRequestProxy(_connection, "org.freedesktop.portal.Desktop", objectPath);
             var tsc = new TaskCompletionSource<string[]?>();
             using var disposable = await request.WatchResponseAsync((e, x) =>
             {
@@ -86,6 +89,19 @@ namespace Avalonia.FreeDesktop
         }
 
         public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
+        {
+            var (file, _) = await SaveFilePickerCoreAsync(options).ConfigureAwait(false);
+            return file;
+        }
+
+        public override async Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
+        {
+            var (file, selectedType) = await SaveFilePickerCoreAsync(options).ConfigureAwait(false);
+            return new SaveFilePickerResult(file) { SelectedFileType = selectedType };
+        }
+
+        private async Task<(IStorageFile? file, FilePickerFileType? selectedType)> SaveFilePickerCoreAsync(
+            FilePickerSaveOptions options)
         {
             var parentWindow = $"x11:{_handle.Handle:X}";
             ObjectPath objectPath;
@@ -95,11 +111,13 @@ namespace Avalonia.FreeDesktop
 
             if (options.SuggestedFileName is { } currentName)
                 chooserOptions.Add("current_name", VariantValue.String(currentName));
-            if (options.SuggestedStartLocation?.TryGetLocalPath()  is { } folderPath)
+            if (options.SuggestedStartLocation?.TryGetLocalPath() is { } folderPath)
                 chooserOptions.Add("current_folder", VariantValue.Array(Encoding.UTF8.GetBytes(folderPath + "\0")));
 
-            objectPath = await _fileChooser.SaveFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions);
-            var request = new OrgFreedesktopPortalRequestProxy(_connection, "org.freedesktop.portal.Desktop", objectPath);
+            objectPath = await _fileChooser.SaveFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions)
+                .ConfigureAwait(false);
+            var request =
+                new OrgFreedesktopPortalRequestProxy(_connection, "org.freedesktop.portal.Desktop", objectPath);
             var tsc = new TaskCompletionSource<string[]?>();
             FilePickerFileType? selectedType = null;
             using var disposable = await request.WatchResponseAsync((e, x) =>
@@ -113,35 +131,39 @@ namespace Avalonia.FreeDesktop
                     if (x.Results.TryGetValue("current_filter", out var currentFilter))
                     {
                         var name = currentFilter.GetItem(0).GetString();
-                        selectedType = new FilePickerFileType(name);
                         var patterns = new List<string>();
                         var mimeTypes = new List<string>();
                         var types = currentFilter.GetItem(1).GetArray<VariantValue>();
-                        foreach(var t in types)
+                        foreach (var t in types)
                         {
                             if (t.GetItem(0).GetUInt32() == 1)
                                 mimeTypes.Add(t.GetItem(1).GetString());
                             else
                                 patterns.Add(t.GetItem(1).GetString());
                         }
-
-                        selectedType.Patterns = patterns;
-                        selectedType.MimeTypes = mimeTypes;
+                        
+                        // Reuse the file type objects from options
+                        // so the consuming code can match exactly the
+                        // file type selected instead of spawning one.
+                        selectedType = options.FileTypeChoices?.FirstOrDefault(type => type.Name == name && (
+                            (type.MimeTypes?.All(y => mimeTypes.Contains(y)) ?? false) ||
+                            (type.Patterns?.All(y => patterns.Contains(y)) ?? false))) 
+                            ?? new FilePickerFileType(name) { MimeTypes = mimeTypes, Patterns = patterns };
                     }
 
                     tsc.TrySetResult(x.Results["uris"].GetArray<string>());
                 }
-            });
+            }).ConfigureAwait(false);
 
-            var uris = await tsc.Task;
+            var uris = await tsc.Task.ConfigureAwait(false);
             var path = uris?.FirstOrDefault() is { } filePath ? new Uri(filePath).LocalPath : null;
 
             if (path is null)
-                return null;
+                return (null, selectedType);
 
             // WSL2 freedesktop automatically adds extension from selected file type, but we can't pass "default ext". So apply it manually.
             path = StorageProviderHelpers.NameWithExtension(path, options.DefaultExtension, selectedType);
-            return new BclStorageFile(new FileInfo(path));
+            return (new BclStorageFile(new FileInfo(path)), selectedType);
         }
 
         public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
@@ -152,8 +174,7 @@ namespace Avalonia.FreeDesktop
             var parentWindow = $"x11:{_handle.Handle:X}";
             var chooserOptions = new Dictionary<string, VariantValue>
             {
-                { "directory", VariantValue.Bool(true) },
-                { "multiple", VariantValue.Bool(options.AllowMultiple) }
+                { "directory", VariantValue.Bool(true) }, { "multiple", VariantValue.Bool(options.AllowMultiple) }
             };
 
             if (options.SuggestedFileName is { } currentName)
@@ -161,8 +182,10 @@ namespace Avalonia.FreeDesktop
             if (options.SuggestedStartLocation?.TryGetLocalPath() is { } folderPath)
                 chooserOptions.Add("current_folder", VariantValue.Array(Encoding.UTF8.GetBytes(folderPath + "\0")));
 
-            var objectPath = await _fileChooser.OpenFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions);
-            var request = new OrgFreedesktopPortalRequestProxy(_connection, "org.freedesktop.portal.Desktop", objectPath);
+            var objectPath =
+                await _fileChooser.OpenFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions);
+            var request =
+                new OrgFreedesktopPortalRequestProxy(_connection, "org.freedesktop.portal.Desktop", objectPath);
             var tsc = new TaskCompletionSource<string[]?>();
             using var disposable = await request.WatchResponseAsync((e, x) =>
             {
@@ -200,7 +223,8 @@ namespace Avalonia.FreeDesktop
                 if (fileType.Patterns?.Count > 0)
                     extensions.AddRange(fileType.Patterns.Select(static pattern => Struct.Create(GlobStyle, pattern)));
                 else if (fileType.MimeTypes?.Count > 0)
-                    extensions.AddRange(fileType.MimeTypes.Select(static mimeType => Struct.Create(MimeStyle, mimeType)));
+                    extensions.AddRange(
+                        fileType.MimeTypes.Select(static mimeType => Struct.Create(MimeStyle, mimeType)));
                 else
                     continue;
 

+ 40 - 17
src/Avalonia.Native/StorageProviderApi.cs

@@ -158,7 +158,7 @@ internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnable
         using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeFilter, null);
         var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty;
 
-        var results = await OpenDialogAsync(events =>
+        var (items, _) = await OpenDialogAsync(events =>
         {
             _native.OpenFileDialog((IAvnWindow?)topLevel?.Native,
                 events,
@@ -167,17 +167,17 @@ internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnable
                 suggestedDirectory,
                 options.SuggestedFileName ?? string.Empty,
                 fileTypes);
-        });
+        }).ConfigureAwait(false);
 
-        return results.OfType<IStorageFile>().ToArray();
+        return items.OfType<IStorageFile>().ToArray();
     }
 
-    public async Task<IStorageFile?> SaveFileDialog(TopLevelImpl? topLevel, FilePickerSaveOptions options)
+    public async Task<(IStorageFile? file, FilePickerFileType? selectedType)> SaveFileDialog(TopLevelImpl? topLevel, FilePickerSaveOptions options)
     {
         using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeChoices, options.DefaultExtension);
         var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty;
 
-        var results = await OpenDialogAsync(events =>
+        var (items, selectedFilterIndex) = await OpenDialogAsync(events =>
         {
             _native.SaveFileDialog((IAvnWindow?)topLevel?.Native,
                 events,
@@ -185,35 +185,46 @@ internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnable
                 suggestedDirectory,
                 options.SuggestedFileName ?? string.Empty,
                 fileTypes);
-        }, create: true);
+        }, create: true).ConfigureAwait(false);
+
+        var file = items.OfType<IStorageFile>().FirstOrDefault();
+        FilePickerFileType? selectedType = null;
+        if (selectedFilterIndex is { } index && index >= 0 && options.FileTypeChoices is { Count: > 0 } choices && index < choices.Count)
+        {
+            selectedType = choices[index];
+        }
 
-        return results.OfType<IStorageFile>().FirstOrDefault();
+        return (file, selectedType);
     }
 
     public async Task<IReadOnlyList<IStorageFolder>> SelectFolderDialog(TopLevelImpl? topLevel, FolderPickerOpenOptions options)
     {
         var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty;
 
-        var results = await OpenDialogAsync(events =>
+        var (items, _) = await OpenDialogAsync(events =>
         {
             _native.SelectFolderDialog((IAvnWindow?)topLevel?.Native,
                 events,
                 options.AllowMultiple.AsComBool(),
                 options.Title ?? "",
                 suggestedDirectory);
-        });
+        }).ConfigureAwait(false);
 
-        return results.OfType<IStorageFolder>().ToArray();
+        return items.OfType<IStorageFolder>().ToArray();
     }
 
-    public async Task<IEnumerable<IStorageItem>> OpenDialogAsync(Action<SystemDialogEvents> runDialog, bool create = false)
+    public async Task<(IEnumerable<IStorageItem> Items, int? SelectedFilterIndex)> OpenDialogAsync(Action<SystemDialogEvents> runDialog, bool create = false)
     {
         using var events = new SystemDialogEvents();
         runDialog(events);
-        var result = await events.Task.ConfigureAwait(false);
-        return (result?
+        var (result, selectedFilterIndex) = await events.Task.ConfigureAwait(false);
+
+        var items = result
             .Select(f => Uri.TryCreate(f, UriKind.Absolute, out var uri) ? TryGetStorageItem(uri, create) : null)
-            .Where(f => f is not null) ?? [])!;
+            .OfType<IStorageItem>()
+            .ToArray();
+
+        return (items, selectedFilterIndex);
     }
 
     public Uri? TryResolveFileReferenceUri(Uri uri)
@@ -282,15 +293,27 @@ internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnable
 
     internal class SystemDialogEvents : NativeCallbackBase, IAvnSystemDialogEvents
     {
-        private readonly TaskCompletionSource<string[]> _tcs = new();
+        private readonly TaskCompletionSource<(string[] Results, int? SelectedFilterIndex)> _tcs = new();
 
-        public Task<string[]> Task => _tcs.Task;
+        public Task<(string[] Results, int? SelectedFilterIndex)> Task => _tcs.Task;
 
         public void OnCompleted(IAvnStringArray? ppv)
+        {
+            Complete(ppv, null);
+        }
+
+        public void OnCompletedWithFilter(IAvnStringArray? ppv, int selectedFilterIndex)
+        {
+            Complete(ppv, selectedFilterIndex);
+        }
+
+        private void Complete(IAvnStringArray? ppv, int? selectedFilterIndex)
         {
             using (ppv)
             {
-                _tcs.SetResult(ppv?.ToStringArray() ?? []);
+                var items = ppv?.ToStringArray() ?? Array.Empty<string>();
+                var typeIndex = selectedFilterIndex is >= 0 ? selectedFilterIndex : null;
+                _tcs.TrySetResult((items, typeIndex));
             }
         }
     }

+ 9 - 2
src/Avalonia.Native/StorageProviderImpl.cs

@@ -21,9 +21,16 @@ internal sealed class StorageProviderImpl(TopLevelImpl topLevel, StorageProvider
         return native.OpenFileDialog(topLevel, options);
     }
 
-    public Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
+    public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
     {
-        return native.SaveFileDialog(topLevel, options);
+        var (file, _) = await native.SaveFileDialog(topLevel, options).ConfigureAwait(false);
+        return file;
+    }
+
+    public async Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
+    {
+        var (file, selectedType) = await native.SaveFileDialog(topLevel, options).ConfigureAwait(false);
+        return new SaveFilePickerResult(file) { SelectedFileType = selectedType };
     }
 
     public Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)

+ 1 - 0
src/Avalonia.Native/avn.idl

@@ -910,6 +910,7 @@ interface IAvnPlatformThreadingInterface : IUnknown
 interface IAvnSystemDialogEvents : IUnknown
 {
      void OnCompleted(IAvnStringArray*array);
+     void OnCompletedWithFilter(IAvnStringArray*array, int selectedFilterIndex);
 }
 
 [uuid(4d7a47db-a944-4061-abe7-62cb6aa0ffd5)]

+ 48 - 17
src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs

@@ -39,10 +39,11 @@ namespace Avalonia.X11.NativeDialogs
         {
             return await await RunOnGlibThread(async () =>
             {
-                var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.Open,
-                    options.AllowMultiple, options.SuggestedStartLocation, null, options.FileTypeFilter, null, false)
+                var (files, _) = await ShowDialog(options.Title, _window, GtkFileChooserAction.Open,
+                        options.AllowMultiple, options.SuggestedStartLocation, null, options.FileTypeFilter, null, false)
                     .ConfigureAwait(false);
-                return res?.Where(f => File.Exists(f)).Select(f => new BclStorageFile(new FileInfo(f))).ToArray() ?? Array.Empty<IStorageFile>();
+                return files?.Where(f => File.Exists(f)).Select(f => new BclStorageFile(new FileInfo(f))).ToArray() ??
+                       Array.Empty<IStorageFile>();
             });
         }
 
@@ -50,10 +51,12 @@ namespace Avalonia.X11.NativeDialogs
         {
             return await await RunOnGlibThread(async () =>
             {
-                var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.SelectFolder,
-                    options.AllowMultiple, options.SuggestedStartLocation, null,
-                    null, null, false).ConfigureAwait(false);
-                return res?.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray() ?? Array.Empty<IStorageFolder>();
+                var (folders, _) = await ShowDialog(options.Title, _window, GtkFileChooserAction.SelectFolder,
+                        options.AllowMultiple, options.SuggestedStartLocation, null,
+                        null, null, false)
+                    .ConfigureAwait(false);
+                return folders?.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray() ??
+                       Array.Empty<IStorageFolder>();
             });
         }
         
@@ -61,16 +64,34 @@ namespace Avalonia.X11.NativeDialogs
         {
             return await await RunOnGlibThread(async () =>
             {
-                var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.Save,
-                    false, options.SuggestedStartLocation, options.SuggestedFileName, options.FileTypeChoices, options.DefaultExtension, options.ShowOverwritePrompt ?? false)
+                var (files, _) = await ShowDialog(options.Title, _window, GtkFileChooserAction.Save,
+                        false, options.SuggestedStartLocation, options.SuggestedFileName, options.FileTypeChoices,
+                        options.DefaultExtension, options.ShowOverwritePrompt ?? false)
                     .ConfigureAwait(false);
-                return res?.FirstOrDefault() is { } file
+                return files?.FirstOrDefault() is { } file
                     ? new BclStorageFile(new FileInfo(file))
                     : null;
             });
         }
 
-        private unsafe Task<string[]?> ShowDialog(string? title, IWindowImpl parent, GtkFileChooserAction action,
+        public override async Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
+        {
+            return await await RunOnGlibThread(async () =>
+            {
+                var (files, selectedFilter) = await ShowDialog(options.Title, _window, GtkFileChooserAction.Save,
+                        false, options.SuggestedStartLocation, options.SuggestedFileName, options.FileTypeChoices,
+                        options.DefaultExtension, options.ShowOverwritePrompt ?? false)
+                    .ConfigureAwait(false);
+                var file = files?.FirstOrDefault() is { } path
+                    ? new BclStorageFile(new FileInfo(path))
+                    : null;
+
+                return new SaveFilePickerResult(file) { SelectedFileType = selectedFilter };
+            });
+        }
+
+        private unsafe Task<(string[]? files, FilePickerFileType? selectedFilter)> ShowDialog(string? title,
+            IWindowImpl parent, GtkFileChooserAction action,
             bool multiSelect, IStorageFolder? initialFolder, string? initialFileName,
             IEnumerable<FilePickerFileType>? filters, string? defaultExtension, bool overwritePrompt)
         {
@@ -88,12 +109,17 @@ namespace Avalonia.X11.NativeDialogs
 
             gtk_window_set_modal(dlg, true);
             gtk_file_chooser_set_local_only(dlg, false);
-            var tcs = new TaskCompletionSource<string[]?>();
+            var tcs = new TaskCompletionSource<(string[]?, FilePickerFileType?)>();
             List<IDisposable>? disposables = null;
 
             void Dispose()
             {
-                foreach (var d in disposables!)
+                if (disposables is null)
+                {
+                    return;
+                }
+
+                foreach (var d in disposables)
                 {
                     d.Dispose();
                 }
@@ -102,6 +128,7 @@ namespace Avalonia.X11.NativeDialogs
             }
 
             var filtersDic = new Dictionary<IntPtr, FilePickerFileType>();
+            FilePickerFileType? selectedFilter = null;
             if (filters != null)
             {
                 foreach (var f in filters)
@@ -146,7 +173,7 @@ namespace Avalonia.X11.NativeDialogs
             {
                 ConnectSignal<signal_generic>(dlg, "close", delegate
                 {
-                    tcs.TrySetResult(null);
+                    tcs.TrySetResult((null, null));
                     Dispose();
                     return false;
                 }),
@@ -170,14 +197,18 @@ namespace Avalonia.X11.NativeDialogs
                         if (action == GtkFileChooserAction.Save)
                         {
                             var currentFilter = gtk_file_chooser_get_filter(dlg);
-                            filtersDic.TryGetValue(currentFilter, out var selectedFilter);
-                            for (var c = 0; c < result.Length; c++) { result[c] = StorageProviderHelpers.NameWithExtension(result[c], defaultExtension, selectedFilter); }
+                            filtersDic.TryGetValue(currentFilter, out selectedFilter);
+                            for (var c = 0; c < result.Length; c++)
+                            {
+                                result[c] = StorageProviderHelpers.NameWithExtension(result[c], defaultExtension,
+                                    selectedFilter);
+                            }
                         }
                     }
 
                     gtk_widget_hide(dlg);
                     Dispose();
-                    tcs.TrySetResult(result);
+                    tcs.TrySetResult((result, selectedFilter));
                     return false;
                 })
             };

+ 6 - 0
src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs

@@ -90,6 +90,12 @@ internal class BrowserStorageProvider : IStorageProvider
         }
     }
 
+    public async Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
+    {
+        var file = await SaveFilePickerAsync(options).ConfigureAwait(false);
+        return new SaveFilePickerResult(file);
+    }
+
     public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
     {
         await AvaloniaModule.ImportStorage();

+ 36 - 24
src/Windows/Avalonia.Win32/Win32StorageProvider.cs

@@ -13,7 +13,7 @@ using Avalonia.Logging;
 
 namespace Avalonia.Win32
 {
-    internal class Win32StorageProvider : BclStorageProvider
+    internal class Win32StorageProvider(WindowImpl windowImpl) : BclStorageProvider
     {
         private const uint SIGDN_DESKTOPABSOLUTEPARSING = 0x80028000;
 
@@ -22,13 +22,6 @@ namespace Avalonia.Win32
             FILEOPENDIALOGOPTIONS.FOS_NOVALIDATE | FILEOPENDIALOGOPTIONS.FOS_NOTESTFILECREATE |
             FILEOPENDIALOGOPTIONS.FOS_DONTADDTORECENT;
 
-        private readonly WindowImpl _windowImpl;
-
-        public Win32StorageProvider(WindowImpl windowImpl)
-        {
-            _windowImpl = windowImpl;
-        }
-
         public override bool CanOpen => true;
 
         public override bool CanSave => true;
@@ -37,28 +30,30 @@ namespace Avalonia.Win32
 
         public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
         {
-            return await ShowFilePicker(
+            var (folders, _) = await ShowFilePicker(
                 true, true,
                 options.AllowMultiple, false,
                 options.Title, options.SuggestedFileName, options.SuggestedStartLocation, null, null,
                 f => new BclStorageFolder(new DirectoryInfo(f)))
                 .ConfigureAwait(false);
+            return folders;
         }
 
         public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
         {
-            return await ShowFilePicker(
+            var (files, _) = await ShowFilePicker(
                 true, false,
                 options.AllowMultiple, false,
                 options.Title, options.SuggestedFileName, options.SuggestedStartLocation,
                 null, options.FileTypeFilter,
                 f => new BclStorageFile(new FileInfo(f)))
                 .ConfigureAwait(false);
+            return files;
         }
 
         public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
         {
-            var files = await ShowFilePicker(
+            var (files, _) = await ShowFilePicker(
                 false, false,
                 false, options.ShowOverwritePrompt,
                 options.Title, options.SuggestedFileName, options.SuggestedStartLocation,
@@ -68,7 +63,25 @@ namespace Avalonia.Win32
             return files.Count > 0 ? files[0] : null;
         }
 
-        private unsafe Task<IReadOnlyList<TStorageItem>> ShowFilePicker<TStorageItem>(
+        public override async Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
+        {
+            var (files, index) = await ShowFilePicker(
+                    false, false,
+                    false, options.ShowOverwritePrompt,
+                    options.Title, options.SuggestedFileName, options.SuggestedStartLocation,
+                    options.DefaultExtension, options.FileTypeChoices,
+                    f => new BclStorageFile(new FileInfo(f)))
+                .ConfigureAwait(false);
+            var file = files.Count > 0 ? files[0] : null;
+            var selectedFileType = options.FileTypeChoices?.Count > 0
+                                   && (index > 0 && index <= options.FileTypeChoices.Count) ?
+                options.FileTypeChoices[index - 1] :
+                null;
+
+            return new SaveFilePickerResult(file) { SelectedFileType = selectedFileType };
+        }
+
+        private unsafe Task<(IReadOnlyList<TStorageItem> items, int typeIndex)> ShowFilePicker<TStorageItem>(
             bool isOpenFile,
             bool openFolder,
             bool allowMultiple,
@@ -83,7 +96,7 @@ namespace Avalonia.Win32
         {
             return Task.Factory.StartNew(() =>
             {
-                IReadOnlyList<TStorageItem> result = Array.Empty<TStorageItem>();
+                IReadOnlyList<TStorageItem> result = [];
                 try
                 {
                     var clsid = isOpenFile ? UnmanagedMethods.ShellIds.OpenFileDialog : UnmanagedMethods.ShellIds.SaveFileDialog;
@@ -107,10 +120,7 @@ namespace Avalonia.Win32
                     }
                     frm.SetOptions(options);
 
-                    if (defaultExtension is null)
-                    {
-                        defaultExtension = string.Empty;
-                    }
+                    defaultExtension ??= string.Empty;
 
                     fixed (char* pExt = defaultExtension)
                     {
@@ -136,7 +146,8 @@ namespace Avalonia.Win32
                             frm.SetFileTypes((ushort)count, pFilters);
                             if (count > 0)
                             {
-                                frm.SetFileTypeIndex(0);
+                                // FileTypeIndex is one based, not zero based.
+                                frm.SetFileTypeIndex(1);
                             }
                         }
                     }
@@ -153,11 +164,13 @@ namespace Avalonia.Win32
                         }
                     }
 
-                    var showResult = frm.Show(_windowImpl.Handle.Handle);
+                    var showResult = frm.Show(windowImpl.Handle.Handle);
+
+                    var typeIndex = (int)frm.FileTypeIndex;
 
                     if ((uint)showResult == (uint)UnmanagedMethods.HRESULT.E_CANCELLED)
                     {
-                        return result;
+                        return (result, typeIndex);
                     }
                     else if ((uint)showResult != (uint)UnmanagedMethods.HRESULT.S_OK)
                     {
@@ -185,10 +198,10 @@ namespace Avalonia.Win32
                     else if (frm.Result is { } shellItem
                         && GetParsingName(shellItem) is { } singleResult)
                     {
-                        result = new[] { convert(singleResult) };
+                        result = [convert(singleResult)];
                     }
 
-                    return result;
+                    return (result, typeIndex);
                 }
                 catch (COMException ex)
                 {
@@ -198,7 +211,6 @@ namespace Avalonia.Win32
             }, TaskCreationOptions.LongRunning);
         }
 
-
         private static string? GetParsingName(IShellItem shellItem)
         {
             return GetDisplayName(shellItem, SIGDN_DESKTOPABSOLUTEPARSING);
@@ -218,7 +230,7 @@ namespace Avalonia.Win32
                     Marshal.FreeCoTaskMem((IntPtr)pszString);
                 }
             }
-            return default;
+            return null;
         }
 
         private byte[] FiltersToPointer(IReadOnlyList<FilePickerFileType>? filters, out int length)

+ 6 - 0
src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs

@@ -229,6 +229,12 @@ internal class IOSStorageProvider : IStorageProvider
         }
     }
 
+    public async Task<SaveFilePickerResult> SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
+    {
+        var file = await SaveFilePickerAsync(options).ConfigureAwait(false);
+        return new SaveFilePickerResult(file);
+    }
+
     public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
     {
         using var documentPicker = OperatingSystem.IsIOSVersionAtLeast(14) ?