Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Directory size browser

0.00/5 (No votes)
17 Feb 2015 1  
This article shows how to build a responsive directory size browser application utilizing threading. The source code includes both C# and VB.Net

Introduction

The problem of this article has been touched several times, how to gather information about disk space usage for files and directories within a disk drive. However, I didn't find a suitable solution easily so I decided to make a small program for the task.


For the program I had few requirements it should satisfy:

  • Visually show cumulative directory space usage
  • Finding big files should be easy
  • The program has elevated privileges in order to access all folders
  • The user interface is responsive and informative while investigating the directories

So here are a few pictures for the outcome. Next we'll go through how the program was actually made.

 

Elevated privileges

Option 1: Manifest

Since I wanted to be able to search through all directories, the first thing is to set up the project so that it uses administrator privileges. Alternative 1 is ensure that administrator privileges are granted and if not, then the application won't run. This is done by adding a manifest file into the project. Simply select Add New Item... for the project and add an Application Manifest File.

The next step is to add proper trust info requirements. The newly created manifest file contains typical configuration options in comments so just select the requireAdministrator level like following

  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
    <security>
      <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
        <requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
      </requestedPrivileges>
    </security>
  </trustInfo>

After this has been done when you start the program, Visual Studio will ask for administrator privileges. The same happens when you run the compiled exe.

The downside of this approach is that the privilege requirement is hardcoded into the application. The application itself would run without admin privileges, it just wouldn't be able to investigate all folders.

Option 2: Elevate privileges during the application startup

If you don't want to use hard coded elevated privileges, you can remove the manifest from the project. However, it still would be handy if the application could control if the privileges are going to be elevated.

The privileges of the process cannot be changed when the process has started, but we can always start a new process with more privileges. Based on this idea the startup method investigates if the process has admin privileges and if not, a question is asked if the privileges can be elevated. The code looks like this

[System.STAThread()]
public static void Main() {
   DirectorySizes.Starter application;
   DirectorySizes.DirectoryBrowser browserWindow;
   System.Security.Principal.WindowsIdentity identity;
   System.Security.Principal.WindowsPrincipal principal;
   System.Windows.MessageBoxResult result;
   System.Diagnostics.ProcessStartInfo adminProcess;

   System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.AppStarting;

   { // --- Alternative for using manifest for elevated privileges

      // Check if admin rights are in place
      identity = System.Security.Principal.WindowsIdentity.GetCurrent();
      principal = new System.Security.Principal.WindowsPrincipal(identity);

      // Ask for permission if not an admin
      if (!principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)) {
         result = System.Windows.MessageBox.Show(
                  "Can the application run in elevated mode in order to access all files?", 
                  "Directory size browser", 
                   System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxImage.Question);
         if (result == System.Windows.MessageBoxResult.Yes) {
            // Re-run the application with administrator privileges
            adminProcess = new System.Diagnostics.ProcessStartInfo();
            adminProcess.UseShellExecute = true;
            adminProcess.WorkingDirectory = System.Environment.CurrentDirectory;
            adminProcess.FileName = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName;
            adminProcess.Verb = "runas";
            try {
               System.Diagnostics.Process.Start(adminProcess);
               // quit after starting the new process
               return;
            } catch (System.Exception exception) {
               System.Windows.MessageBox.Show(exception.Message, "Directory size browser", 
                  System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Exclamation);
               return;
            }
         }
      }
    } // --- Alternative for using manifest for elevated privileges

   application = new DirectorySizes.Starter();
   browserWindow = new DirectorySizes.DirectoryBrowser();

   application.Run(browserWindow);
}
<System.STAThread>
Public Shared Sub Main()
   Dim application As DirectorySizes.Starter
   Dim browserWindow As DirectorySizes.DirectoryBrowser
   Dim identity As System.Security.Principal.WindowsIdentity
   Dim principal As System.Security.Principal.WindowsPrincipal
   Dim result As System.Windows.MessageBoxResult
   Dim adminProcess As System.Diagnostics.ProcessStartInfo

   System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.AppStarting

   ' --- Alternative for using manifest for elevated privileges

   ' Check if admin rights are in place
   identity = System.Security.Principal.WindowsIdentity.GetCurrent()
   principal = New System.Security.Principal.WindowsPrincipal(identity)

   ' Ask for permission if not an admin
   If (Not principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)) Then
      result = System.Windows.MessageBox.Show(
         "Can the application run in elevated mode in order to access all files?",
         "Directory size browser", 
         System.Windows.MessageBoxButton.YesNo, 
         System.Windows.MessageBoxImage.Question)
      If (result = System.Windows.MessageBoxResult.Yes) Then
         adminProcess = New System.Diagnostics.ProcessStartInfo()
         adminProcess.UseShellExecute = True
         adminProcess.WorkingDirectory = System.Environment.CurrentDirectory
         adminProcess.FileName = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName
         adminProcess.Verb = "runas"
         Try
            System.Diagnostics.Process.Start(adminProcess)
            Return
         Catch exception As System.Exception
            System.Windows.MessageBox.Show(exception.Message, "Directory size browser",
               System.Windows.MessageBoxButton.OK, 
               System.Windows.MessageBoxImage.Exclamation)
            Return
         End Try
      End If
   End If

   ' // --- Alternative for using manifest for elevated privileges

   application = New DirectorySizes.Starter()
   browserWindow = New DirectorySizes.DirectoryBrowser()

   application.Run(browserWindow)
End Sub

If the user accepts the elevation a new process is started with runas verb. This process is then let to end so the user interface starts in the newly created process.

The downside of this approach is that the process changes. If the application is simply run this doesn't matter but if you're debugging the application then the original process ends and the Visual Studio debugger closes. So in order to debug the application with elevated privileges, you need to attach the debugger to the new process.

Because of this behaviour I decided to leave the manifest in place in the download. So if you want to try this approach, comment out the manifest.

The program itself

The main logic 

The application consists of few main classes. These are:

  • DirectoryBrowser window, the user interface.
  • DirectoryHelper, a static class containing all the logic for gathering the information.
  • DirectoryDetail and FileDetail, these classes hold the data.

The data gather is done in a recursive method called ListFiles. Let's have a look at it in whole and then have a closer look at it part by part. So the method looks like this

/// <summary>
/// Adds recursively files and directories to hashsets
/// </summary>
/// <param name="directory">Directory to gather data from</param>
/// <returns>Directory details</returns>
private static DirectoryDetail ListFiles(DirectoryDetail thisDirectoryDetail) {
   DirectoryDetail subDirectoryDetail;
   System.IO.FileInfo fileInfo;

   // Exit if stop is requested
   lock (DirectoryHelper._lockObject) {
      if (DirectoryHelper._stopRequested) {
         return thisDirectoryDetail;
      }
   }

   RaiseStatusUpdate(string.Format("Analyzing {0}", DirectoryHelper.ShortenPath(thisDirectoryDetail.Path)));

   //List files in this directory
   try {
      // Loop through child directories
      foreach (string subDirectory 
      in System.IO.Directory.EnumerateDirectories(thisDirectoryDetail.Path).OrderBy(x => x)) {
         subDirectoryDetail = ListFiles(new DirectoryDetail(subDirectory, 
                                        thisDirectoryDetail.Depth + 1, 
                                        thisDirectoryDetail));
         thisDirectoryDetail.CumulativeSize += subDirectoryDetail.CumulativeSize;
         thisDirectoryDetail.CumulativeNumberOfFiles += subDirectoryDetail.CumulativeNumberOfFiles;
         thisDirectoryDetail.SubDirectoryDetails.Add(subDirectoryDetail);
         // Break if stop is requested
         lock (DirectoryHelper._lockObject) {
            if (DirectoryHelper._stopRequested) {
               break;
            }
         }
      }

      if (!DirectoryHelper._stopRequested) {
         // List files in this directory
         foreach (string file 
         in System.IO.Directory.EnumerateFiles(thisDirectoryDetail.Path, "*.*", 
         System.IO.SearchOption.TopDirectoryOnly)) {
            fileInfo = new System.IO.FileInfo(file);
            lock (DirectoryHelper._lockObject) {
               FileDetails.Add(new FileDetail() {
                  Name = fileInfo.Name,
                  Path = fileInfo.DirectoryName,
                  Size = fileInfo.Length,
                  LastAccessed = fileInfo.LastAccessTime,
                  Extension = fileInfo.Extension,
                  DirectoryDetail = thisDirectoryDetail
               });
            }
            thisDirectoryDetail.CumulativeSize += fileInfo.Length;
            thisDirectoryDetail.Size += fileInfo.Length;
            thisDirectoryDetail.NumberOfFiles++;
            thisDirectoryDetail.CumulativeNumberOfFiles++;
            DirectoryHelper.OverallFileCount++;
         }
      }
            
      // add this directory to the collection
      lock (DirectoryHelper._lockObject) {
         DirectoryDetails.Add(thisDirectoryDetail);
      }
      DirectoryHelper.OverallDirectoryCount++;
      DirectoryHelper.RaiseCountersChanged();
   } catch (System.UnauthorizedAccessException exception) {
      // Listing files in the directory not allowed so ignore this directory
      lock (DirectoryHelper._lockObject) {
         DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path);
      }
      DirectoryHelper.RaiseDirectorySkipped(string.Format("Skipped {0}, reason: {1}", 
                                       thisDirectoryDetail.Path, exception.Message));
   } catch (System.IO.PathTooLongException exception) {
      // Path is too long
      lock (DirectoryHelper._lockObject) {
         DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path);
      }
      DirectoryHelper.RaiseDirectorySkipped(string.Format("Skipped {0}, reason: {1}", 
                     thisDirectoryDetail.Path, exception.Message));
   }

   if (thisDirectoryDetail.Depth == 1) {
      if (DirectoryComplete != null) {
         DirectoryComplete(null, thisDirectoryDetail);
      }
   }

   return thisDirectoryDetail;
}
'  Adds recursively files and directories to hashsets
Private Function ListFiles(thisDirectoryDetail As DirectoryDetail) As DirectoryDetail
   Dim subDirectoryDetail As DirectoryDetail
   Dim fileInfo As System.IO.FileInfo

   ' Exit if stop is requested
   SyncLock (DirectoryHelper._lockObject)
      If (DirectoryHelper._stopRequested) Then
         Return thisDirectoryDetail
      End If
   End SyncLock

   RaiseStatusUpdate(String.Format("Analyzing {0}", DirectoryHelper.ShortenPath(thisDirectoryDetail.Path)))

   ' List files in this directory
   Try
      ' Loop through child directories
      For Each subDirectory As String 
      In System.IO.Directory.EnumerateDirectories(thisDirectoryDetail.Path).OrderBy(Function(x) x)
         subDirectoryDetail = ListFiles(New DirectoryDetail(subDirectory, 
                                        thisDirectoryDetail.Depth + 1, 
                                        thisDirectoryDetail))
         thisDirectoryDetail.CumulativeSize += subDirectoryDetail.CumulativeSize
         thisDirectoryDetail.CumulativeNumberOfFiles += subDirectoryDetail.CumulativeNumberOfFiles
         thisDirectoryDetail.SubDirectoryDetails.Add(subDirectoryDetail)
         ' Break if stop is requested
         SyncLock (DirectoryHelper._lockObject)
            If (DirectoryHelper._stopRequested) Then
               Exit For
            End If
         End SyncLock
      Next subDirectory

      If (Not DirectoryHelper._stopRequested) Then
         ' List files in this directory
         For Each file As String 
         In System.IO.Directory.EnumerateFiles(thisDirectoryDetail.Path, "*.*", 
         System.IO.SearchOption.TopDirectoryOnly)
            fileInfo = New System.IO.FileInfo(file)
            SyncLock (DirectoryHelper._lockObject)
               FileDetails.Add(New FileDetail() With {
                  .Name = fileInfo.Name,
                  .Path = fileInfo.DirectoryName,
                  .Size = fileInfo.Length,
                  .LastAccessed = fileInfo.LastAccessTime,
                  .Extension = fileInfo.Extension,
                  .DirectoryDetail = thisDirectoryDetail
               })
            End SyncLock
            thisDirectoryDetail.CumulativeSize += fileInfo.Length
            thisDirectoryDetail.Size += fileInfo.Length
            thisDirectoryDetail.NumberOfFiles += 1
            thisDirectoryDetail.CumulativeNumberOfFiles += 1
            DirectoryHelper.OverallFileCount += 1
            DirectoryHelper.RaiseCountersChanged()
         Next file
      End If

      ' add this directory to the collection
      SyncLock (DirectoryHelper._lockObject)
         DirectoryDetails.Add(thisDirectoryDetail)
      End SyncLock
      DirectoryHelper.OverallDirectoryCount += 1
      DirectoryHelper.RaiseCountersChanged()
   Catch exception As System.UnauthorizedAccessException
      ' Listing files in the directory not allowed so ignore this directory
      SyncLock (DirectoryHelper._lockObject)
         DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path)
      End SyncLock
      DirectoryHelper.RaiseDirectorySkipped(String.Format("Skipped {0}, reason: {1}", 
                                         thisDirectoryDetail.Path, exception.Message))
   Catch exception As System.IO.PathTooLongException
      ' Path is too long
      SyncLock (DirectoryHelper._lockObject)
         DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path)
      End SyncLock
      DirectoryHelper.RaiseDirectorySkipped(String.Format("Skipped {0}, reason: {1}", 
                                         thisDirectoryDetail.Path, exception.Message))
   End Try

   If (thisDirectoryDetail.Depth = 1) Then
      RaiseEvent DirectoryComplete(Nothing, thisDirectoryDetail)
   End If

   Return thisDirectoryDetail
End Function

At this point let's skip the Raise... methods and the locking. We'll get back to those. What the method does is, it receives a directory as a parameter, loops through its sub directories and for each sub directory it calls  ListFiles method again to achieve recursion:

// Loop through child directories
foreach (string subDirectory 
   in System.IO.Directory.EnumerateDirectories(thisDirectoryDetail.Path).OrderBy(x => x)) {
   subDirectoryDetail = ListFiles(new DirectoryDetail(subDirectory, 
                                                      thisDirectoryDetail.Depth + 1, 
                                                      thisDirectoryDetail));
   thisDirectoryDetail.CumulativeSize += subDirectoryDetail.CumulativeSize;
   thisDirectoryDetail.CumulativeNumberOfFiles += subDirectoryDetail.CumulativeNumberOfFiles;
   thisDirectoryDetail.SubDirectoryDetails.Add(subDirectoryDetail);
   // Break if stop is requested
   lock (DirectoryHelper._lockObject) {
      if (DirectoryHelper._stopRequested) {
         break;
      }
   }
}
' Loop through child directories
For Each subDirectory As String In System.IO.Directory.EnumerateDirectories(thisDirectoryDetail.Path).OrderBy(Function(x) x)
   subDirectoryDetail = ListFiles(New DirectoryDetail(subDirectory, 
                                                      thisDirectoryDetail.Depth + 1, 
                                                      thisDirectoryDetail))
   thisDirectoryDetail.CumulativeSize += subDirectoryDetail.CumulativeSize
   thisDirectoryDetail.CumulativeNumberOfFiles += subDirectoryDetail.CumulativeNumberOfFiles
   thisDirectoryDetail.SubDirectoryDetails.Add(subDirectoryDetail)
   ' Break if stop is requested
   SyncLock (DirectoryHelper._lockObject)
      If (DirectoryHelper._stopRequested) Then
         Exit For
      End If
   End SyncLock
Next subDirectory

When the recursion ends the ListFiles method returns a DirectoryDetail object which is filled with the data gathered for that directory and it's subdirectories. When the execution returns from the recursion the CumulativeSize and CumulativeNumberOfFiles for the directory at hand are incremented based on the values returned from the recursion. This helps to investigate the directories based on size later on. The next step gathers information about the files in current directory

// List files in this directory
foreach (string file in System.IO.Directory.EnumerateFiles(thisDirectoryDetail.Path, "*.*", 
                                                           System.IO.SearchOption.TopDirectoryOnly)) {
   fileInfo = new System.IO.FileInfo(file);
   lock (DirectoryHelper._lockObject) {
      FileDetails.Add(new FileDetail() {
         Name = fileInfo.Name,
         Path = fileInfo.DirectoryName,
         Size = fileInfo.Length,
         LastAccessed = fileInfo.LastAccessTime,
         Extension = fileInfo.Extension,
         DirectoryDetail = thisDirectoryDetail
      });
   }
   thisDirectoryDetail.CumulativeSize += fileInfo.Length;
   thisDirectoryDetail.Size += fileInfo.Length;
   thisDirectoryDetail.NumberOfFiles++;
   thisDirectoryDetail.CumulativeNumberOfFiles++;
   DirectoryHelper.OverallFileCount++;
}
lock (DirectoryHelper._lockObject) {
   DirectoryDetails.Add(thisDirectoryDetail);
}
DirectoryHelper.OverallDirectoryCount++;
DirectoryHelper.RaiseCountersChanged();
' List files in this directory
For Each file As String In System.IO.Directory.EnumerateFiles(thisDirectoryDetail.Path, "*.*", 
System.IO.SearchOption.TopDirectoryOnly)
   fileInfo = New System.IO.FileInfo(file)
   SyncLock (DirectoryHelper._lockObject)
      FileDetails.Add(New FileDetail() With {
         .Name = fileInfo.Name,
         .Path = fileInfo.DirectoryName,
         .Size = fileInfo.Length,
         .LastAccessed = fileInfo.LastAccessTime,
         .Extension = fileInfo.Extension,
         .DirectoryDetail = thisDirectoryDetail
      })
   End SyncLock
   thisDirectoryDetail.CumulativeSize += fileInfo.Length
   thisDirectoryDetail.Size += fileInfo.Length
   thisDirectoryDetail.NumberOfFiles += 1
   thisDirectoryDetail.CumulativeNumberOfFiles += 1
   DirectoryHelper.OverallFileCount += 1
   DirectoryHelper.RaiseCountersChanged()
Next file

So this is a simple loop to go through all files in the directory and for each file found a new FileDetail object is created. Also the cumulative counters for the directory are incremented.

As you can see both DirectoryDetail and FileDetail objects are added to separate collections (FileDetails and DirectoryDetails) during the process. Even though only DirectoryDetails collection would be needed if FileDetails would be included for each directory, it's far better to have a single collection containing all the files when we want to list and sort the files regardless of the directory.

Of course things can go wrong and actually they will. Because of this there's a catch block for UnauthorizedAccessException. Even though we are using administrator privileges, some of the directories will cause this exception. Mainly these are junction points in NTFS. For more information, visit NTFS reparse pointWhen this type of directory is encountered, it's listed in the SkippedDirectories collection which then again is shown to the user using an event. The same handling applies if a PathTooLongException is raised.

} catch (System.UnauthorizedAccessException exception) {
   // Listing files in the directory not allowed so ignore this directory
   lock (DirectoryHelper._lockObject) {
      DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path);
   }
   DirectoryHelper.RaiseDirectorySkipped(string.Format("Skipped {0}, reason: {1}", 
                                                      thisDirectoryDetail.Path, exception.Message));
} catch (System.IO.PathTooLongException exception) {
   // Path is too long
   lock (DirectoryHelper._lockObject) {
      DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path);
   }
   DirectoryHelper.RaiseDirectorySkipped(string.Format("Skipped {0}, reason: {1}", 
                                                      thisDirectoryDetail.Path, exception.Message));
}
Catch exception As System.UnauthorizedAccessException
   ' Listing files in the directory not allowed so ignore this directory
   SyncLock (DirectoryHelper._lockObject)
      DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path)
   End SyncLock
   DirectoryHelper.RaiseDirectorySkipped(String.Format("Skipped {0}, reason: {1}", 
                                                       thisDirectoryDetail.Path, exception.Message))
Catch exception As System.IO.PathTooLongException
   ' Path is too long
   SyncLock (DirectoryHelper._lockObject)
      DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path)
   End SyncLock
   DirectoryHelper.RaiseDirectorySkipped(String.Format("Skipped {0}, reason: {1}", 
                                                       thisDirectoryDetail.Path, exception.Message))
End Try

After all the data gather is done, the method returns.

if (thisDirectoryDetail.Depth == 1) {
   if (DirectoryComplete != null) {
      DirectoryComplete(null, thisDirectoryDetail);
   }
}

return thisDirectoryDetail;
If (thisDirectoryDetail.Depth = 1) Then
   RaiseEvent DirectoryComplete(Nothing, thisDirectoryDetail)
End If

Return thisDirectoryDetail

Depending on the recursion the filled DirectoryDetail object will be returned to the previous level of recursion or to the original caller.

Gathering the data asynchronously

As said in the beginning I wanted the user interface to be responsive even if the data gather is in progress. Actually I wanted to be able to investigate a directory as soon as the information for it and its sub directories is gathered. This means that the gathering needs to be done asynchronously. As this is a WPF application with framework 4 or above a Task object will be used.

The GatherData which initiates the recursion looks like this

/// <summary>
/// Collects the data for a drive
/// </summary>
/// <param name="drive">Drive to investigate</param>
/// <returns>True if succesful</returns>
private static bool GatherData(System.IO.DriveInfo driveInfo) {
   DirectoryHelper.RaiseGatherInProgressChanges(true);
   DirectoryHelper.ListFiles(new DirectoryDetail(driveInfo.Name, 0, 
                                                driveInfo.TotalSize - driveInfo.AvailableFreeSpace));

   DirectoryHelper.RaiseStatusUpdate("Calculating statistics...");
   DirectoryHelper.CalculateStatistics();

   DirectoryHelper.RaiseStatusUpdate("Idle");
   DirectoryHelper.RaiseGatherInProgressChanges(false);

   return true;
}
'  Collects the data for a drive
Private Function GatherData(driveInfo As System.IO.DriveInfo) As Boolean
   DirectoryHelper.RaiseGatherInProgressChanges(True)
   DirectoryHelper.ListFiles(New DirectoryDetail(driveInfo.Name, 0, 
                                                driveInfo.TotalSize - driveInfo.AvailableFreeSpace))

   DirectoryHelper.RaiseStatusUpdate("Calculating statistics...")
   DirectoryHelper.CalculateStatistics()

   DirectoryHelper.RaiseStatusUpdate("Idle")
   DirectoryHelper.RaiseGatherInProgressChanges(False)

   Return True
End Function

It raises an event to inform that the search is in progress then starts the recursion. But the interesting part is how this method is called.

/// <summary>
/// Starts the data gathering process
/// </summary>
/// <param name="drive"></param>
/// <returns></returns>
internal static bool StartDataGathering(System.IO.DriveInfo driveInfo) {
   DirectoryHelper.FileDetails.Clear();
   DirectoryHelper.DirectoryDetails.Clear();
   DirectoryHelper.ExtensionDetails.Clear();
   DirectoryHelper.SkippedDirectories.Clear();
   DirectoryHelper.OverallDirectoryCount = 0;
   DirectoryHelper.OverallFileCount = 0;
   DirectoryHelper._stopRequested = false;

   DirectoryHelper._gatherTask = new System.Threading.Tasks.Task(
                                 () => { GatherData(driveInfo); }, 
                                 System.Threading.Tasks.TaskCreationOptions.LongRunning);
   DirectoryHelper._gatherTask.Start();

   return true;
}
'  Starts the data gathering process
Friend Function StartDataGathering(driveInfo As System.IO.DriveInfo) As Boolean
   DirectoryHelper.FileDetails.Clear()
   DirectoryHelper.DirectoryDetails.Clear()
   DirectoryHelper.ExtensionDetails.Clear()
   DirectoryHelper.SkippedDirectories.Clear()
   DirectoryHelper.OverallDirectoryCount = 0
   DirectoryHelper.OverallFileCount = 0
   DirectoryHelper._stopRequested = False

   DirectoryHelper._gatherTask = New System.Threading.Tasks.Task(
                                 Function() GatherData(driveInfo), 
                                 System.Threading.Tasks.TaskCreationOptions.LongRunning)
   DirectoryHelper._gatherTask.Start()

   Return True
End Function

The beginning of the method is just clearing variables in case this isn't the first run. The Task constructor is used to tell what code should be run as the action for this task. In this case the GatherData method is called. Also the task is informed that the code is going to be long running so there's no point of doing fine grained scheduling.

When the Start method is called the DataGather method starts in its own separate thread and this method continues on in the UI thread. So now we have two different threads working, one running the UI and the other one collecting data for the directories.

Getting information from the other thread

Now when the other thread is working and making progress it sure would be nice to know that something is actually happening. I decided to inform the user about the directory currently under investigation and how many files or folders have been found so far. This information is sent to the user interface using events, so nothing very special here. The class has few static events such as 

/// <summary>
/// Event used to send information about the gather process
/// </summary>
internal static event System.EventHandler<string> StatusUpdate;

/// <summary>
/// Event used to inform that the overall statistics have been changed
/// </summary>
internal static event System.EventHandler CountersChanged;
'  Event used to send information about the gather process
Friend Event StatusUpdate As System.EventHandler(Of String)

'  Event used to inform that the overall counters have been changed
Friend Event CountersChanged As System.EventHandler

And when the window is created it wires these events in a normal way.

// Wire the events
DirectoryHelper.StatusUpdate += DirectoryHelper_StatusUpdate;
DirectoryHelper.CountersChanged += DirectoryHelper_CountersChanged;
' Wire the events
AddHandler DirectoryHelper.StatusUpdate, AddressOf DirectoryHelper_StatusUpdate
AddHandler DirectoryHelper.CountersChanged, AddressOf DirectoryHelper_CountersChanged

However, if these events would try to directly update the status item to contain the directory name passed as a parameter or modify any other user interface object an InvalidOperationException would be raised

An exception of type 'System.InvalidOperationException' occurred in WindowsBase.dll but was not handled in user code
Additional information: The calling thread cannot access this object because a different thread owns it.

Remember, the UI runs in a separate thread than the data gatherer which sent the event. In order to update the window we need to change the context back to the UI thread. This is done using the dispatcher that is created by the UI thread and calling the BeginInvoke method to execute a piece of code that does the UI operations.

So the status update is as simple as this

/// <summary>
/// Updates the status bar
/// </summary>
/// <param name="state"></param>
private void UpdateStatus(string state) {
   this.Status.Content = state;
}
' Updates the status bar
Private Sub UpdateStatus(state As String)
   Me.Status.Content = state
End Sub

and the call in the event handler to execute UpdateStatus method is like the following

private void DirectoryHelper_StatusUpdate(object sender, string e) {
   this.Status.Dispatcher.BeginInvoke((System.Action)(() => { UpdateStatus(e); }));
}
Private Sub DirectoryHelper_StatusUpdate(sender As Object, e As String)
   Me.Status.Dispatcher.BeginInvoke(Sub() UpdateStatus(e))
End Sub

This needs some explanation. This overload of the BeginInvoke is called with an Action delegate or Sub in VB. This anonymous delegate executes the UpdateStatus method. So in the code above there's no predefined delegate. Another option would be to define a delegate, for example

private delegate void UpdateGatherCountersDelegate(bool forceUpdate);
Private Delegate Sub UpdateGatherCountersDelegate(forceUpdate As Boolean)

Then define the actual method having the same method signature as in the delegate defintion

/// <summary>
/// Updates the counters
/// </summary>
void UpdateGatherCounters(bool forceUpdate) {
   if (forceUpdate || System.DateTime.Now.Subtract(this._lastCountersUpdate).TotalMilliseconds > 500) {
      this.CountInfo.Content = string.Format("{0} directories, {1} files", 
                                       DirectoryHelper.OverallDirectoryCount.ToString("N0"), 
                                       DirectoryHelper.OverallFileCount.ToString("N0"));
      this._lastCountersUpdate = System.DateTime.Now;
   }
}
' Updates the counters
Sub UpdateGatherCounters(forceUpdate As Boolean)
   If (forceUpdate Or System.DateTime.Now.Subtract(Me._lastCountersUpdate).TotalMilliseconds > 500) Then
      Me.CountInfo.Content = String.Format("{0} directories, {1} files", 
                                    DirectoryHelper.OverallDirectoryCount.ToString("N0"), 
                                    DirectoryHelper.OverallFileCount.ToString("N0"))
      Me._lastCountersUpdate = System.DateTime.Now
   End If
End Sub

And then use the BeginInvoke with the predefined delegate like this

void DirectoryHelper_CountersChanged(object sender, System.EventArgs e) {
   this.CountInfo.Dispatcher.BeginInvoke(new UpdateGatherCountersDelegate(UpdateGatherCounters), false);
}
Private Sub DirectoryHelper_StatusUpdate(sender As Object, e As String)
   Me.Status.Dispatcher.BeginInvoke(Sub() UpdateStatus(e))
End Sub

Accessing the collections during the data gather

Okay, now we update the UI while the data gather is in progress. One of the events, DirectoryComplete is raised whenever the data gather for a root folder is complete. Within this event the folder is added to the directories list and the user can start investigating it. The user can expand the directory and see the sub directories. User can also select the directory and see list of the files in that specific folder and 100 biggest files in that path.

The directory is added using the AddRootNode method

/// <summary>
/// Used to add root folders
/// </summary>
/// <param name="directoryDetail">Directory to add</param>
private void AddRootNode(DirectoryDetail directoryDetail) {
   AddDirectoryNode(this.DirectoryTree.Items, directoryDetail);
}
' Used to add root folders
Private Sub AddRootNode(directoryDetail As DirectoryDetail)
   AddDirectoryNode(Me.DirectoryTree.Items, directoryDetail)
End Sub

This method simply calls a common method to add a directory node since the same method is used when a directory node is expanded. So adding a node looks like this​

/// <summary>
/// Adds a directory node to the specified items collection
/// </summary>
/// <param name="parentItemCollection">Items collection of the parent directory</param>
/// <param name="directoryDetail">Directory to add</param>
/// <returns>True if succesful</returns>
private bool AddDirectoryNode(System.Windows.Controls.ItemCollection parentItemCollection, 
                              DirectoryDetail directoryDetail) {
   System.Windows.Controls.TreeViewItem treeViewItem;
   System.Windows.Controls.StackPanel stackPanel;

   // Create the stackpanel and it's content
   stackPanel = new System.Windows.Controls.StackPanel();
   stackPanel.Orientation = System.Windows.Controls.Orientation.Horizontal;
   // Content
   stackPanel.Children.Add(
      this.CreateProgressBar("Cumulative percentage from total used space {0}% ({1}))", 
                             directoryDetail.CumulativeSizePercentage, 
                             directoryDetail.FormattedCumulativeBytes));
   stackPanel.Children.Add(new System.Windows.Controls.TextBlock() { 
                           Text = directoryDetail.DirectoryName });

   // Create the treeview item
   treeViewItem = new System.Windows.Controls.TreeViewItem();
   treeViewItem.Tag = directoryDetail;
   treeViewItem.Header = stackPanel;
   treeViewItem.Expanded += tvi_Expanded;

   // If this directory contains subdirectories, add a placeholder
   if (directoryDetail.SubDirectoryDetails.Count() > 0) {
      treeViewItem.Items.Add(new System.Windows.Controls.TreeViewItem() { Name = "placeholder" });
   }

   // Add the treeview item into the items collection
   parentItemCollection.Add(treeViewItem);

   return true;
}
' Adds a directory node to the specified items collection
Private Function AddDirectoryNode(parentItemCollection As System.Windows.Controls.ItemCollection, directoryDetail As DirectoryDetail) As Boolean
   Dim treeViewItem As System.Windows.Controls.TreeViewItem
   Dim stackPanel As System.Windows.Controls.StackPanel

   ' Create the stackpanel and it's content
   stackPanel = New System.Windows.Controls.StackPanel()
   stackPanel.Orientation = System.Windows.Controls.Orientation.Horizontal
   ' Content
   stackPanel.Children.Add(Me.CreateProgressBar("Cumulative percentage from total used space {0}% ({1}))", 
                           directoryDetail.CumulativeSizePercentage, 
                           directoryDetail.FormattedCumulativeBytes))
   stackPanel.Children.Add(New System.Windows.Controls.TextBlock() With {
                           .Text = directoryDetail.DirectoryName})

   ' Create the treeview item
   treeViewItem = New System.Windows.Controls.TreeViewItem()
   treeViewItem.Tag = directoryDetail
   treeViewItem.Header = stackPanel
   AddHandler treeViewItem.Expanded, AddressOf tvi_Expanded

   ' If this directory contains subdirectories, add a placeholder
   If (directoryDetail.SubDirectoryDetails.Count() > 0) Then
      treeViewItem.Items.Add(New System.Windows.Controls.TreeViewItem() With {.Name = "placeholder"})
   End If

   ' Add the treeview item into the items collection
   parentItemCollection.Add(treeViewItem)

   Return True
End Function

The idea is that each directory node will show the cumulative percentage of disk space usage in a ProgressBar and the directory name. These controls are placed inside a StackPanel which then again is set as the header of the TreeViewItem.

So far so good, but if the TreeViewItem is selected, the program lists the content of the directory like this

/// <summary>
/// Populates the file list for a directory in descending order based on the file sizes
/// </summary>
/// <param name="tvi">Directory to populate files for</param>
private void ListDirectoryFiles(System.Windows.Controls.TreeViewItem tvi) {
   DirectoryDetail directoryDetail;

   this.FileList.ItemsSource = null;
   this.Top100FileList.ItemsSource = null;
   if (tvi != null) {
      directoryDetail = (DirectoryDetail)tvi.Tag;
      this.FileList.ItemsSource = DirectoryHelper.FilesInDirectory(directoryDetail);
      this.Top100FileList.ItemsSource = DirectoryHelper.BiggestFilesInPath(directoryDetail, 100);
   }
}
' Populates the file list for a directory in descending order based on the file sizes
Private Sub ListDirectoryFiles(tvi As System.Windows.Controls.TreeViewItem)
   Dim directoryDetail As DirectoryDetail
   Me.FileList.ItemsSource = Nothing
   Me.Top100FileList.ItemsSource = Nothing
   If (Not tvi Is Nothing) Then
      directoryDetail = CType(tvi.Tag, DirectoryDetail)
      Me.FileList.ItemsSource = DirectoryHelper.FilesInDirectory(directoryDetail)
      Me.Top100FileList.ItemsSource = DirectoryHelper.BiggestFilesInPath(directoryDetail, 100)
   End If
End Sub

And the file list is fetched in the following code

/// <summary>
/// Lists all the files in a directory sorted by size in descending order
/// </summary>
/// <param name="directoryDetail"></param>
/// <returns></returns>
internal static System.Collections.Generic.List<FileDetail> FilesInDirectory(
                                                               DirectoryDetail directoryDetail) {
   System.Collections.Generic.List<FileDetail> fileList;

   lock (DirectoryHelper._lockObject) {
      fileList = DirectoryHelper.FileDetails
                 .Where(x => x.DirectoryDetail == directoryDetail)
                 .OrderByDescending(x => x.Size).ToList();
   }

   return fileList;
}
'  Lists all the files in a directory sorted by size in descending order
Friend Function FilesInDirectory(directoryDetail As DirectoryDetail) As System.Collections.Generic.List(Of FileDetail)
   Dim fileList As System.Collections.Generic.List(Of FileDetail)

   SyncLock (DirectoryHelper._lockObject)
      fileList = DirectoryHelper.FileDetails.Where(Function(x) x.DirectoryDetail Is directoryDetail)
                                            .OrderByDescending(Function(x) x.Size).ToList()
   End SyncLock

   Return fileList
End Function

Let's have a bit closer look on this. If the lock statement would commented out and the list is populated at the same time when the data gather is in progress, you have roughly 100% chance of hitting the following error

An unhandled exception of type 'System.InvalidOperationException' occurred in System.Core.dll
Additional information: Collection was modified; enumeration operation may not execute.

What just would have happened is that when we tried to populate the file list the other thread added data into the same collection simultaneously. So the enumeration failed because the content of the collection was changed.

In order to prevent this from happening we need to have a mechanism that allows only one thread to enumerate or modify the collection at a time. This means locking.

The lock statement ensures that only one thread can enter a critical section of code at a time. If another thread tries to lock the same object it'll have to wait until the lock is freed. In the beginning of the article we saw how a new DirectoryDetail was added to the collection like this

lock (DirectoryHelper._lockObject) {
   DirectoryDetails.Add(thisDirectoryDetail);
}
SyncLock (DirectoryHelper._lockObject)
   DirectoryDetails.Add(thisDirectoryDetail)
End SyncLock

So now when we read the collection we try to lock the same _lockObject. This ensures that enumeration and modifications are not done at the same time.

However, that's not the entire story. You may wonder why the enumeration code has a ToList() method call in the end of the statement. If the code would return only an IOrderedEnumerable<FileDetail> collection for the caller the actual enumeration would happen outside the scope of the lock statement.

In other words when this collection would return to the WPF control, it would try to enumerate the collection and the exact same problem would be hit; Collection was modified; enumeration operation may not execute. Because of this the code creates a copy, the list, inside the scope of the lock and after that it's safe to return the list and let the WPF control loop through the collection.

Stopping the execution

The last detail is how to stop the data gather process before all directories have been investigated. This again involves locking. There are few, controlled places where the execution is stopped if needed. Just before a directory investigation is done or after a sub directory is processed.

The code simply investigates if a stop is requested using a static variable, like this

// Break if stop is requested
lock (DirectoryHelper._lockObject) {
   if (DirectoryHelper._stopRequested) {
      break;
   }
}
' Break if stop is requested
SyncLock (DirectoryHelper._lockObject)
   If (DirectoryHelper._stopRequested) Then
      Exit For
   End If
End SyncLock

If the stop button is pressed during the data gather, the _stopRequest is changed in the StopDataGathering method

/// <summary>
/// Stops the data gathering process
/// </summary>
/// <param name="drive"></param>
/// <returns></returns>
internal static bool StopDataGathering() {
   if (DirectoryHelper._gatherTask.Status != System.Threading.Tasks.TaskStatus.Running) {
      return true;
   }
   lock (DirectoryHelper._lockObject) {
      DirectoryHelper._stopRequested = true;
   }
   DirectoryHelper._gatherTask.Wait();

   return true;
}
'  Stops the data gathering process
Friend Function StopDataGathering() As Boolean
   If (DirectoryHelper._gatherTask.Status <> System.Threading.Tasks.TaskStatus.Running) Then
      Return True
   End If
   SyncLock (DirectoryHelper._lockObject)
      DirectoryHelper._stopRequested = True
   End SyncLock
   DirectoryHelper._gatherTask.Wait()

   Return True
End Function

After the request status has been changed the UI thread calls Wait method for the task so that the UI thread is stopped until the data gather thread finishes.

So that covers the main parts of the program. There are a lot of details in the project if you want to investigate it but this should give the big picture how the application was made.

Oh, by the way, if you double click a file in any of the file lists it should open the containing folder for you.

Hopefully you find this useful and of course all comments and suggestions are more than welcome.

References

Here are some references that should be useful when going through the code

History

  • 10th February, 2015: Article created.
  • 13th February, 2015:
    • Added catch for PathTooLongException and reformatted the skipped directory info.
    • Added the ability to browse mapped network drives.
    • Added more frequent counter change in case of large directories.
  • 16th February, 2015: Alternative option for elevated privileges added.
  • 17th February, 2015: VB version added.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here