Skip to content

Instantly share code, notes, and snippets.

@jcwillox
Last active January 23, 2024 03:54
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save jcwillox/a9b480f8d180d44368e7df2eb7009207 to your computer and use it in GitHub Desktop.
Save jcwillox/a9b480f8d180d44368e7df2eb7009207 to your computer and use it in GitHub Desktop.
PowerShell script to automatically add context menu entries for Jetbrains IDEs
<#
.SYNOPSIS
Automatically add context menu entries for Jetbrains IDEs.
.PARAMETER Name
The name or names of the IDEs to add context menus for, use -List to see available IDEs.
.PARAMETER BasePath
The path to the Toolbox apps directory, defaults to "$env:LOCALAPPDATA\JetBrains\Toolbox\apps".
.PARAMETER Global
Install context menu entries in HKLM registry (machine wide), requires running as administrator.
.PARAMETER Force
Overwrite current registry entries, useful when updating existing entries.
.PARAMETER Remove
Will remove the context menu entries for specified IDEs
.PARAMETER List
List available IDEs installed via Toolbox
.PARAMETER UseNircmd
Will use nircmd (if installed) to invisibly run the IDE's batch script, this will avoid you having to
re-run this script each time an IDE is updated via Toolbox.
.PARAMETER NirCmdPath
Specify the location of the nircmd executable to use, will attempt to locate it from $PATH if not specified.
.PARAMETER AppDir
If you are not using Toolbox you can specify the IDE's installation directory.
.PARAMETER DefaultChannel
Specify the default channel to use when there are multiple channels found, the user will be prompted to
choose a channel if the default channel is not found.
#>
[CmdletBinding()]
param (
[array]$Name,
[string]$BasePath,
[switch]$Global,
[switch]$Force,
[switch]$Remove,
[switch]$List,
[switch]$UseNircmd,
[string]$NirCmdPath,
[string]$AppDir,
[ArgumentCompleter({ return @("'Release'", "'Early Access Program'") })]
[string]$DefaultChannel
)
$ErrorActionPreference = "Stop"
trap { throw $Error[0] }
$toolbox = if ($BasePath) { $BasePath } else { Join-Path $env:LOCALAPPDATA "JetBrains\Toolbox\apps" }
$regRoot = if ($Global) { "HKLM" } else { "HKCU" }
Write-Verbose "Toolbox: '$toolbox'"
if ($UseNircmd -and -not (Get-Command "nircmd.exe" -ErrorAction Ignore)) {
Write-Error "nircmd.exe is not installed or missing from the path"
Write-Error "either install nircmd or specify its location using '-NirCmdPath'"
return
}
function Get-Channel([string]$Path) {
$channels = Get-ChildItem (Join-Path $Path "ch-*")
if (-not $channels) {
Write-Warning "no channels found in '$Path'"
return
}
# immediately return if only 1 channel
if ($channels.Length -eq 1) {
return $channels[0]
}
$channelsOutput = ""
for ($i = 0; $i -lt $channels.Length; $i++) {
$channel = $channels[$i]
# extract channel type
$channelSettings = (Get-Content (Join-Path $channel.FullName ".channel.settings.json") | ConvertFrom-Json)
$channelName = $channelSettings.filter.quality_filter.name
# skip the prompt if we match the default channel
if ($DefaultChannel -eq $channelName) {
return $channel
}
$channelsOutput += "[$i]: '$($channel.BaseName)' ($channelName)"
if ($i -lt $channels.Length - 1) {
$channelsOutput += "`n"
}
}
# prompt user to select which channel to use
Write-Host (Split-Path -Leaf $Path) -ForegroundColor Green
Write-Host $channelsOutput
$i = $null
while (-not ($i -ge 0 -and $i -lt $channels.Length)) {
if ($null -ne $i) {
Write-Host "'$i' is not a valid index." -ForegroundColor Red
}
$i = Read-Host "Enter the number of the desired channel"
}
return $channels[$i]
}
function Get-IDEs {
$IDEs = @{}
foreach ($item in (Get-ChildItem $toolbox -Exclude Toolbox)) {
$channel = Get-Channel -Path $item.FullName
if (-not $channel) { continue }
$versionPath = (Get-ChildItem -Path $channel -Directory -Exclude "*.plugins" | Sort-Object BaseName -Descending | Select-Object -First 1 | Select-Object -ExpandProperty FullName)
Write-Verbose "Version Path: '$versionPath'"
$binPath = Join-Path $versionPath "bin"
if (Test-Path $binPath) {
$IDEs[$item.BaseName] = $versionPath
}
}
return $IDEs
}
if ($List) {
return Get-IDEs
}
function Add-ShellKeys {
param (
[string]$Name,
[string]$ExePath,
[string]$Path,
[string]$Action,
[string]$LaunchArgs
)
$Path = Join-Path "${regRoot}:" $Path
if ($UseNircmd) {
# extract shell link path
$scriptPath = Get-Content (Join-Path (Split-Path $ExePath) "../.." ".shellLink")
# move icon outside of version directory so it will survive updates
$baseName = (Split-Path $ExePath -LeafBase).Replace("64", "")
$iconPath = Join-Path (Split-Path $ExePath) "$baseName.ico"
$destPath = Join-Path (Split-Path (Split-Path (Split-Path $ExePath))) "$baseName.ico"
Move-Item -Path $iconPath -Destination $destPath -ErrorAction Ignore
$iconPath = "`"$destPath`",0"
# contruct args
$ExePath = if ($NirCmdPath) { $NirCmdPath } else { (Get-Command "nircmd.exe").Path }
$LaunchArgs = "execmd $scriptPath $LaunchArgs"
} else {
$iconPath = $ExePath
}
if (-not $Force -and (Test-Path -LiteralPath "$Path\$Name")) {
Write-Host "EXISTS: $Path\$Name" -f Yellow
} else {
New-Item `
-Path "$Path\$Name" `
-Value "$Action with $Name" `
-Force:$Force `
-Confirm:$false | Out-Null
Write-Host "ADDED: $Path\$Name" -f DarkGreen
}
if (-not $Force -and (Get-ItemProperty -LiteralPath "$Path\$Name" -Name "Icon" -ErrorAction Ignore)) {
Write-Host "EXISTS: $Path\$Name [Icon]" -f Yellow
} else {
New-ItemProperty `
-LiteralPath "$Path\$Name" `
-PropertyType ExpandString `
-Name "Icon" `
-Value $iconPath `
-Force:$Force `
-Confirm:$false | Out-Null
Write-Host "ADDED: $Path\$Name [Icon]" -f DarkGreen
}
if (-not $Force -and (Get-Item -LiteralPath "$Path\$Name\command" -ErrorAction Ignore)) {
Write-Host "EXISTS: $Path\$Name\command" -f Yellow
} else {
New-Item `
-Path "$Path\$Name\command" `
-Value "`"$exePath`" $LaunchArgs" `
-Force:$Force `
-Confirm:$false | Out-Null
Write-Host "ADDED: $Path\$Name\command" -f DarkGreen
}
}
function Add-RegKey([string]$Path) {
$Path = Join-Path "${regRoot}:" $Path
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -Path $Path -Force -ErrorAction Ignore -Confirm:$false | Out-Null
}
}
function Add-ContextMenu {
param (
[string]$Name,
[string]$ExePath
)
# ensure base folders exist
Add-RegKey "SOFTWARE\Classes\`*\shell"
Add-RegKey "SOFTWARE\Classes\Directory\shell"
Add-RegKey "SOFTWARE\Classes\Directory\Background\shell"
# add to file context menu
Write-Output "ACTION: Edit with $Name (Files)"
Add-ShellKeys $Name $ExePath "SOFTWARE\Classes\*\shell" "Edit" "`"%1`""
# add to directory context menu
Write-Output "ACTION: Open with $Name (Directory)"
Add-ShellKeys $Name $ExePath "SOFTWARE\Classes\Directory\shell" "Open" "`"%1`""
# add to directory background context menu
Write-Output "ACTION: Open with $Name (Directory Background)"
Add-ShellKeys $Name $ExePath "SOFTWARE\Classes\Directory\Background\shell" "Open" "`"%V`""
}
function Remove-Reg([string]$Path) {
$Path = Join-Path "${regRoot}:" $Path
if (-not (Test-Path -LiteralPath $Path)) {
Write-Host "MISSING: $Path" -f Yellow
} else {
Remove-Item -LiteralPath $Path -Recurse
Write-Host "REMOVED: $Path" -f Red
}
}
function Remove-ContextMenu([string]$Name) {
Remove-Reg "SOFTWARE\Classes\*\shell\$Name"
Remove-Reg "SOFTWARE\Classes\Directory\shell\$Name"
Remove-Reg "SOFTWARE\Classes\Directory\Background\shell\$Name"
}
function Start-ContextMenu([string]$AppDir) {
$info = (Get-Content (Join-Path $AppDir "product-info.json") | ConvertFrom-Json)
$friendlyName = $info.Name
$exePath = Join-Path $AppDir $info.launch[0].launcherPath
if ($Remove) {
Write-Host "Removing Context Menu for '$friendlyName'" -ForegroundColor Cyan
Remove-ContextMenu -Name $friendlyName
} else {
Write-Host "Adding Context Menu for '$friendlyName'" -ForegroundColor Cyan
Add-ContextMenu -Name $friendlyName -ExePath $exePath
}
}
if ($AppDir) {
Start-ContextMenu $AppDir
} else {
$ides = (Get-IDEs)
foreach ($ide in $ides.Keys) {
foreach ($match in $Name) {
if (($match -eq "*") -or ($ide.ToLower().StartsWith($match.ToLower()))) {
Start-ContextMenu $ides[$ide]
break
}
}
}
}
@jcwillox
Copy link
Author

jcwillox commented Jan 22, 2022

Download Script

iwr https://gist.github.com/jcwillox/a9b480f8d180d44368e7df2eb7009207/raw/toolbox-context-menu.ps1 -o toolbox-context-menu.ps1

You may also need to run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser.

Toolbox 2.0+

Newer versions of the toolbox provide consistent paths to your tools unlike version 1.0. This makes the process a lot simpler as you can use the direct install method below, however, your tools will likely be in AppData instead of Program Files

.\toolbox-context-menu.ps1 -AppDir "C:\Users\%USERNAME%\AppData\Local\Programs\PyCharm Professional"
# use -Force to override/update existing entries
.\toolbox-context-menu.ps1 -AppDir "C:\Users\%USERNAME%\AppData\Local\Programs\PyCharm Professional" -Force
Toolbox 1.0 You can list installed IDEs with `-List` and then specify the IDEs you want to add context menus for, note that you don't have to specify the full name `pyc` will be enough to match with `PyCharm-P`. You can remove the added context menus by using the `-Remove` option.
.\toolbox-context-menu.ps1 -List
.\toolbox-context-menu.ps1 pyc,idea
.\toolbox-context-menu.ps1 pyc,idea -Remove
# update existing context menu entries
.\toolbox-context-menu.ps1 pyc,idea -Force

NirCmd
With the default method you will have to re-run this script using the -Force option each time toolbox updates an IDE as the executable path will change. You can avoid this by using nircmd, it will be used to silently run the batch script (e.g. idea.cmd). Make sure you've enabled the "Generate shell scripts" option in Toolbox, it doesn't matter where you place these scripts as long as the path won't change when you update an IDE, ~/.local/bin or $env:LOCALAPPDATA\JetBrains\Toolbox\scripts are good options.

.\toolbox-context-menu.ps1 pyc,idea -UseNircmd -Force
# optionally specify the path for nircmd if its not on your system $PATH
.\toolbox-context-menu.ps1 pyc,idea -UseNircmd -NirCmdPath "C:\...path"

Direct Install

If you installed your IDE with the direct download you can still use this script by specifying the installation directory with -AppDir

.\toolbox-context-menu.ps1 -AppDir "C:\Program Files\JetBrains\WebStorm 2021.3.1"

@ntwi
Copy link

ntwi commented Apr 1, 2022

To fix the issue @Townsy45 mentioned, the hard-coded channel number 0 in "ch-0" on this line is the culprit. Some users may not have a ch-0 directory.

This is caused after removing whichever version of the product from toolbox was assigned to ch-0, JetBrains enumerates these for each version you install, so until you install another version ch-0 won't exist.

Changing "ch-0" to "ch-*" on this line should work.

@jcwillox
Copy link
Author

jcwillox commented Apr 1, 2022

Interesting, that's what I thought it was but I couldn't be sure as I've never encountered that. So what do they use instead, should I search for ch-* and then grab the highest number?

E.g. something like

Get-ChildItem "$env:LOCALAPPDATA\JetBrains\Toolbox\apps\PyCharm-P\ch-*" | Sort-Object -Descending | Select-Object -First 1

@ntwi
Copy link

ntwi commented Apr 1, 2022

Interesting, that's what I thought it was but I couldn't be sure as I've never encountered that. So what do they use instead, should I search for ch-* and then grab the highest number?

It looks like my edit came through just prior to your comment, but I believe if you change ch-0 to ch-* and change the sorting from -Descending to -Ascending, it may do the trick.

That won't do the trick since I just saw how it's sorted.

You're proposed solution should do the trick or you can try replacing ch-0 with ch-*, and changing -FIRST 1 to -LAST 1. That should solve it.

@ntwi
Copy link

ntwi commented Apr 1, 2022

Also, thank you for creating this! This feature is a delightful addition to the JetBrains Toolbox UX.

@jcwillox
Copy link
Author

jcwillox commented Apr 1, 2022

Oh yeah, I just happened to open GitHub right as you commented XD.

So, just checking, if I have

ch-0
ch-1
ch-2

Then is the correct folder to choose is ch-2.
Which is what this should produce

Get-ChildItem "$env:LOCALAPPDATA\JetBrains\Toolbox\apps\PyCharm-P\ch-*" | Sort-Object -Descending | Select-Object -First 1

Also, thank you for creating this! This feature is a delightful addition to the JetBrains Toolbox UX.

Thanks for saying that always great to know people are getting value out of these things 😀

@ntwi
Copy link

ntwi commented Apr 1, 2022

It's difficult to know due to how JetBrains enumerates.
For example:
If you have 5 versions installed, it may look something like this

ch-0
ch-1
ch-2
ch-3
ch-4

but if you uninstall 3 versions, you may end up with any combination of the remaining versions; In this case, any two versions.

ch-1
ch-3

now when a user installs a new version, it will be placed in ch-0:

ch-0
ch-1
ch-3

and another...

ch-0
ch-1
ch-2
ch-3

and for completeness sake, another version:

ch-0
ch-1
ch-2
ch-3
ch-4

this will be followed by ch-5, and so on.

The solution proposed won't fix all cases unfortunately.

You may want to look at grabbing the version number from each channel and selecting the largest, however it may be best from a UX standpoint to prompt the user for input in this scenario.

@jcwillox
Copy link
Author

jcwillox commented Apr 1, 2022

Righhht I totally understand what you mean now, thanks for that. Indeed automatically selecting it won't be a good option, I think prompting the user is a good way to go. I'll probably also add a flag like -DefaultChannel Release which can check .channel.settings.json to see if it's the release version and use that if found.

@jcwillox
Copy link
Author

jcwillox commented Apr 2, 2022

I've added the channel selection functionality, and also added the DefaultChannel parameter so you can set a default channel to use and skip the selection prompt if it's found. So, hopefully, it should now be working for everyone.

@mmoore99
Copy link

mmoore99 commented May 4, 2022

Very nice job! Very helpful. Appreciate your effort.

@harrynguon
Copy link

Hi @jcwillox, thank you for this utility. I have now reinstalled my JetBrains tools using the Jetbrains Toolbox and am using this utility to add the entries to the context menu via the default method.

However, when trying to add the context menu entries using the Nircmd method, I am receiving this error when running the command:

➜ .\toolbox-context-menu.ps1 rider -UseNircmd -Force
Adding Context Menu for 'JetBrains Rider'
ACTION: Edit with JetBrains Rider (Files)
Join-Path : A positional parameter cannot be found that accepts argument '.shellLink'.
At C:\Users\Harry\AppData\Local\JetBrains\Toolbox\scripts\toolbox-context-menu.ps1:123 char:32
+ ...  = Get-Content (Join-Path (Split-Path $ExePath) "../.." ".shellLink")
+                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidArgument: (:) [Join-Path], ParameterBindingException
    + FullyQualifiedErrorId : PositionalParameterNotFound,Microsoft.PowerShell.Commands.JoinPathCommand

Any ideas how this can be resolved?

Cheers!

@jcwillox
Copy link
Author

Thanks! Pretty sure you just need to enable "shell scripts" in toolbox's settings and provide toolbox a location to put the shell scripts.

@Townsy45
Copy link

Hey, just thought I would put an updated comment on. Used the default method today and worked like a charm, found the dirs and applications and added / removed them super easy. Cheers!

@jcwillox
Copy link
Author

Forgot to update the instructions comment at the top for toolbox 2.0, but better late than never, you can just use the direct install method now.
https://gist.github.com/jcwillox/a9b480f8d180d44368e7df2eb7009207?permalink_comment_id=4037552#gistcomment-4037552

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment