One feature that was missing in WPF was the OpenFolderDialog. To circumvent the lack of something to select a folder, there were some methods, but none of them were optimal:
- Use Windows Forms, which required to add the System.Windows.Forms package
- Use Win32 API, using P/Invoke or COM interfaces
- Use a third party component
Version after version, we were still waiting for a native OpenFolderDialog for WPF, and now, in .NET 8.0, finally, it's here!
To implement it, a new base class was introduced, CommonItemDialog
, which is the parent of OpenFolderDialog
:
Source: https://devblogs.microsoft.com/dotnet/wpf-file-dialog-improvements-in-dotnet-8/
To used it, you must install .NET 8.0 preview 7 or later (you can get it at https://dotnet.microsoft.com/en-us/download/dotnet/8.0). Once you have it installed, we can create a new WPF app to use the OpenFolderDialog:
dotnet new wpf -o OpenFolderDialogWPF
cd OpenFolderDialogWPF
code .
Then, we can use OpenFolderDialog to select a folder an enumerate all files in it. In MainWindow.xaml, add this code:
<Window x:Class="OpenFolderDialogWPF.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:OpenFolderDialogWPF"
mc:Ignorable="d"
Title="MainWindow"
Height="450"
Width="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="40" />
<RowDefinition Height="*" />
<RowDefinition Height="30" />
</Grid.RowDefinitions>
<Button Width="85"
Height="30"
Content="Select Folder"
Click="SelectFolderClick"
HorizontalAlignment="Right"
Margin="5"
Grid.Row="0" />
<ListBox x:Name="FilesList"
HorizontalContentAlignment="Stretch"
Grid.Row="1" />
<StackPanel Grid.Row="2"
Orientation="Horizontal">
<TextBlock x:Name="TotalFilesText"
Margin="5,0"
VerticalAlignment="Center" />
<TextBlock x:Name="LengthFilesText"
Margin="5,0"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
</Window>
We are adding a button to open the dialog to select the folder. Below it, we add a listbox that will contain the file list and a status bar to show the file count and the total size.
The code for the SelectFolderClick
event handler for the button click is:
private void SelectFolderClick(object sender, RoutedEventArgs e)
{
FilesList.Items.Clear();
var ofd = new OpenFolderDialog();
if (ofd.ShowDialog() != true)
{
FilesList.Items.Add("No folder selected");
return;
}
var selectedPath = ofd.FolderName;
var files = new DirectoryInfo(selectedPath)
.GetFiles("*.*", SearchOption.AllDirectories)
.Select(f => new
{
Name = Path.GetRelativePath(selectedPath,f.FullName),
Size = f.Length
});
TotalFilesText.Text = $"File count: {files.Count()}";
LengthFilesText.Text = $"Total size: {files.Sum(f => f.Size).ToString("N0")}";
FilesList.ItemsSource = files
.OrderByDescending(f => f.Size)
.Select(f => $"{f.Name} ({f.Size.ToString("N0")})");
}
We are using the new OpenFolderDialog to select a folder. If the user selects a folder, we get an array of files (we are using an anonymous class with the relative path and size of the file), which we use to get the count, total size and fill the listbox. The GetFiles
method with the SearchOption.AllDirectories
parameter will scan the folder and all subdirectories and get all files. We are ordering the files by descending size (the larger ones will come first).
If you run the program, you will see something like this:
Now we have a way to select folders and we don't need anything extra to manage the dialog. We can now port the project described here to .NET 8.0, and we will use the new component.
For that, clone the repository at https://github.com/bsonnino/FindDuplicates and open it in Visual Studio. This project targets .NET 4.7.2. It uses two Nuget packages, WPFFolderBrowserDialog (which we will replace by the native one) and CRC32C (which is no longer maintained). We'll replace both Nuget packages and we'll see if there are performance improvements.
When I run the code in the original file, I get something like this:
To start the conversion, we will use Dotnet convert assistant, shown in this article.
Once I used the latest version to upgrade the project (if you have an older version installed you can update it with dotnet tool update -g upgrade-assistant
), I got a .NET 8 project:
This project still doesn't work, as CRC32C is not available for .NET 8. We will change the OpenFolderDialog and change CRC32.
For the OpenFolderDialog, the change is very simple: remove the line that references the Nuget package in the csproj file and make these changes in StartClick
:
private async void StartClick(object sender, RoutedEventArgs e)
{
var ofd = new OpenFolderDialog();
if (ofd.ShowDialog() != true)
return;
var sw = new Stopwatch();
sw.Start();
FilesList.ItemsSource = null;
var selectedPath = ofd.FolderName;
var files = await GetPossibleDuplicatesAsync(selectedPath);
FilesList.ItemsSource = await GetRealDuplicatesAsync(files);
sw.Stop();
var allFiles = files.SelectMany(f => f).ToList();
TotalFilesText.Text = $"{allFiles.Count} files found " +
$"({allFiles.Sum(f => f.Length):N0} total duplicate bytes) {sw.ElapsedMilliseconds} ms";
}
For the CRC, we can use the Crc32
class in System.IO.Hashing
. We will do the same procedure as we did with the CRC32C class: read the file, 10000 bytes at a time and call the Append
method to add the bytes to the calculation. At the end, we call the GetCurrentHash
method and return the result.
This class will return an array of bytes, and we must convert it to an UInt using the BitConverter
class. The code for GetCrc32FromFile
is:
public byte[] GetCrc32FromFile(string fileName)
{
try
{
using (var file = new FileStream(fileName, FileMode.Open,FileAccess.Read))
{
const int NumBytes = 1000;
var bytes = new byte[NumBytes];
var numRead = file.Read(bytes, 0, NumBytes);
if (numRead == 0)
return [];
var crc = new Crc32();
while (numRead > 0)
{
crc.Append(bytes);
numRead = file.Read(bytes, 0, NumBytes);
}
return crc.GetCurrentHash();
}
}
catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException)
{
return [];
}
}
With this code, you have control on the buffer size, but if that's not an issue, you can use another overload to calculate the CRC, using the file stream:
public byte[] GetCrc32FromFile(string fileName)
{
try
{
using (FileStream file = new FileStream(fileName, FileMode.Open))
{
var crc = new Crc32();
crc.Append(file);
return crc.GetCurrentHash();
}
}
catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException)
{
return [];
}
}
We use this function in GetRealDuplicatesAsync
to check if the files with the same size are really duplicates, by comparing their hash codes:
private async Task<Dictionary<uint, List<FileInfo>>> GetRealDuplicatesAsync(
List<IGrouping<long, FileInfo>> files)
{
var dictFiles = new ConcurrentDictionary<uint, ConcurrentBag<FileInfo>>();
await Task.Factory.StartNew(() =>
{
Parallel.ForEach(files.SelectMany(f => f), file =>
{
var crc = GetCrc32FromFile(file.FullName);
if (crc.Length > 0)
{
var hash = BitConverter.ToUInt32(GetCrc32FromFile(file.FullName),0);
if (dictFiles.ContainsKey(hash))
dictFiles[hash].Add(file);
else
dictFiles.TryAdd(hash, new ConcurrentBag<FileInfo>(new[] { file }));
}
});
});
return dictFiles.Where(p => p.Value.Count > 1)
.OrderByDescending(p => p.Value.First().Length)
.ToDictionary(p => p.Key, p => p.Value.ToList());
}
When you run this code, you will have the same results as before:
The program is converted to .NET 8, it runs exactly the same and we are not using any external components. We are using the OpenFolderDialog and the Crc32 class from .NET.
All the source code for the projects are in https://github.com/bsonnino/OpenFolderDlgWPF