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 <maxkatz6@outlook.com>
pull/19939/head
Jumar Macato 3 months ago
committed by GitHub
parent
commit
2c540873ec
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 40
      api/Avalonia.nupkg.xml
  2. 14
      native/Avalonia.Native/src/OSX/StorageProvider.mm
  3. 1
      samples/ControlCatalog/Pages/DialogsPage.xaml
  4. 55
      samples/ControlCatalog/Pages/DialogsPage.xaml.cs
  5. 6
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs
  6. 6
      src/Avalonia.Base/Platform/Storage/FallbackStorageProvider.cs
  7. 1
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs
  8. 3
      src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs
  9. 12
      src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs
  10. 14
      src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs
  11. 6
      src/Avalonia.Base/Platform/Storage/IStorageProvider.cs
  12. 5
      src/Avalonia.Base/Platform/Storage/NoopStorageProvider.cs
  13. 22
      src/Avalonia.Base/Platform/Storage/SaveFilePickerResult.cs
  14. 5
      src/Avalonia.DesignerSupport/Remote/Stubs.cs
  15. 8
      src/Avalonia.Dialogs/Internal/ManagedFileChooserFilterViewModel.cs
  16. 6
      src/Avalonia.Dialogs/Internal/ManagedFileChooserViewModel.cs
  17. 13
      src/Avalonia.Dialogs/ManagedStorageProvider.cs
  18. 68
      src/Avalonia.FreeDesktop/DBusSystemDialog.cs
  19. 57
      src/Avalonia.Native/StorageProviderApi.cs
  20. 11
      src/Avalonia.Native/StorageProviderImpl.cs
  21. 1
      src/Avalonia.Native/avn.idl
  22. 65
      src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs
  23. 6
      src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs
  24. 60
      src/Windows/Avalonia.Win32/Win32StorageProvider.cs
  25. 6
      src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs

40
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 --> <!-- 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"> <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> <Suppression>
<DiagnosticId>CP0006</DiagnosticId> <DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.Input.Platform.IClipboard.SetDataAsync(Avalonia.Input.IAsyncDataTransfer)</Target> <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> <Left>baseline/Avalonia/lib/net6.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net6.0/Avalonia.Base.dll</Right> <Right>current/Avalonia/lib/net6.0/Avalonia.Base.dll</Right>
</Suppression> </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> <Suppression>
<DiagnosticId>CP0006</DiagnosticId> <DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.OpenGL.IGlExternalSemaphore.SignalTimelineSemaphore(Avalonia.OpenGL.IGlExternalImageTexture,System.UInt64)</Target> <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> <Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right> <Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression> </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> <Suppression>
<DiagnosticId>CP0006</DiagnosticId> <DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.OpenGL.IGlExternalSemaphore.SignalTimelineSemaphore(Avalonia.OpenGL.IGlExternalImageTexture,System.UInt64)</Target> <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> <Left>baseline/Avalonia/lib/netstandard2.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/netstandard2.0/Avalonia.Base.dll</Right> <Right>current/Avalonia/lib/netstandard2.0/Avalonia.Base.dll</Right>
</Suppression> </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> <Suppression>
<DiagnosticId>CP0006</DiagnosticId> <DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.OpenGL.IGlExternalSemaphore.SignalTimelineSemaphore(Avalonia.OpenGL.IGlExternalImageTexture,System.UInt64)</Target> <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> <Left>baseline/Avalonia/lib/netstandard2.0/Avalonia.OpenGL.dll</Left>
<Right>current/Avalonia/lib/netstandard2.0/Avalonia.OpenGL.dll</Right> <Right>current/Avalonia/lib/netstandard2.0/Avalonia.OpenGL.dll</Right>
</Suppression> </Suppression>
</Suppressions> </Suppressions>

14
native/Avalonia.Native/src/OSX/StorageProvider.mm

@ -320,12 +320,22 @@ public:
} }
auto handler = ^(NSModalResponse result) { 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) if(result == NSFileHandlingPanelOKButton)
{ {
auto url = [panel URL]; auto url = [panel URL];
auto urls = [NSArray<NSURL*> arrayWithObject:url]; auto urls = [NSArray<NSURL*> arrayWithObject:url];
auto uriStrings = CreateAvnStringArray(urls); auto uriStrings = CreateAvnStringArray(urls);
events->OnCompleted(uriStrings); events->OnCompletedWithFilter(uriStrings, selectedIndex);
[panel orderOut:panel]; [panel orderOut:panel];
@ -338,7 +348,7 @@ public:
return; return;
} }
events->OnCompleted(nullptr); events->OnCompletedWithFilter(nullptr, selectedIndex);
}; };

1
samples/ControlCatalog/Pages/DialogsPage.xaml

@ -38,6 +38,7 @@
<Button Name="OpenFolderPicker">Select Fo_lder</Button> <Button Name="OpenFolderPicker">Select Fo_lder</Button>
<Button Name="OpenFilePicker">_Open File</Button> <Button Name="OpenFilePicker">_Open File</Button>
<Button Name="SaveFilePicker">_Save 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="OpenFileFromBookmark">Open File Bookmark</Button>
<Button Name="OpenFolderFromBookmark">Open Folder Bookmark</Button> <Button Name="OpenFolderFromBookmark">Open Folder Bookmark</Button>
</StackPanel> </StackPanel>

55
samples/ControlCatalog/Pages/DialogsPage.xaml.cs

@ -276,6 +276,48 @@ namespace ControlCatalog.Pages
await SetPickerResult(file is null ? null : new[] { file }); 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 this.Get<Button>("OpenFolderPicker").Click += async delegate
{ {
var folders = await GetStorageProvider().OpenFolderPickerAsync(new FolderPickerOpenOptions() 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(); currentFolderBox.Text = folder?.Path is { IsAbsoluteUri: true } abs ? abs.LocalPath : folder?.Path?.ToString();
ignoreTextChanged = false; ignoreTextChanged = false;
} }
async Task SetPickerResult(IReadOnlyCollection<IStorageItem>? items) async Task SetPickerResult(IReadOnlyCollection<IStorageItem>? items, FilePickerFileType? selectedType = null)
{ {
items ??= Array.Empty<IStorageItem>(); items ??= Array.Empty<IStorageItem>();
bookmarkContainer.Text = items.FirstOrDefault(f => f.CanBookmark) is { } f ? await f.SaveBookmarkAsync() : "Can't bookmark"; bookmarkContainer.Text = items.FirstOrDefault(f => f.CanBookmark) is { } f ? await f.SaveBookmarkAsync() : "Can't bookmark";
var mappedResults = new List<string>(); var mappedResults = new List<string>();
string resultText = "";
if (items.FirstOrDefault() is IStorageItem item) if (items.FirstOrDefault() is IStorageItem item)
{ {
var resultText = item is IStorageFile ? "File:" : "Folder:"; resultText += item is IStorageFile ? "File:" : "Folder:";
resultText += Environment.NewLine; resultText += Environment.NewLine;
var props = await item.GetBasicPropertiesAsync(); var props = await item.GetBasicPropertiesAsync();
@ -374,8 +417,6 @@ namespace ControlCatalog.Pages
} }
} }
openedFileContent.Text = resultText;
if (item is IStorageFolder storageFolder) if (item is IStorageFolder storageFolder)
{ {
SetFolder(storageFolder); SetFolder(storageFolder);
@ -404,6 +445,12 @@ namespace ControlCatalog.Pages
lastSelectedItem = item; lastSelectedItem = item;
} }
if (selectedType is not null)
{
resultText += Environment.NewLine + "Selected type: " + selectedType.Name;
}
openedFileContent.Text = resultText;
results.ItemsSource = mappedResults; results.ItemsSource = mappedResults;
resultsVisible.IsVisible = mappedResults.Any(); resultsVisible.IsVisible = mappedResults.Any();
} }

6
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(); 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) public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{ {
var intent = new Intent(Intent.ActionOpenDocumentTree) var intent = new Intent(Intent.ActionOpenDocumentTree)

6
src/Avalonia.Base/Platform/Storage/FallbackStorageProvider.cs

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

1
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs

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

3
src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs

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

12
src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs

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

14
src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs

@ -53,4 +53,18 @@ public static class FilePickerFileTypes
AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" }, AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" },
MimeTypes = new[] { "application/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
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> /// <returns>Saved <see cref="IStorageFile"/> or null if user canceled the dialog.</returns>
Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options); 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> /// <summary>
/// Returns true if it's possible to open folder picker on the current platform. /// Returns true if it's possible to open folder picker on the current platform.
/// </summary> /// </summary>

5
src/Avalonia.Base/Platform/Storage/NoopStorageProvider.cs

@ -19,6 +19,11 @@ internal class NoopStorageProvider : BclStorageProvider
return Task.FromResult<IStorageFile?>(null); 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 bool CanPickFolder => false;
public override Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) public override Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{ {

22
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
src/Avalonia.DesignerSupport/Remote/Stubs.cs

@ -307,6 +307,11 @@ namespace Avalonia.DesignerSupport.Remote
return Task.FromResult<IStorageFile?>(null); 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 bool CanPickFolder => false;
public override Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) public override Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{ {

8
src/Avalonia.Dialogs/Internal/ManagedFileChooserFilterViewModel.cs

@ -8,9 +8,15 @@ namespace Avalonia.Dialogs.Internal
{ {
private readonly Regex[]? _patterns; private readonly Regex[]? _patterns;
public string Name { get; } 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; Name = filter.Name;
if (filter.Patterns?.Contains("*.*") == true) if (filter.Patterns?.Contains("*.*") == true)

6
src/Avalonia.Dialogs/Internal/ManagedFileChooserViewModel.cs

@ -141,7 +141,8 @@ namespace Avalonia.Dialogs.Internal
if (filePickerOpen.FileTypeFilter?.Count > 0) 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]; _selectedFilter = Filters[0];
ShowFilters = true; ShowFilters = true;
} }
@ -161,7 +162,8 @@ namespace Avalonia.Dialogs.Internal
if (filePickerSave.FileTypeChoices?.Count > 0) 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]; _selectedFilter = Filters[0];
ShowFilters = true; ShowFilters = true;
} }

13
src/Avalonia.Dialogs/ManagedStorageProvider.cs

@ -46,6 +46,19 @@ internal class ManagedStorageProvider : BclStorageProvider
: null; : 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) public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{ {
var model = new ManagedFileChooserViewModel(options, _managedOptions); var model = new ManagedFileChooserViewModel(options, _managedOptions);

68
src/Avalonia.FreeDesktop/DBusSystemDialog.cs

@ -17,12 +17,13 @@ namespace Avalonia.FreeDesktop
{ {
internal static async Task<IStorageProvider?> TryCreateAsync(IPlatformHandle handle) internal static async Task<IStorageProvider?> TryCreateAsync(IPlatformHandle handle)
{ {
if (DBusHelper.DefaultConnection is not {} conn) if (DBusHelper.DefaultConnection is not { } conn)
return null; return null;
using var restoreContext = AvaloniaSynchronizationContext.Ensure(DispatcherPriority.Input); 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; uint version;
try try
{ {
@ -41,7 +42,8 @@ namespace Avalonia.FreeDesktop
private readonly IPlatformHandle _handle; private readonly IPlatformHandle _handle;
private readonly uint _version; 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; _connection = connection;
_fileChooser = fileChooser; _fileChooser = fileChooser;
@ -64,14 +66,15 @@ namespace Avalonia.FreeDesktop
if (TryParseFilters(options.FileTypeFilter, out var filters)) if (TryParseFilters(options.FileTypeFilter, out var filters))
chooserOptions.Add("filters", 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("current_folder", VariantValue.Array(Encoding.UTF8.GetBytes(folderPath + "\0")));
chooserOptions.Add("multiple", VariantValue.Bool(options.AllowMultiple)); chooserOptions.Add("multiple", VariantValue.Bool(options.AllowMultiple));
objectPath = await _fileChooser.OpenFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions); 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[]?>(); var tsc = new TaskCompletionSource<string[]?>();
using var disposable = await request.WatchResponseAsync((e, x) => using var disposable = await request.WatchResponseAsync((e, x) =>
{ {
@ -86,6 +89,19 @@ namespace Avalonia.FreeDesktop
} }
public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) 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}"; var parentWindow = $"x11:{_handle.Handle:X}";
ObjectPath objectPath; ObjectPath objectPath;
@ -95,11 +111,13 @@ namespace Avalonia.FreeDesktop
if (options.SuggestedFileName is { } currentName) if (options.SuggestedFileName is { } currentName)
chooserOptions.Add("current_name", VariantValue.String(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"))); chooserOptions.Add("current_folder", VariantValue.Array(Encoding.UTF8.GetBytes(folderPath + "\0")));
objectPath = await _fileChooser.SaveFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions); objectPath = await _fileChooser.SaveFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions)
var request = new OrgFreedesktopPortalRequestProxy(_connection, "org.freedesktop.portal.Desktop", objectPath); .ConfigureAwait(false);
var request =
new OrgFreedesktopPortalRequestProxy(_connection, "org.freedesktop.portal.Desktop", objectPath);
var tsc = new TaskCompletionSource<string[]?>(); var tsc = new TaskCompletionSource<string[]?>();
FilePickerFileType? selectedType = null; FilePickerFileType? selectedType = null;
using var disposable = await request.WatchResponseAsync((e, x) => using var disposable = await request.WatchResponseAsync((e, x) =>
@ -113,35 +131,39 @@ namespace Avalonia.FreeDesktop
if (x.Results.TryGetValue("current_filter", out var currentFilter)) if (x.Results.TryGetValue("current_filter", out var currentFilter))
{ {
var name = currentFilter.GetItem(0).GetString(); var name = currentFilter.GetItem(0).GetString();
selectedType = new FilePickerFileType(name);
var patterns = new List<string>(); var patterns = new List<string>();
var mimeTypes = new List<string>(); var mimeTypes = new List<string>();
var types = currentFilter.GetItem(1).GetArray<VariantValue>(); var types = currentFilter.GetItem(1).GetArray<VariantValue>();
foreach(var t in types) foreach (var t in types)
{ {
if (t.GetItem(0).GetUInt32() == 1) if (t.GetItem(0).GetUInt32() == 1)
mimeTypes.Add(t.GetItem(1).GetString()); mimeTypes.Add(t.GetItem(1).GetString());
else else
patterns.Add(t.GetItem(1).GetString()); patterns.Add(t.GetItem(1).GetString());
} }
selectedType.Patterns = patterns; // Reuse the file type objects from options
selectedType.MimeTypes = mimeTypes; // 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>()); 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; var path = uris?.FirstOrDefault() is { } filePath ? new Uri(filePath).LocalPath : null;
if (path is 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. // 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); 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) public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
@ -152,8 +174,7 @@ namespace Avalonia.FreeDesktop
var parentWindow = $"x11:{_handle.Handle:X}"; var parentWindow = $"x11:{_handle.Handle:X}";
var chooserOptions = new Dictionary<string, VariantValue> var chooserOptions = new Dictionary<string, VariantValue>
{ {
{ "directory", VariantValue.Bool(true) }, { "directory", VariantValue.Bool(true) }, { "multiple", VariantValue.Bool(options.AllowMultiple) }
{ "multiple", VariantValue.Bool(options.AllowMultiple) }
}; };
if (options.SuggestedFileName is { } currentName) if (options.SuggestedFileName is { } currentName)
@ -161,8 +182,10 @@ namespace Avalonia.FreeDesktop
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("current_folder", VariantValue.Array(Encoding.UTF8.GetBytes(folderPath + "\0")));
var objectPath = await _fileChooser.OpenFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions); var objectPath =
var request = new OrgFreedesktopPortalRequestProxy(_connection, "org.freedesktop.portal.Desktop", 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[]?>(); var tsc = new TaskCompletionSource<string[]?>();
using var disposable = await request.WatchResponseAsync((e, x) => using var disposable = await request.WatchResponseAsync((e, x) =>
{ {
@ -200,7 +223,8 @@ namespace Avalonia.FreeDesktop
if (fileType.Patterns?.Count > 0) if (fileType.Patterns?.Count > 0)
extensions.AddRange(fileType.Patterns.Select(static pattern => Struct.Create(GlobStyle, pattern))); extensions.AddRange(fileType.Patterns.Select(static pattern => Struct.Create(GlobStyle, pattern)));
else if (fileType.MimeTypes?.Count > 0) 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 else
continue; continue;

57
src/Avalonia.Native/StorageProviderApi.cs

@ -158,7 +158,7 @@ internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnable
using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeFilter, null); using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeFilter, null);
var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty; var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty;
var results = await OpenDialogAsync(events => var (items, _) = await OpenDialogAsync(events =>
{ {
_native.OpenFileDialog((IAvnWindow?)topLevel?.Native, _native.OpenFileDialog((IAvnWindow?)topLevel?.Native,
events, events,
@ -167,17 +167,17 @@ internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnable
suggestedDirectory, suggestedDirectory,
options.SuggestedFileName ?? string.Empty, options.SuggestedFileName ?? string.Empty,
fileTypes); 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); using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeChoices, options.DefaultExtension);
var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty; var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty;
var results = await OpenDialogAsync(events => var (items, selectedFilterIndex) = await OpenDialogAsync(events =>
{ {
_native.SaveFileDialog((IAvnWindow?)topLevel?.Native, _native.SaveFileDialog((IAvnWindow?)topLevel?.Native,
events, events,
@ -185,35 +185,46 @@ internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnable
suggestedDirectory, suggestedDirectory,
options.SuggestedFileName ?? string.Empty, options.SuggestedFileName ?? string.Empty,
fileTypes); 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) public async Task<IReadOnlyList<IStorageFolder>> SelectFolderDialog(TopLevelImpl? topLevel, FolderPickerOpenOptions options)
{ {
var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty; var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty;
var results = await OpenDialogAsync(events => var (items, _) = await OpenDialogAsync(events =>
{ {
_native.SelectFolderDialog((IAvnWindow?)topLevel?.Native, _native.SelectFolderDialog((IAvnWindow?)topLevel?.Native,
events, events,
options.AllowMultiple.AsComBool(), options.AllowMultiple.AsComBool(),
options.Title ?? "", options.Title ?? "",
suggestedDirectory); 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(); using var events = new SystemDialogEvents();
runDialog(events); runDialog(events);
var result = await events.Task.ConfigureAwait(false); var (result, selectedFilterIndex) = await events.Task.ConfigureAwait(false);
return (result?
var items = result
.Select(f => Uri.TryCreate(f, UriKind.Absolute, out var uri) ? TryGetStorageItem(uri, create) : null) .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) public Uri? TryResolveFileReferenceUri(Uri uri)
@ -282,15 +293,27 @@ internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnable
internal class SystemDialogEvents : NativeCallbackBase, IAvnSystemDialogEvents 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) 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) using (ppv)
{ {
_tcs.SetResult(ppv?.ToStringArray() ?? []); var items = ppv?.ToStringArray() ?? Array.Empty<string>();
var typeIndex = selectedFilterIndex is >= 0 ? selectedFilterIndex : null;
_tcs.TrySetResult((items, typeIndex));
} }
} }
} }

11
src/Avalonia.Native/StorageProviderImpl.cs

@ -21,9 +21,16 @@ internal sealed class StorageProviderImpl(TopLevelImpl topLevel, StorageProvider
return native.OpenFileDialog(topLevel, options); 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) public Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)

1
src/Avalonia.Native/avn.idl

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

65
src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs

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

6
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) public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{ {
await AvaloniaModule.ImportStorage(); await AvaloniaModule.ImportStorage();

60
src/Windows/Avalonia.Win32/Win32StorageProvider.cs

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

6
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) public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{ {
using var documentPicker = OperatingSystem.IsIOSVersionAtLeast(14) ? using var documentPicker = OperatingSystem.IsIOSVersionAtLeast(14) ?

Loading…
Cancel
Save