Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

SCRIPT LIBRARY · POWERSHELL

Take Control of Explorer's Preview Pane with PowerShell

List the preview handlers installed on a PC, see which one each file type uses, and point extensions like .log, .ps1 or .json at the handler you want.

AT A GLANCESet-PreviewHandler.ps1
What it does
Lists registered Explorer preview handlers, shows which handler a file extension is using, and maps extensions to a handler (or removes the mapping) for the current user or the whole machine.
Requires
  • PowerShell 7+ or Windows PowerShell 5.1
  • No modules
Permissions
None for -Scope CurrentUser. Local admin for -Scope LocalMachine.
Runs on
Windows 10/11
Tested
Parse-checked and dry-run with a mocked registry in PowerShell 7.4 (listing, name and CLSID lookup, existing-mapping protection, -Force, -Remove and -WhatIf)

The preview pane (Alt+P in Explorer) is one of those features people either love or forget exists. Click a file, see what's in it, no app launch. Except half the time you click a .log or .ps1 and get "No preview available," even though it's plain text and Windows ships a perfectly good text previewer.

That's because preview handlers are wired up per file type. A handler is a COM object, registered in HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\PreviewHandlers, and Explorer finds the right one for a file through a shellex key named {8895b1c6-b41f-4c1c-a562-0d564250836f} under the file's extension (or its ProgID). If that key isn't there, you get nothing. This script lists what's installed, shows what each extension is using, and adds or removes that mapping.

The earlier version of this post installed a few apps with Chocolatey and changed their file associations with assoc and ftype. That changes which app opens a file, which isn't the same thing as a preview handler, so it didn't really do what the title said. Third-party handlers (for PDFs, 3D models, Markdown and so on) come with their own installers, including Microsoft's PowerToys. Install whichever you like, then use this to see and control where they apply.

Set-PreviewHandler.ps1Download
<#
.SYNOPSIS
    Lists Explorer preview handlers and maps file extensions to them.
.DESCRIPTION
    Preview handlers are COM objects registered under
    HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\PreviewHandlers. Explorer finds the one for a
    file type through the shellex key {8895b1c6-b41f-4c1c-a562-0d564250836f} under the extension
    (or its ProgID). This script shows what's installed, shows which handler an extension uses,
    and points an extension at a handler you choose, or removes that mapping. Supports -WhatIf.
.PARAMETER ListHandlers
    List every registered preview handler with its CLSID.
.PARAMETER Extension
    One or more extensions, like .log or .ps1. On its own, shows the current mapping.
.PARAMETER Handler
    The handler to use: a CLSID, or part of its name as shown by -ListHandlers.
.PARAMETER Remove
    Delete the mapping for the extension at the chosen scope.
.PARAMETER Scope
    CurrentUser (HKCU, no admin needed) or LocalMachine (HKLM, needs admin). Default CurrentUser.
.PARAMETER Force
    Replace an existing mapping that points at a different handler.
.EXAMPLE
    .\Set-PreviewHandler.ps1 -ListHandlers
.EXAMPLE
    .\Set-PreviewHandler.ps1 -Extension .log, .ps1, .json -Handler '{1531d583-8375-4d3f-b5fb-d23bbd169f22}' -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Show')]
param(
    [Parameter(Mandatory, ParameterSetName = 'List')][switch]$ListHandlers,
    [Parameter(Mandatory, ParameterSetName = 'Show')]
    [Parameter(Mandatory, ParameterSetName = 'Set')]
    [Parameter(Mandatory, ParameterSetName = 'Remove')]
    [ValidatePattern('^\.[\w\-\.]+$')][string[]]$Extension,
    [Parameter(Mandatory, ParameterSetName = 'Set')][string]$Handler,
    [Parameter(Mandatory, ParameterSetName = 'Remove')][switch]$Remove,
    [Parameter(ParameterSetName = 'Set')]
    [Parameter(ParameterSetName = 'Remove')]
    [ValidateSet('CurrentUser', 'LocalMachine')][string]$Scope = 'CurrentUser',
    [Parameter(ParameterSetName = 'Set')][switch]$Force
)

$previewGuid = '{8895b1c6-b41f-4c1c-a562-0d564250836f}'
$hkcr = 'Registry::HKEY_CLASSES_ROOT'

function Get-DefaultValue([string]$Key) {
    if (Test-Path -LiteralPath $Key) { (Get-ItemProperty -LiteralPath $Key -ErrorAction SilentlyContinue).'(default)' }
}

# Every registered handler: value name is the CLSID, value data is the friendly name.
$handlers = foreach ($root in 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\PreviewHandlers', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\PreviewHandlers') {
    if (-not (Test-Path -LiteralPath $root)) { continue }
    $props = Get-ItemProperty -LiteralPath $root
    foreach ($p in $props.PSObject.Properties | Where-Object Name -match '^\{[0-9a-fA-F\-]{36}\}$') {
        [pscustomobject]@{ Name = $p.Value; Clsid = $p.Name.ToLower() }
    }
}
$handlers = @($handlers | Sort-Object Clsid -Unique | Sort-Object Name)

if ($ListHandlers) { return $handlers }

foreach ($ext in $Extension.ToLower()) {
    $userKey = "HKCU:\Software\Classes\$ext\shellex\$previewGuid"
    $key = if ($Scope -eq 'LocalMachine') { "HKLM:\Software\Classes\$ext\shellex\$previewGuid" } else { $userKey }

    if ($PSCmdlet.ParameterSetName -eq 'Show') {
        # Look where handlers usually get registered: the extension, its ProgID, and SystemFileAssociations.
        $progId = Get-DefaultValue "$hkcr\$ext"
        $found = $null
        foreach ($where in @("$hkcr\$ext", $(if ($progId) { "$hkcr\$progId" }), "$hkcr\SystemFileAssociations\$ext")) {
            if (-not $where) { continue }
            $clsid = Get-DefaultValue "$where\shellex\$previewGuid"
            if ($clsid) { $found = [pscustomobject]@{ Extension = $ext; Handler = ($handlers | Where-Object Clsid -eq $clsid.ToLower()).Name; Clsid = $clsid; FoundAt = $where -replace '^Registry::', '' }; break }
        }
        if (-not $found) { $found = [pscustomobject]@{ Extension = $ext; Handler = '(none)'; Clsid = $null; FoundAt = $null } }
        $found
        continue
    }

    try {
        if ($Remove) {
            if (-not (Test-Path -LiteralPath $key)) { Write-Verbose "$ext has no mapping at $Scope scope."; continue }
            if ($PSCmdlet.ShouldProcess("$ext ($Scope)", 'Remove preview handler mapping')) {
                Remove-Item -LiteralPath $key -Recurse -ErrorAction Stop
                [pscustomobject]@{ Extension = $ext; Scope = $Scope; Status = 'Removed'; Clsid = $null }
            }
            continue
        }

        $target = if ($Handler -match '^\{?[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\}?$') {
            '{' + $Handler.Trim('{}').ToLower() + '}'
        }
        else {
            $hits = @($handlers | Where-Object Name -like "*$Handler*")
            if ($hits.Count -ne 1) { throw "'$Handler' matches $($hits.Count) handlers. Use -ListHandlers and pass the CLSID." }
            $hits[0].Clsid
        }
        if ($handlers.Clsid -notcontains $target -and -not $Force) { throw "$target isn't a registered preview handler. Install the handler first, or use -Force to map it anyway." }

        $current = Get-DefaultValue $key
        if ($current -and $current -ne $target -and -not $Force) {
            [pscustomobject]@{ Extension = $ext; Scope = $Scope; Status = "SkippedExisting ($current), use -Force"; Clsid = $current }
            continue
        }
        if ($PSCmdlet.ShouldProcess("$ext ($Scope)", "Set preview handler to $target")) {
            if (-not (Test-Path -LiteralPath $key)) { $null = New-Item -Path $key -Force -ErrorAction Stop }
            Set-ItemProperty -LiteralPath $key -Name '(default)' -Value $target -ErrorAction Stop
            [pscustomobject]@{ Extension = $ext; Scope = $Scope; Status = 'Set'; Clsid = $target }
        }
    }
    catch {
        Write-Warning "${ext}: $($_.Exception.Message)"
    }
}

Parameters

ParameterTypeDefaultWhat it's for
-ListHandlersswitch—List every registered preview handler, with its name and CLSID.
-Extensionstring[]—One or more extensions, with the dot. On its own, shows which handler each one uses and where the mapping lives.
-Handlerstring—The handler to map to. A CLSID, or part of the handler's name as -ListHandlers shows it. A name has to match exactly one handler.
-Removeswitch—Delete the mapping for the extension at the chosen scope. Honors -WhatIf.
-ScopestringCurrentUserCurrentUser writes to HKCU and needs no admin rights. LocalMachine writes to HKLM for everyone on the PC.
-Forceswitch—Replace a mapping that already points at a different handler, or map a CLSID that isn't in the registered list.

Run it

What's installed on this PC?

.\Set-PreviewHandler.ps1 -ListHandlers

Which handler do these file types use right now?

.\Set-PreviewHandler.ps1 -Extension .txt, .log, .ps1, .md

Preview scripts and logs as plain text, with Windows' built-in text previewer.

.\Set-PreviewHandler.ps1 -Extension .log, .ps1, .psm1, .json, .yml -Handler '{1531d583-8375-4d3f-b5fb-d23bbd169f22}' -WhatIf

Undo it for one type.

.\Set-PreviewHandler.ps1 -Extension .json -Remove

What you'll see

Example outputvalues are illustrative
Extension Scope       Status                                                      Clsid
--------- -----       ------                                                      -----
.log      CurrentUser Set                                                         {1531d583-8375-4d3f-b5fb-d23bbd169f22}
.ps1      CurrentUser Set                                                         {1531d583-8375-4d3f-b5fb-d23bbd169f22}
.psm1     CurrentUser Set                                                         {1531d583-8375-4d3f-b5fb-d23bbd169f22}
.json     CurrentUser SkippedExisting ({11111111-2222-3333-4444-555555555555}), use -Force {11111111-2222-3333-4444-555555555555}
.yml      CurrentUser Set                                                         {1531d583-8375-4d3f-b5fb-d23bbd169f22}

How it works

  1. Read the handler list. Every registered handler is a value under HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\PreviewHandlers (and its WOW6432Node twin), where the value name is the CLSID and the data is the friendly name. -ListHandlers just returns that.
  2. Show a mapping. With only -Extension, the script looks in the merged HKEY_CLASSES_ROOT view under the extension, its ProgID, and SystemFileAssociations, for a shellex\{8895b1c6-...} key, and reports the first one it finds and where it lives.
  3. Resolve the handler. -Handler can be a CLSID or part of a name. A name has to match exactly one registered handler, and a CLSID that isn't registered is refused unless you pass -Force, because mapping to a handler that doesn't exist just gets you a blank pane.
  4. Write it. The mapping goes in Software\Classes\<ext>\shellex\{8895b1c6-b41f-4c1c-a562-0d564250836f} under HKCU or HKLM, with the handler's CLSID as the default value. An existing mapping to a different handler is left alone unless you pass -Force.
  5. Everything that writes goes through ShouldProcess. Setting and removing both honor -WhatIf and -Confirm.

Take it further

  • Roll it out with Intune or Group Policy. A per-user mapping is just a registry value under HKCU, so a Group Policy Preferences registry item or a remediation script can push the same thing to every machine.
  • Map a whole family at once. Keep a list of text-ish extensions (.log, .ini, .cfg, .ps1, .psm1, .psd1, .json, .yml, .md, .csv) in a file and pass it straight to -Extension.
  • Audit before you change. Run the show mode across a list of extensions on a few PCs first. You'll learn which apps have already claimed which types.

Things that'll trip you up

  • Explorer caches. A new mapping usually shows up the next time you click a file, but sometimes Explorer hangs on to the old answer. Close and reopen the window, or restart Explorer from Task Manager, before deciding it didn't work.
  • Downloaded files may never preview. Recent Windows security updates block the preview pane for files carrying the Mark of the Web (anything downloaded from the internet). If a downloaded file won't preview but a local copy will, that's why. Unblock it in the file's Properties if you trust it.
  • The ProgID can have its own handler. The script writes the mapping under the extension. If an app registered a different handler under the file type's ProgID, that one can win. Run the script with just -Extension to see what's found and where.
  • Check the CLSID on your build. {1531d583-8375-4d3f-b5fb-d23bbd169f22} is the text previewer on Windows 10 and 11, but run -ListHandlers and check before you roll a mapping out to a fleet.
  • Handlers run code on every click. A preview handler parses the file the moment you select it. Only install handlers from sources you trust, and don't map risky types (like .hta or .lnk) to anything.