feat: support Windows right-click context menu to open directory#3280
feat: support Windows right-click context menu to open directory#3280cannebergeee wants to merge 1 commit intowavetermdev:mainfrom
Conversation
…ave Terminal Pass workingDirectory from Electron second-instance event through createNewWaveWindow > createBrowserWindow, and set cmd:cwd metadata on new workspaces so shells spawn in the right-clicked directory. Also add .reg files for installing/removing the context menu entry.
WalkthroughThe changes extend Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
emain/emain-window.ts (1)
725-745:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win
cwdis silently dropped when recreating an existing window.
UpdateObjectMetais nested insideif (!waveWindow), so it only runs when a brand-new window is created (waveWindow == null). The "recreate first window" code path increateNewWaveWindow(Line 866) callscreateBrowserWindow(existingWindowData, fullConfig, { cwd })with a non-nullwaveWindow, so theif (!waveWindow)block is entirely skipped andcwdis never persisted.This hits the edge case where Wave is running but has no visible windows (all windows closed, app still alive), then the user invokes the context menu. Move the call to after the window data is fully resolved so it applies in all paths, including the workspace-recovery fallback at Line 736:
🐛 Proposed fix
if (!waveWindow) { console.log("createBrowserWindow: no waveWindow"); waveWindow = await WindowService.CreateWindow(null, ""); - if (opts.cwd && waveWindow?.workspaceid) { - await ObjectService.UpdateObjectMeta(`workspace:${waveWindow.workspaceid}`, { "cmd:cwd": opts.cwd } as MetaType); - } } let workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid); if (!workspace) { console.log("createBrowserWindow: no workspace, creating new window"); await WindowService.CloseWindow(waveWindow.oid, true); waveWindow = await WindowService.CreateWindow(null, ""); workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid); } + if (opts.cwd && waveWindow?.workspaceid) { + await ObjectService.UpdateObjectMeta(`workspace:${waveWindow.workspaceid}`, { "cmd:cwd": opts.cwd } as MetaType); + } console.log("createBrowserWindow", waveWindow.oid, workspace.oid, workspace);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@emain/emain-window.ts` around lines 725 - 745, The cwd meta is only set when waveWindow is null, so recreate paths skip persisting opts.cwd; move the ObjectService.UpdateObjectMeta call so it runs after the window and workspace are fully resolved (i.e., after you obtain/possibly recreate waveWindow and fetch workspace via WorkspaceService.GetWorkspace), using waveWindow?.workspaceid and opts.cwd to update `workspace:<id>` in all code paths; update inside createBrowserWindow (around where waveWindow and workspace are finalized, before constructing WaveBrowserWindow and returning) so both CreateWindow and the workspace-recovery fallback persist cwd.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@emain/emain.ts`:
- Around line 389-393: The second-instance handler currently uses
workingDirectory which is only reliable for Directory\\Background; instead, in
the electronApp.on("second-instance", ...) callback scan the argv array for the
first non-flag absolute path (skip entries that start with "-" or are relative)
and use that path as cwd when calling createNewWaveWindow; if none found, fall
back to the provided workingDirectory/undefined. Update the handler around
electronApp.on("second-instance", ...) to perform this argv scan and then call
fireAndForget(() => createNewWaveWindow(resolvedCwd)) so Directory and Drive
launch targets open the correct folder.
In `@wave-context-menu.reg`:
- Around line 14-33: The .reg file hardcodes a developer path
(C:\\Users\\azurr\\AppData\\Local\\Programs\\waveterm\\Wave.exe) in all Icon and
command values so it will fail for other users; replace the static .reg with a
PowerShell installer that constructs the executable path from $env:LOCALAPPDATA
(e.g. $env:LOCALAPPDATA\Programs\waveterm\Wave.exe), writes entries under
HKCU:\SOFTWARE\Classes for the keys Directory\Background\shell\WaveTerminal,
Directory\shell\WaveTerminal and Drive\shell\WaveTerminal (setting the default
value, Icon and the \command default), and ensure the command default uses the
correct shell variable (%V vs %1) for Directory vs Folder/Drive as noted so the
context-menu opens the intended target without requiring elevation.
---
Outside diff comments:
In `@emain/emain-window.ts`:
- Around line 725-745: The cwd meta is only set when waveWindow is null, so
recreate paths skip persisting opts.cwd; move the ObjectService.UpdateObjectMeta
call so it runs after the window and workspace are fully resolved (i.e., after
you obtain/possibly recreate waveWindow and fetch workspace via
WorkspaceService.GetWorkspace), using waveWindow?.workspaceid and opts.cwd to
update `workspace:<id>` in all code paths; update inside createBrowserWindow
(around where waveWindow and workspace are finalized, before constructing
WaveBrowserWindow and returning) so both CreateWindow and the workspace-recovery
fallback persist cwd.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 9b017f9f-8be0-434f-93e4-a751a49f5b7b
📒 Files selected for processing (4)
emain/emain-window.tsemain/emain.tswave-context-menu-remove.regwave-context-menu.reg
| electronApp.on("second-instance", (_event, argv, workingDirectory) => { | ||
| console.log("second-instance event, argv:", argv, "workingDirectory:", workingDirectory); | ||
| fireAndForget(createNewWaveWindow); | ||
| const cwd = workingDirectory || undefined; | ||
| fireAndForget(() => createNewWaveWindow(cwd)); | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Electron second-instance workingDirectory vs argv for Windows Explorer right-click Directory context menu
💡 Result:
Electron single-instance relaunch: use the second-instance event’s argv (and workingDirectory) rather than process.argv. 1) What Electron passes to the primary instance - When a second process starts and the first instance called app.requestSingleInstanceLock, Electron emits: - second-instance with parameters (event, argv, workingDirectory, additionalData). - argv is an array of the second instance’s command-line arguments, and workingDirectory is the second instance’s current working directory. - additionalData is JSON/object data you passed to app.requestSingleInstanceLock(additionalData); Electron documentation notes that argv may not be exactly the same as what you launched with (order may change; additional args may be appended). - Electron also notes Chromium may add extra arguments (e.g. --original-process-start-time). So, for your Windows Explorer right-click Directory context menu, the value you care about (the selected directory path) should be read from the argv array provided to the second-instance handler, and/or (more robustly) you should move that data into additionalData. 2) workingDirectory vs argv (which one to use) - workingDirectory: - It is the “current working directory” of the second launch. - This is generally not the selected directory path you get from Explorer context menus. - argv: - Contains the command-line arguments that Explorer (via your registered command) passed to your Electron executable for the second launch. - This is what you should parse to obtain the selected folder path. 3) Important caveats on Windows/Chromium argument handling - Electron/Chromium may manipulate the second-instance argv: - The documentation explicitly warns that argv “will not be exactly the same list of arguments as those passed to the second instance”; order can change and additional args can be appended. - Chromium command-line parsing can also split/move values such as switches vs positional arguments (documented in electron/electron issues). This can make “exact argv reproduction” unreliable. Practical implication for your question: - Don’t rely on workingDirectory to provide the right-click target. - Don’t rely on argv matching process.argv exactly. 4) Recommended robust pattern - In main process, register a single-instance lock and in second-instance: - Parse the directory path from the argv array you receive. - Focus/notify your existing BrowserWindow. - If you need strict data fidelity (exact path, including tricky quoting), prefer passing your payload through additionalData (Electron supports an object passed via app.requestSingleInstanceLock(additionalData) and received in the second-instance handler as additionalData). Code skeleton: const { app, BrowserWindow } = require('electron'); let win; const gotTheLock = app.requestSingleInstanceLock(/* optional initial data /); if (!gotTheLock) app.quit; else { app.on('second-instance', (event, argv, workingDirectory /, additionalData */) => { // argv: array of second instance’s command-line args // workingDirectory: cwd of second instance // Example: find your custom flag and read the following value // const path = extractFrom(argv); if (win) { if (win.isMinimized) win.restore; win.focus; } // send the extracted folder path to renderer / update tabs }); app.whenReady.then( => { win = new BrowserWindow({}); // load your app }); } 5) Bottom line answer to the “workingDirectory vs argv” part - Use argv for the Explorer right-click “Directory” target path you pass via your registry “command” line. - workingDirectory is only the second process’s current working directory and usually won’t be the selected directory. - Treat argv as parsed-by-Chromium and not guaranteed to equal your original process.argv; if you need exactness, use additionalData.
Citations:
- 1: https://www.electronjs.org/docs/api/app
- 2: https://electronjs.org/docs/latest/api/app
- 3: https://stackoverflow.com/questions/66150541/electron-accessing-the-browser-window-of-the-single-instance-when-called-a-seco
- 4:
second-instanceevent doesn't provide expected arguments electron/electron#13652 - 5:
second-instanceevent process arguments rip apart "name-value" arguments electron/electron#20322
workingDirectory is only correct for Directory\Background; the Directory and Drive entries will silently open the wrong folder.
workingDirectory is the second instance's current working directory, not necessarily the folder that was right-clicked. For Directory\Background, Windows Explorer sets the process CWD to the browsed folder, so workingDirectory is correct. However, for Directory and Drive, the target path is passed as a command-line argument (%1 in the registry command) and the process CWD is elsewhere — workingDirectory will silently resolve to the wrong location.
The robust approach is to scan argv for the first non-flag, absolute-path argument and fall back to workingDirectory. Note that Electron's argv may not exactly match what was passed to the second instance — order may change and Chromium flags (e.g., --original-process-start-time) may be appended, which is why scanning for a non-flag absolute path (not relying on a fixed position) is necessary.
Proposed fix
electronApp.on("second-instance", (_event, argv, workingDirectory) => {
console.log("second-instance event, argv:", argv, "workingDirectory:", workingDirectory);
- const cwd = workingDirectory || undefined;
+ // For Directory/Drive context menu, the target path is passed as an explicit
+ // %1/%V argument in argv. For Directory\Background, workingDirectory is the folder.
+ // Electron may inject Chromium flags into argv, so scan for a non-flag absolute path.
+ const pathArg = argv?.find(
+ (arg) => !arg.startsWith("-") && /^[a-zA-Z]:[\\\/]/.test(arg)
+ );
+ const cwd = pathArg || workingDirectory || undefined;
fireAndForget(() => createNewWaveWindow(cwd));
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| electronApp.on("second-instance", (_event, argv, workingDirectory) => { | |
| console.log("second-instance event, argv:", argv, "workingDirectory:", workingDirectory); | |
| fireAndForget(createNewWaveWindow); | |
| const cwd = workingDirectory || undefined; | |
| fireAndForget(() => createNewWaveWindow(cwd)); | |
| }); | |
| electronApp.on("second-instance", (_event, argv, workingDirectory) => { | |
| console.log("second-instance event, argv:", argv, "workingDirectory:", workingDirectory); | |
| // For Directory/Drive context menu, the target path is passed as an explicit | |
| // %1/%V argument in argv. For Directory\Background, workingDirectory is the folder. | |
| // Electron may inject Chromium flags into argv, so scan for a non-flag absolute path. | |
| const pathArg = argv?.find( | |
| (arg) => !arg.startsWith("-") && /^[a-zA-Z]:[\\\/]/.test(arg) | |
| ); | |
| const cwd = pathArg || workingDirectory || undefined; | |
| fireAndForget(() => createNewWaveWindow(cwd)); | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@emain/emain.ts` around lines 389 - 393, The second-instance handler currently
uses workingDirectory which is only reliable for Directory\\Background; instead,
in the electronApp.on("second-instance", ...) callback scan the argv array for
the first non-flag absolute path (skip entries that start with "-" or are
relative) and use that path as cwd when calling createNewWaveWindow; if none
found, fall back to the provided workingDirectory/undefined. Update the handler
around electronApp.on("second-instance", ...) to perform this argv scan and then
call fireAndForget(() => createNewWaveWindow(resolvedCwd)) so Directory and
Drive launch targets open the correct folder.
| "Icon"="C:\\Users\\azurr\\AppData\\Local\\Programs\\waveterm\\Wave.exe" | ||
|
|
||
| [HKEY_CLASSES_ROOT\Directory\Background\shell\WaveTerminal\command] | ||
| @="\"C:\\Users\\azurr\\AppData\\Local\\Programs\\waveterm\\Wave.exe\" \"%V\"" | ||
|
|
||
| ; --- Folder itself (right-click on folder) --- | ||
| [HKEY_CLASSES_ROOT\Directory\shell\WaveTerminal] | ||
| @="Open in Wave Terminal" | ||
| "Icon"="C:\\Users\\azurr\\AppData\\Local\\Programs\\waveterm\\Wave.exe" | ||
|
|
||
| [HKEY_CLASSES_ROOT\Directory\shell\WaveTerminal\command] | ||
| @="\"C:\\Users\\azurr\\AppData\\Local\\Programs\\waveterm\\Wave.exe\" \"%1\"" | ||
|
|
||
| ; --- Drive root (right-click on drive in This PC) --- | ||
| [HKEY_CLASSES_ROOT\Drive\shell\WaveTerminal] | ||
| @="Open in Wave Terminal" | ||
| "Icon"="C:\\Users\\azurr\\AppData\\Local\\Programs\\waveterm\\Wave.exe" | ||
|
|
||
| [HKEY_CLASSES_ROOT\Drive\shell\WaveTerminal\command] | ||
| @="\"C:\\Users\\azurr\\AppData\\Local\\Programs\\waveterm\\Wave.exe\" \"%1\"" |
There was a problem hiding this comment.
Hardcoded developer path makes this file non-functional for any other user.
All six path entries reference C:\\Users\\azurr\\AppData\\Local\\Programs\\waveterm\\Wave.exe — a personal installation that will not exist on any other machine. Merging this as-is means the feature ships completely broken for every user who tries to apply it.
Since .reg files don't expand %LOCALAPPDATA% in plain REG_SZ values, the recommended fix is to replace this static .reg file with a PowerShell script that builds the paths dynamically:
$wavePath = "$env:LOCALAPPDATA\Programs\waveterm\Wave.exe"
@(
"HKCU:\SOFTWARE\Classes\Directory\Background\shell\WaveTerminal",
"HKCU:\SOFTWARE\Classes\Directory\shell\WaveTerminal",
"HKCU:\SOFTWARE\Classes\Drive\shell\WaveTerminal"
) | ForEach-Object {
New-Item -Path "$_\command" -Force | Out-Null
Set-ItemProperty -Path $_ -Name "(default)" -Value "Open in Wave Terminal"
Set-ItemProperty -Path $_ -Name "Icon" -Value $wavePath
Set-ItemProperty -Path "$_\command" -Name "(default)" -Value "`"$wavePath`" `"%V`""
}Using HKCU:\SOFTWARE\Classes\... rather than HKEY_CLASSES_ROOT\... also avoids the administrator-elevation requirement.
Note: for the
DirectoryandDrivecases you'll also want to validate which shell variable (%Vvs%1) correctly delivers the target path (see the comment onemain.ts).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@wave-context-menu.reg` around lines 14 - 33, The .reg file hardcodes a
developer path (C:\\Users\\azurr\\AppData\\Local\\Programs\\waveterm\\Wave.exe)
in all Icon and command values so it will fail for other users; replace the
static .reg with a PowerShell installer that constructs the executable path from
$env:LOCALAPPDATA (e.g. $env:LOCALAPPDATA\Programs\waveterm\Wave.exe), writes
entries under HKCU:\SOFTWARE\Classes for the keys
Directory\Background\shell\WaveTerminal, Directory\shell\WaveTerminal and
Drive\shell\WaveTerminal (setting the default value, Icon and the \command
default), and ensure the command default uses the correct shell variable (%V vs
%1) for Directory vs Folder/Drive as noted so the context-menu opens the
intended target without requiring elevation.
Summary
workingDirectoryfrom Electron'ssecond-instanceevent tocreateNewWaveWindowand ultimately sets"cmd:cwd"metadata on the new workspace, so shells spawn in the right-clicked directory..regfiles for installing/removing the Windows context menu entry.Problem
Currently, right-clicking a folder in Windows Explorer and selecting "Open in Wave Terminal" opens Wave, but always starts in the home directory — the path from the context menu is silently ignored.
The root cause:
emain.tssecond-instancehandler receivesworkingDirectorybut throws it away:Solution
Three small changes in the Electron main process:
emain.ts— passworkingDirectorytocreateNewWaveWindowemain-window.ts—createNewWaveWindow(cwd?)→createBrowserWindow→ after workspace creation, callObjectService.UpdateObjectMetawith{"cmd:cwd": cwd}before the default tab initializes its shellemain-window.ts— addcwd?: stringtoWindowOptsThe Go backend already reads
"cmd:cwd"from workspace metadata inshellexec.go:Testing
tsc --noEmit— zero new errors inemain/electron-vite build— passes (SSR + preload + frontend)Registry files included
wave-context-menu.reg— installs "Open in Wave Terminal" inDirectory\Background,Directory, andDriveshell keyswave-context-menu-remove.reg— removes the entriesRelated
second-instancehandler was already receiving the data — this PR just stops ignoring it.