4

I'm using PowerShell to enable the user to browse for file/folder paths for a Node.js app (because I haven't found a better light-weight alternative so far), and I'm having the age old trouble of dealing with the horrible, poor usability FolderBrowserDialog that doesn't support:

  • pasting paths
  • accessing Quick Access items
  • changing the view
  • Sorting or filtering items
  • etc...

The bad dialog

The standard script looks like this:

Function Select-FolderDialog($Description="Select Folder", $RootFolder="MyComputer"){
    [System.Reflection.Assembly]::LoadWithPartialName("System.windows.forms") | Out-Null     
        
    $objForm = New-Object System.Windows.Forms.FolderBrowserDialog
    $objForm.RootFolder = $RootFolder
    $objForm.ShowNewFolderButton = $true
    $objForm.Description = "Please choose a folder"
    $Show = $objForm.ShowDialog()
    If ($Show -eq "OK")
    {
        Return $objForm.SelectedPath
    }
    Else
    {
        Write-Error "Operation cancelled by user."
    }
}
$folder = Select-FolderDialog
write-host $folder

I've used the Windows API CodePack for C# Windows Forms apps in the past to create a CommonOpenFileDialog with IsFolderPicker = true, giving me the features and accessibility of the OpenFileDialog with the ease of use of a managed folder browser. Better, but non-standard dialog

in my search of a way to use something like this here as well, I learned that the regular FolderBrowserDialog got an upgrade, at least in .Net Core.
Huh, that's neat.

Is there any way I can access the upgraded version from a PowerShell script?

Adding $objForm.AutoUpgradeEnabled = $true to the above code doesn't change anything (and is indeed the default)
(Also, if somebody has a good idea how to provide a decent Folder Browser Dialog to a Node.js app in a more direct way, drop me a comment ^^)

Workarounds so far:

1: Abuse an OpenFileDialog

Function Select-FolderDialog($Description="Select Folder", $RootFolder="MyComputer"){
    [System.Reflection.Assembly]::LoadWithPartialName("System.windows.forms") | Out-Null     

    $objForm = New-Object System.Windows.Forms.OpenFileDialog
    $objForm.DereferenceLinks = $true
    $objForm.CheckPathExists = $true
    $objForm.FileName = "[Select this folder]"
    $objForm.Filter = "Folders|`n"
    $objForm.AddExtension = $false
    $objForm.ValidateNames = $false
    $objForm.CheckFileExists = $false
    $Show = $objForm.ShowDialog()
    If ($Show -eq "OK")
    {
        Return $objForm.FileName
    }
    Else
    {
        Write-Error "Operation cancelled by user."
    }
}

$folder = Select-FolderDialog
write-host $folder

This creates a dialog inheriting from the much nicer FileDialog Class, which displays only folders and enables you to return a path like "C:\Some dir\Dir I want\[Select this folder]", even when it doesn't exist, which I can then trim back to "C:\Some dir\Dir I want".

Pros:

  • Full featured browser, as desired

Cons:

  • The file name field can't be empty. Trying to use something like a newline character results in an error dialog complaining about an invalid filename and the FileOpenDialog refusing to return the filename, even though ValidateNames is false.
  • The user could enter anything in the filename field, which could lead to confusion.
  • When you navigate up a folder tree, clicking the accept button ("Open") simply browses back to the previously selected child directory, even if you deselect it
  • You have to trim away the last part of the returned path and hope that no further weirdness happened as a result of selecting a non-existent file

2: Give a standard dialog an Edit control

Using the Shell.BrowseForFolder method

# Included the options values from https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-browseinfoa
$BIF_RETURNONLYFSDIRS = [uint32]"0x00000001"
$BIF_DONTGOBELOWDOMAIN = [uint32]"0x00000002"
$BIF_STATUSTEXT = [uint32]"0x00000004"
$BIF_RETURNFSANCESTORS = [uint32]"0x00000008"
$BIF_EDITBOX = [uint32]"0x00000010" # <-- this is the important one
$BIF_VALIDATE = [uint32]"0x00000020"
$BIF_NEWDIALOGSTYLE = [uint32]"0x00000040" # <-- this sounds nice, but somehow changes nothing
$BIF_BROWSEINCLUDEURLS = [uint32]"0x00000080"
$BIF_USENEWUI = $BIF_NEWDIALOGSTYLE
$BIF_UAHINT = [uint32]"0x00000100"
$BIF_NONEWFOLDERBUTTON = [uint32]"0x00000200"
$BIF_NOTRANSLATETARGETS = [uint32]"0x00000400"
$BIF_BROWSEFORCOMPUTER = [uint32]"0x00001000"
$BIF_BROWSEFORPRINTER = [uint32]"0x00002000"
$BIF_BROWSEINCLUDEFILES = [uint32]"0x00004000"
$BIF_SHAREABLE = [uint32]"0x00008000"
$BIF_BROWSEFILEJUNCTIONS = [uint32]"0x00010000"

$options = 0
$options += $BIF_STATUSTEXT
$options += $BIF_EDITBOX
$options += $BIF_VALIDATE
$options += $BIF_NEWDIALOGSTYLE
$options += $BIF_BROWSEINCLUDEURLS
$options += $BIF_SHAREABLE
$options += $BIF_BROWSEFILEJUNCTIONS

$shell = new-object -comobject Shell.Application
$folder = $shell.BrowseForFolder(0, "Select a folder", $options)
if($folder){
    write-host $folder.Self.Path()
}

I included the options for clarity, but you could hard-code all of the the above into $folder = $shell.BrowseForFolder(0, "Select a folder", 98548), which is neat.
Look mom, auto-completing UNC paths
Pros:

  • Using the folder browser dialog as intended
  • Robust UX
  • Can paste a path
  • Supports UNC paths
  • Supports auto-complete

Cons:

  • No side panel with Quick Access Items, etc
  • Can't change view, sort, etc
  • No previews/thumbnails
Ben Philipp
  • 1,832
  • 1
  • 14
  • 29
  • Impressive amount of disk storage you have there – stuartd Mar 26 '21 at 20:44
  • @stuartd :D I suffer from an unfortunate combination of _wanting to save everything_ and _FOMO on things when they aren't complete_. It feels good to have everything, but it gets expensive, especially when you do your own backups >:( – Ben Philipp Mar 26 '21 at 21:19

1 Answers1

8

Wow, you can use C# in PowerShell!

Looking around, I got envious of everybody playing around in C# and leveraging cool features I don't know how to access in PowerShell.
I liked this approach, for example, which doesn't rely on legacy APIs and has a fallback for unsupported systems.
And then I saw that you can use actual C# in PowerShell! I put the two together, modified the code a bit to make it easier to call from PS, and out comes a fairly lightweight, hopefully robust way to summon the best Folder Browser Dialog available to the user:

[Revised code below]

I'm interested to hear opinions on how reliable the whole approach likely is.
How likely is it that some reference can't be accessed or that something else goes wrong?

Anyway, I'm pretty happy with this approach for now :)

Edit: PowerShell "Core" (pwsh.exe; Newer assemblies)

So, as @mklement0 pointed out in the comments, the actively developed PowerShell (formerly (and hereafter, for the sake of readability) known as "PowerShell Core"; as oppowed to the Windows PowerShell that comes with Windows) doesn't seem to play as nicely with this. After looking into what PS Core unhelpfully only reported as "The type initializer for 'VistaDialog' threw an exception.", (and adding a reference to System.ComponentModel.Primitives), it turns out PS Core tends to use a newer version of System.Windows.Forms, in my casee 5.0.4.0, which doesn't contain a type FileDialogNative, let alone its nested type IFileDialog.
I tried to force it to use the version that Windows PS referenced (4.0.0.0), but it wouldn't comply.

Well, I finally had the penny drop and had PS Core simply use the default dialog, which is already the upgraded one I was after in the first place

So, along with the conceptual change of introducing a constructor method with optional parameters, I added a fallback for a failing "Vista Dialog".
Instead of checking for versions of PS or individual assemblies and/or classes/types, I simply wrapped the call in a try/catch block.

$path = $args[0]
$title = $args[1]
$message = $args[2]

$source = @'
using System;
using System.Diagnostics;
using System.Reflection;
using System.Windows.Forms;
/// <summary>
/// Present the Windows Vista-style open file dialog to select a folder. Fall back for older Windows Versions
/// </summary>
#pragma warning disable 0219, 0414, 0162
public class FolderSelectDialog {
    private string _initialDirectory;
    private string _title;
    private string _message;
    private string _fileName = "";
    
    public string InitialDirectory {
        get { return string.IsNullOrEmpty(_initialDirectory) ? Environment.CurrentDirectory : _initialDirectory; }
        set { _initialDirectory = value; }
    }
    public string Title {
        get { return _title ?? "Select a folder"; }
        set { _title = value; }
    }
    public string Message {
        get { return _message ?? _title ?? "Select a folder"; }
        set { _message = value; }
    }
    public string FileName { get { return _fileName; } }

    public FolderSelectDialog(string defaultPath="MyComputer", string title="Select a folder", string message=""){
        InitialDirectory = defaultPath;
        Title = title;
        Message = message;
    }
    
    public bool Show() { return Show(IntPtr.Zero); }

    /// <param name="hWndOwner">Handle of the control or window to be the parent of the file dialog</param>
    /// <returns>true if the user clicks OK</returns>
    public bool Show(IntPtr? hWndOwnerNullable=null) {
        IntPtr hWndOwner = IntPtr.Zero;
        if(hWndOwnerNullable!=null)
            hWndOwner = (IntPtr)hWndOwnerNullable;
        if(Environment.OSVersion.Version.Major >= 6){
            try{
                var resulta = VistaDialog.Show(hWndOwner, InitialDirectory, Title, Message);
                _fileName = resulta.FileName;
                return resulta.Result;
            }
            catch(Exception){
                var resultb = ShowXpDialog(hWndOwner, InitialDirectory, Title, Message);
                _fileName = resultb.FileName;
                return resultb.Result;
            }
        }
        var result = ShowXpDialog(hWndOwner, InitialDirectory, Title, Message);
        _fileName = result.FileName;
        return result.Result;
    }

    private struct ShowDialogResult {
        public bool Result { get; set; }
        public string FileName { get; set; }
    }

    private static ShowDialogResult ShowXpDialog(IntPtr ownerHandle, string initialDirectory, string title, string message) {
        var folderBrowserDialog = new FolderBrowserDialog {
            Description = message,
            SelectedPath = initialDirectory,
            ShowNewFolderButton = true
        };
        var dialogResult = new ShowDialogResult();
        if (folderBrowserDialog.ShowDialog(new WindowWrapper(ownerHandle)) == DialogResult.OK) {
            dialogResult.Result = true;
            dialogResult.FileName = folderBrowserDialog.SelectedPath;
        }
        return dialogResult;
    }

    private static class VistaDialog {
        private const string c_foldersFilter = "Folders|\n";
        
        private const BindingFlags c_flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
        private readonly static Assembly s_windowsFormsAssembly = typeof(FileDialog).Assembly;
        private readonly static Type s_iFileDialogType = s_windowsFormsAssembly.GetType("System.Windows.Forms.FileDialogNative+IFileDialog");
        private readonly static MethodInfo s_createVistaDialogMethodInfo = typeof(OpenFileDialog).GetMethod("CreateVistaDialog", c_flags);
        private readonly static MethodInfo s_onBeforeVistaDialogMethodInfo = typeof(OpenFileDialog).GetMethod("OnBeforeVistaDialog", c_flags);
        private readonly static MethodInfo s_getOptionsMethodInfo = typeof(FileDialog).GetMethod("GetOptions", c_flags);
        private readonly static MethodInfo s_setOptionsMethodInfo = s_iFileDialogType.GetMethod("SetOptions", c_flags);
        private readonly static uint s_fosPickFoldersBitFlag = (uint) s_windowsFormsAssembly
            .GetType("System.Windows.Forms.FileDialogNative+FOS")
            .GetField("FOS_PICKFOLDERS")
            .GetValue(null);
        private readonly static ConstructorInfo s_vistaDialogEventsConstructorInfo = s_windowsFormsAssembly
            .GetType("System.Windows.Forms.FileDialog+VistaDialogEvents")
            .GetConstructor(c_flags, null, new[] { typeof(FileDialog) }, null);
        private readonly static MethodInfo s_adviseMethodInfo = s_iFileDialogType.GetMethod("Advise");
        private readonly static MethodInfo s_unAdviseMethodInfo = s_iFileDialogType.GetMethod("Unadvise");
        private readonly static MethodInfo s_showMethodInfo = s_iFileDialogType.GetMethod("Show");

        public static ShowDialogResult Show(IntPtr ownerHandle, string initialDirectory, string title, string description) {
            var openFileDialog = new OpenFileDialog {
                AddExtension = false,
                CheckFileExists = false,
                DereferenceLinks = true,
                Filter = c_foldersFilter,
                InitialDirectory = initialDirectory,
                Multiselect = false,
                Title = title
            };

            var iFileDialog = s_createVistaDialogMethodInfo.Invoke(openFileDialog, new object[] { });
            s_onBeforeVistaDialogMethodInfo.Invoke(openFileDialog, new[] { iFileDialog });
            s_setOptionsMethodInfo.Invoke(iFileDialog, new object[] { (uint) s_getOptionsMethodInfo.Invoke(openFileDialog, new object[] { }) | s_fosPickFoldersBitFlag });
            var adviseParametersWithOutputConnectionToken = new[] { s_vistaDialogEventsConstructorInfo.Invoke(new object[] { openFileDialog }), 0U };
            s_adviseMethodInfo.Invoke(iFileDialog, adviseParametersWithOutputConnectionToken);

            try {
                int retVal = (int) s_showMethodInfo.Invoke(iFileDialog, new object[] { ownerHandle });
                return new ShowDialogResult {
                    Result = retVal == 0,
                    FileName = openFileDialog.FileName
                };
            }
            finally {
                s_unAdviseMethodInfo.Invoke(iFileDialog, new[] { adviseParametersWithOutputConnectionToken[1] });
            }
        }
    }

    // Wrap an IWin32Window around an IntPtr
    private class WindowWrapper : IWin32Window {
        private readonly IntPtr _handle;
        public WindowWrapper(IntPtr handle) { _handle = handle; }
        public IntPtr Handle { get { return _handle; } }
    }
    
    public string getPath(){
        if (Show()){
            return FileName;
        }
        return "";
    }
}
'@
Add-Type -Language CSharp -TypeDefinition $source -ReferencedAssemblies ("System.Windows.Forms", "System.ComponentModel.Primitives")
([FolderSelectDialog]::new($path, $title, $message)).getPath()

This should work for Windows PowerShell (final version ~5.1) and current PS "Core" (pwsh.exe ~7.1.3)

Ben Philipp
  • 1,832
  • 1
  • 14
  • 29
  • Nicely done. Since you don't need _string interpolation_, I suggest using a [_single-quoted_ here-string](https://stackoverflow.com/a/55614306/45375), to prevent accidental expansion of `$`-prefixed tokens by PowerShell. ` -Language CSharp ` is the _default_ (I have yet to see a non-C#-based example, frankly), and the only assembly you need to reference explicitly is `-ReferencedAssemblies System.Windows.Forms`. – mklement0 Mar 26 '21 at 21:02
  • 1
    As for reliability: The compilation is based on the .NET framework underlying the PowerShell version, which changes over time, though no longer for _Windows PowerShell_, which is based on .NET Framework - neither of which will see new versions (except critical bug fixes). By contrast, PowerShell (Core), the cross-platform edition based on .NET Core / .NET 5+ will see versions, and, unfortunately, your code does _not_ work there: It fails with `The type initializer for 'VistaDialog' threw an exception.`, for reasons unknown to me. – mklement0 Mar 26 '21 at 21:05
  • 1
    @mklement0 Wow, that's super useful! And I didn't even know there was a PS Core. I'll try adding a constructor method, since the original didn't have one. I didn't want to change it too much, but now I'll do away with the one-shot method I added and instead reorganize the setup with a proper constructor. Thank you for your insight AND testing!! <3 – Ben Philipp Mar 26 '21 at 21:15
  • My pleasure, Ben - glad to hear it was helpful. It would certainly be nice to get this to work in [PowerShell (Core)](https://github.com/PowerShell/PowerShell/blob/master/README.md) too (on Windows), if possible. – mklement0 Mar 26 '21 at 21:21
  • 1
    @mklement0 Uh, so... Turns out PS Core likes to reference a newer version of `System.Windows.Forms`, which no longer includes `FileDialogNative`, let alone its nested type `IFileDialog`. Good news is: It doesn't, because it **already uses the upgraded Folder Browser Dialog** I was after :D ...That's a couple of hours of debugging and trying to force PSC to use older references that I could've done without ^^ – Ben Philipp Mar 27 '21 at 18:04
  • 1
    Updated. Thank you for the further insight, I'm learning a lot about PowerShell :) – Ben Philipp Mar 28 '21 at 13:25
  • I'm glad you're willing to, Ben - thanks for updating. – mklement0 Mar 28 '21 at 13:45
  • @BenPhilipp I'm developing an MIT-licensed terminal utils package. May I please use your code in that project under the MIT license? – aggregate1166877 Nov 25 '22 at 04:28
  • 1
    @aggregate1166877 of course, that's why I shared it :) – Ben Philipp Nov 26 '22 at 06:52