From c5ec90682576fb72643bda6c2b80d0e6bfdfde77 Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 11:50:40 +0800 Subject: [PATCH 01/12] v2.7.0: Fix verb truncation, & stripping, atomic SendInput; enhance context menu matching --- mods/click-on-empty-explorer.wh.cpp | 703 ++++++++++++++++++++++++++-- 1 file changed, 651 insertions(+), 52 deletions(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index 975a2d1f66..ee49bbb451 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -1,12 +1,12 @@ // ==WindhawkMod== // @id click-on-empty-explorer // @name Click on Empty Explorer -// @description Configure double click, middle click and double middle click actions on empty space in File Explorer -// @version 2.3.0 +// @description Configure double/triple click, middle click, double middle click, and modifier+click actions on empty space in File Explorer. "Open in..." actions reuse the folder's right-click background context menu, so any registered program (VS Code, Terminal, Cursor, Git Bash...) works. +// @version 2.7.0 // @author LiHua81 // @github https://github.com/LiHua81 // @include explorer.exe -// @compilerOptions -lcomctl32 -loleaut32 -lole32 +// @compilerOptions -lcomctl32 -loleaut32 -lole32 -lshlwapi // @architecture x86-64 // ==/WindhawkMod== @@ -22,8 +22,9 @@ /* # Click on Empty Explorer -Configure what happens when you double click, middle click, or double middle click on -empty space in File Explorer. Supports 12 different actions. +Configure what happens when you double click, triple click, middle click, double middle click, +or modifier+click (Ctrl/Alt/Shift+Click) on empty space in File Explorer. Supports 14 different +actions. ## How it works @@ -31,6 +32,14 @@ This mod intercepts mouse clicks on the blank area of File Explorer's file list no file or folder is located) and performs the action you've configured. - **Double left click** — Windows natively detects double clicks. No delay at all. +- **Triple left click** — When triple-click is configured, double-click actions are + delayed by ~500ms (the system double-click time). If a third click arrives in that + window, the double-click is cancelled and only the triple-click action fires. If no + third click arrives, the double-click action fires when the timer expires. + When triple-click is not configured, double-click fires instantly as before. +- **Modifier + Click** — Hold Ctrl, Alt, or Shift while left-clicking empty space to + trigger a separate action (e.g. Ctrl+Click = Go Up, Alt+Click = Refresh). Each + modifier has its own independent action setting. - **Middle click** — If only a single-click action is set (double middle click is disabled), the action fires instantly with no delay, just like left click. - **Double middle click** — Windows does not natively support double middle click, so @@ -56,6 +65,10 @@ no file or folder is located) and performs the action you've configured. - **Custom Hotkey** — Send a custom key combination, configured per trigger (see below) - **Go to Desktop** — Navigate to the Desktop - **Go to Home** — Navigate to Quick Access / Home +- **Open in VS Code** — Invoke the "Open in VS Code" entry from the folder's right-click background context menu (no hard-coded path; works if the verb is registered) +- **Open in Terminal** — Invoke the "Open in Terminal" / Windows Terminal entry from the context menu +- **Open in Cursor** — Invoke the "Open in Cursor" entry from the context menu +- **Open Context Menu Item** — Invoke any right-click background context menu entry by matching its text/verb (configured via "Context Menu Match" setting). Lets you open the folder in Git Bash, PowerShell 7, any editor, etc. - **None** — Do nothing ## Custom Hotkey @@ -69,7 +82,46 @@ When you choose "Custom Hotkey" for a trigger, a text field appears where you de **Examples:** `Ctrl+V`, `Ctrl+Shift+N`, `Win+E`, `Alt+F4`, `Ctrl+Shift+Esc`, `F5`, `Win+Shift+S` -Each trigger (double click, middle click, double middle click) has its own independent custom hotkey field. +Each trigger has its own independent custom hotkey field. + +## Context Menu Match + +When you choose **Open Context Menu Item** for a trigger, this text field tells the mod which +right-click background menu entry to invoke. The mod opens the folder's actual right-click +context menu programmatically and clicks the first entry that matches your input. + +### Matching Rules + +Matching is **case-insensitive** and ignores **spaces** and `&` accelerator markers: + +| Menu item text | Verb | You can type any of | +|-----------------------------|-------------------|------------------------------------------| +| `Open Git Ba&sh here` | `git_shell` | `gitbash`, `git`, `bash`, `shell` | +| `Open with Code` | `{1C6DF0C0...}` | `code`, `openwithcode` | +| `Open in Terminal` | `{9F156763...}` | `terminal`, `openinterminal` | +| `&ExtractAllFiles` | `ExtractAllFiles` | `extractall`, `extract` | +| `Properties` | `properties` | `prop`, `properties` | +| `New` → `Folder` | `NewFolder` | `newfolder`, `folder` | + +### How to Find the Right Text + +If your match text doesn't work, check the debug log (enable in Windhawk editor) — +the mod will dump ALL available menu items with their normalized text: + +``` +No match for 'Git Bash' — dumping all context menu items: +CMENU[Open Git Ba&sh here] wID=92 verb=[git_shell] → match: "opengitbashhere" or "git_shell" +``` + +The `→ match:` part shows the normalized form of each entry's text and verb. +**Type any part of either normalized string** to match that entry. For example, +`"gitbash"`, `"git"`, `"bash"`, `"shell"`, or `"git_shell"` would all match this entry. + +### Tips + +- **Any program** — any program that registered a right-click entry on the folder background works, regardless of install path +- **Multiple matches** — if more than one entry matches, the first one in menu order is used +- **Non-English menus** — type any substring from the display text in your system language (e.g., Japanese, Chinese, Korean all work) ## Windows version support @@ -82,7 +134,7 @@ require Windows 11 for tabbed Explorer support. /* - doubleClickAction: goUp $name: Double Click Action - $description: What to do when double left clicking empty space. No delay (native Windows support). + $description: What to do when double left clicking empty space. Instant when triple-click is disabled. When triple-click is enabled, delayed ~500ms and overridden if a third click arrives. $options: - goUp: Go Up - goBack: Go Back @@ -97,7 +149,39 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal + - openInCursor: Open in Cursor + - openWithContextMenu: Open Context Menu Item - none: None +- doubleClickCustomHotkey: "" + $name: Double Click Custom Hotkey + $description: "Format: modifier keys + main key. Modifiers: Ctrl, Shift, Alt, Win (can combine multiple, e.g. Ctrl+Shift+N, Win+Shift+S). Main key: letter, F1-F24, Tab, Enter, Escape, arrows, Backspace, Delete, Home, End, PageUp, PageDown, Insert" +- tripleClickAction: none + $name: Triple Click Action + $description: What to do when triple left clicking empty space. When enabled, double-click is delayed ~500ms; if a third click arrives, only the triple-click action fires (double-click is cancelled). + $options: + - goUp: Go Up + - goBack: Go Back + - goForward: Go Forward + - refresh: Refresh + - newTab: New Tab (Win11) + - duplicateTab: Duplicate Tab (Win11) + - closeTab: Close Tab (Win11) + - newFolder: New Folder + - copyPath: Copy Path + - paste: Paste + - customHotkey: Custom Hotkey + - goToDesktop: Go to Desktop + - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal + - openInCursor: Open in Cursor + - openWithContextMenu: Open Context Menu Item + - none: None +- tripleClickCustomHotkey: "" + $name: Triple Click Custom Hotkey + $description: "Same format as Double Click Custom Hotkey. Supports multiple modifiers. Ex: Ctrl+W, Win+D, Ctrl+Shift+Esc" - middleClickAction: none $name: Middle Click Action $description: What to do when single middle clicking empty space. If only single click is set, fires instantly. If both single and double are set, single is delayed ~500ms to detect double clicks. @@ -115,7 +199,14 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal + - openInCursor: Open in Cursor + - openWithContextMenu: Open Context Menu Item - none: None +- middleClickCustomHotkey: "" + $name: Middle Click Custom Hotkey + $description: "Same format as Double Click Custom Hotkey. Supports multiple modifiers. Ex: Ctrl+V, Ctrl+T, Win+D, Ctrl+Shift+Esc" - doubleMiddleClickAction: none $name: Double Middle Click Action $description: What to do when double middle clicking empty space. Two middle clicks within ~500ms count as a double click. If only double is set, single middle clicks are ignored. @@ -133,16 +224,92 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal + - openInCursor: Open in Cursor + - openWithContextMenu: Open Context Menu Item - none: None -- doubleClickCustomHotkey: "" - $name: Double Click Custom Hotkey - $description: "Format: modifier keys + main key. Modifiers: Ctrl, Shift, Alt, Win (can combine multiple, e.g. Ctrl+Shift+N, Win+Shift+S). Main key: letter, F1-F24, Tab, Enter, Escape, arrows, Backspace, Delete, Home, End, PageUp, PageDown, Insert" -- middleClickCustomHotkey: "" - $name: Middle Click Custom Hotkey - $description: "Same format as Double Click Custom Hotkey. Supports multiple modifiers. Ex: Ctrl+V, Ctrl+T, Win+D, Ctrl+Shift+Esc" - doubleMiddleClickCustomHotkey: "" $name: Double Middle Click Custom Hotkey $description: "Same format as Double Click Custom Hotkey. Supports multiple modifiers. Ex: Ctrl+W, Alt+Tab, Win+E, Alt+Shift+F4" +- ctrlClickAction: none + $name: Ctrl+Click Action + $description: What to do when Ctrl+left clicking empty space. Hold Ctrl and single-click on empty area. + $options: + - goUp: Go Up + - goBack: Go Back + - goForward: Go Forward + - refresh: Refresh + - newTab: New Tab (Win11) + - duplicateTab: Duplicate Tab (Win11) + - closeTab: Close Tab (Win11) + - newFolder: New Folder + - copyPath: Copy Path + - paste: Paste + - customHotkey: Custom Hotkey + - goToDesktop: Go to Desktop + - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal + - openInCursor: Open in Cursor + - openWithContextMenu: Open Context Menu Item + - none: None +- ctrlClickCustomHotkey: "" + $name: Ctrl+Click Custom Hotkey + $description: "Same format as Double Click Custom Hotkey. Ex: Ctrl+N, Ctrl+Shift+E" +- altClickAction: none + $name: Alt+Click Action + $description: What to do when Alt+left clicking empty space. Hold Alt and single-click on empty area. + $options: + - goUp: Go Up + - goBack: Go Back + - goForward: Go Forward + - refresh: Refresh + - newTab: New Tab (Win11) + - duplicateTab: Duplicate Tab (Win11) + - closeTab: Close Tab (Win11) + - newFolder: New Folder + - copyPath: Copy Path + - paste: Paste + - customHotkey: Custom Hotkey + - goToDesktop: Go to Desktop + - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal + - openInCursor: Open in Cursor + - openWithContextMenu: Open Context Menu Item + - none: None +- altClickCustomHotkey: "" + $name: Alt+Click Custom Hotkey + $description: "Same format as Double Click Custom Hotkey. Ex: Alt+F4, Alt+Tab" +- shiftClickAction: none + $name: Shift+Click Action + $description: What to do when Shift+left clicking empty space. Hold Shift and single-click on empty area. + $options: + - goUp: Go Up + - goBack: Go Back + - goForward: Go Forward + - refresh: Refresh + - newTab: New Tab (Win11) + - duplicateTab: Duplicate Tab (Win11) + - closeTab: Close Tab (Win11) + - newFolder: New Folder + - copyPath: Copy Path + - paste: Paste + - customHotkey: Custom Hotkey + - goToDesktop: Go to Desktop + - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal + - openInCursor: Open in Cursor + - openWithContextMenu: Open Context Menu Item + - none: None +- shiftClickCustomHotkey: "" + $name: Shift+Click Custom Hotkey + $description: "Same format as Double Click Custom Hotkey. Ex: Shift+F10, Ctrl+Shift+N" +- contextMenuMatch: "" + $name: Context Menu Match + $description: "Used by the 'Open Context Menu Item' action. Text to match (case-insensitive substring) against the folder background right-click menu entries' display text or verb. Ex: VS Code, Terminal, Git Bash, PowerShell, Cursor. Any program that registered an 'Open in ...' entry works regardless of install path." */ // ==/WindhawkModSettings== @@ -155,6 +322,7 @@ require Windows 11 for tabbed Explorer support. #include #include +#include #include #include #include @@ -192,41 +360,75 @@ class StringSetting { static std::mutex g_settingsMutex; static StringSetting g_doubleClickAction; +static StringSetting g_tripleClickAction; static StringSetting g_middleClickAction; static StringSetting g_doubleMiddleClickAction; +static StringSetting g_ctrlClickAction; +static StringSetting g_altClickAction; +static StringSetting g_shiftClickAction; static StringSetting g_doubleClickCustomCombo; +static StringSetting g_tripleClickCustomCombo; static StringSetting g_middleClickCustomCombo; static StringSetting g_doubleMiddleClickCustomCombo; +static StringSetting g_ctrlClickCustomCombo; +static StringSetting g_altClickCustomCombo; +static StringSetting g_shiftClickCustomCombo; +static StringSetting g_contextMenuMatch; static void LoadSettings() { std::lock_guard lock(g_settingsMutex); g_doubleClickAction.Load(L"doubleClickAction"); + g_tripleClickAction.Load(L"tripleClickAction"); g_middleClickAction.Load(L"middleClickAction"); g_doubleMiddleClickAction.Load(L"doubleMiddleClickAction"); + g_ctrlClickAction.Load(L"ctrlClickAction"); + g_altClickAction.Load(L"altClickAction"); + g_shiftClickAction.Load(L"shiftClickAction"); g_doubleClickCustomCombo.Load(L"doubleClickCustomHotkey"); + g_tripleClickCustomCombo.Load(L"tripleClickCustomHotkey"); g_middleClickCustomCombo.Load(L"middleClickCustomHotkey"); g_doubleMiddleClickCustomCombo.Load(L"doubleMiddleClickCustomHotkey"); + g_ctrlClickCustomCombo.Load(L"ctrlClickCustomHotkey"); + g_altClickCustomCombo.Load(L"altClickCustomHotkey"); + g_shiftClickCustomCombo.Load(L"shiftClickCustomHotkey"); + g_contextMenuMatch.Load(L"contextMenuMatch"); } // Read settings under lock, deep-copy strings so they outlive the lock struct SettingsSnapshot { std::wstring doubleClick; + std::wstring tripleClick; std::wstring middleClick; std::wstring doubleMiddleClick; + std::wstring ctrlClick; + std::wstring altClick; + std::wstring shiftClick; std::wstring doubleClickCombo; + std::wstring tripleClickCombo; std::wstring middleClickCombo; std::wstring doubleMiddleClickCombo; + std::wstring ctrlClickCombo; + std::wstring altClickCombo; + std::wstring shiftClickCombo; }; static SettingsSnapshot CopySettings() { std::lock_guard lock(g_settingsMutex); return { g_doubleClickAction.Get() ? g_doubleClickAction.Get() : L"", + g_tripleClickAction.Get() ? g_tripleClickAction.Get() : L"", g_middleClickAction.Get() ? g_middleClickAction.Get() : L"", g_doubleMiddleClickAction.Get() ? g_doubleMiddleClickAction.Get() : L"", + g_ctrlClickAction.Get() ? g_ctrlClickAction.Get() : L"", + g_altClickAction.Get() ? g_altClickAction.Get() : L"", + g_shiftClickAction.Get() ? g_shiftClickAction.Get() : L"", g_doubleClickCustomCombo.Get() ? g_doubleClickCustomCombo.Get() : L"", + g_tripleClickCustomCombo.Get() ? g_tripleClickCustomCombo.Get() : L"", g_middleClickCustomCombo.Get() ? g_middleClickCustomCombo.Get() : L"", - g_doubleMiddleClickCustomCombo.Get() ? g_doubleMiddleClickCustomCombo.Get() : L"" + g_doubleMiddleClickCustomCombo.Get() ? g_doubleMiddleClickCustomCombo.Get() : L"", + g_ctrlClickCustomCombo.Get() ? g_ctrlClickCustomCombo.Get() : L"", + g_altClickCustomCombo.Get() ? g_altClickCustomCombo.Get() : L"", + g_shiftClickCustomCombo.Get() ? g_shiftClickCustomCombo.Get() : L"" }; } @@ -261,8 +463,7 @@ static void SendKeyCombo(WORD vk1, WORD vk2, WORD vk3 = 0) { Release(vk2); if (vk3) Release(vk3); Release(vk1); - SendInput(count / 2, inputs, sizeof(INPUT)); - SendInput(count / 2, inputs + count / 2, sizeof(INPUT)); + SendInput(count, inputs, sizeof(INPUT)); // single atomic call } // ---- Custom hotkey parsing ---- @@ -339,7 +540,6 @@ static void SendParsedHotkey(const std::wstring& combo) { inputs[count].ki.wVk = keys[i]; count++; } - SendInput(count, inputs, sizeof(INPUT)); // Release in reverse for (int i = (int)keys.size() - 1; i >= 0; i--) { inputs[count].type = INPUT_KEYBOARD; @@ -347,7 +547,203 @@ static void SendParsedHotkey(const std::wstring& combo) { inputs[count].ki.dwFlags = KEYEVENTF_KEYUP; count++; } - SendInput((int)keys.size(), inputs + (int)keys.size(), sizeof(INPUT)); + SendInput(count, inputs, sizeof(INPUT)); // single atomic call +} + +// ---- Invoke a folder background context menu entry (borrow the right-click menu) ---- +// Reuses exactly what Explorer shows when you right-click empty space in a folder, so any +// program that registered an "Open in ..." (or similar) verb works without hard-coded paths. +// matchText is matched (case-insensitive, space-insensitive) against both the menu display +// text and the underlying verb (e.g. "VS Code" matches verb "VSCode" or text "Open with Code"). +// +// SAFETY NOTE: Called from Explorer UI thread subclass proc — COM must already be initialized +// (Explorer does that internally). All PIDL/interface clean-up is manual and exception-safe +// (no C++ exceptions; early-return via if-guards). No window subclassing is used: cascading +// submenus are populated by calling IContextMenu2::HandleMenuMsg(WM_INITMENUPOPUP, ...) directly. + +// Normalize: lowercase + drop whitespace + drop & accelerator markers. +// "Open Git Ba&sh here" → "opengitbashhere", so user typing "gitbash" matches. +// "通过 Code 打开" → "通过code打开", so user typing "code" or "通过code" matches. +static std::wstring NormalizeForMatch(PCWSTR s) { + std::wstring out; + if (!s) return out; + out.reserve(wcslen(s) + 1); + for (const wchar_t* p = s; *p; ++p) { + if (iswspace((wint_t)*p) || *p == L'&') continue; + out.push_back((wchar_t)towlower((wint_t)*p)); + } + return out; +} + +// Case-insensitive substring test after normalization. +// This lets "VS Code" match the verb "VSCode" and "Open with Code" match "Code", etc. +static bool StrContainsNorm(PCWSTR haystack, PCWSTR needle) { + if (!haystack || !needle || !*needle) return false; + std::wstring h = NormalizeForMatch(haystack); + std::wstring n = NormalizeForMatch(needle); + return StrStrIW(h.c_str(), n.c_str()) != NULL; +} + +// Recursively walk the (possibly nested) context menu, invoking the first item whose +// display text or verb contains matchText. Returns true and invokes on success. +static bool EnumContextMenuMatch(HMENU hMenu, IContextMenu* pcm, IContextMenu2* pcm2, + HWND hwnd, PCWSTR matchText, int idCmdFirst) { + int count = GetMenuItemCount(hMenu); + for (int i = 0; i < count; i++) { + MENUITEMINFOW mii = { sizeof(mii) }; + mii.fMask = MIIM_ID | MIIM_TYPE | MIIM_SUBMENU; + if (!GetMenuItemInfoW(hMenu, i, TRUE, &mii)) continue; + + // Skip separators (wID == 0). Without this, offset = 0 - 1 = UINT_MAX is + // passed to GetCommandString/InvokeCommand → undefined behaviour / crash. + if (mii.wID == 0) continue; + + // Command-ID range guard: the context menu's QueryContextMenu uses + // the range [idCmdFirst..0x7FFF]. Non-context-menu submenus (e.g. + // "New", "View", "Sort by") contain items with system-assigned IDs + // outside this range. Passing an out-of-range offset to GetCommandString + // or InvokeCommand is array-index OOB → instant Explorer crash. + if (mii.wID < (UINT)idCmdFirst || mii.wID > 0x7FFF) continue; + + if (mii.hSubMenu != NULL) { + // Cascading submenu: populate it by forwarding WM_INITMENUPOPUP to + // IContextMenu2 (e.g. the Windows Terminal / 7-Zip cascades). Without + // this the submenu is empty and its entries can never be matched. + if (pcm2) + pcm2->HandleMenuMsg(WM_INITMENUPOPUP, (WPARAM)mii.hSubMenu, MAKELPARAM(i, 0)); + if (EnumContextMenuMatch(mii.hSubMenu, pcm, pcm2, hwnd, matchText, idCmdFirst)) + return true; + continue; + } + + // Leaf item: read verb + display text. + UINT offset = mii.wID - idCmdFirst; + + CHAR verbA[MAX_PATH] = {}; + pcm->GetCommandString(offset, GCS_VERBA, NULL, verbA, MAX_PATH); + wchar_t verb[MAX_PATH] = {}; + if (verbA[0]) + MultiByteToWideChar(CP_ACP, 0, verbA, -1, verb, MAX_PATH); + + wchar_t text[MAX_PATH] = {}; + MENUITEMINFOW miiT = { sizeof(miiT) }; + miiT.fMask = MIIM_STRING; + miiT.dwTypeData = text; + miiT.cch = MAX_PATH; + GetMenuItemInfoW(hMenu, i, TRUE, &miiT); + + if (StrContainsNorm(text, matchText) || StrContainsNorm(verb, matchText)) { + CMINVOKECOMMANDINFO ci = { sizeof(ci) }; + ci.hwnd = hwnd; + ci.lpVerb = MAKEINTRESOURCEA(offset); + ci.nShow = SW_SHOWNORMAL; + if (SUCCEEDED(pcm->InvokeCommand(&ci))) + return true; + } + } + return false; +} + +// Diagnostic: recursively dump all context-menu leaf items. +// Called when a match fails, so the user can see what verbs/text are available +// and adjust the "Context Menu Match" setting accordingly. +static void DumpContextMenuRecursive(HMENU hMenu, IContextMenu* pcm, IContextMenu2* pcm2, + int idCmdFirst, int depth) { + int count = GetMenuItemCount(hMenu); + for (int i = 0; i < count; i++) { + MENUITEMINFOW mii = { sizeof(mii) }; + mii.fMask = MIIM_ID | MIIM_TYPE | MIIM_SUBMENU; + if (!GetMenuItemInfoW(hMenu, i, TRUE, &mii)) continue; + if (mii.wID == 0) continue; // separator + if (mii.wID < (UINT)idCmdFirst || mii.wID > 0x7FFF) continue; + + wchar_t indent[64] = {}; + for (int d = 0; d < depth && d < 31; d++) indent[d] = L' '; + indent[depth < 31 ? depth : 31] = 0; + + if (mii.hSubMenu != NULL) { + wchar_t stext[MAX_PATH] = {}; + MENUITEMINFOW miiT = { sizeof(miiT) }; + miiT.fMask = MIIM_STRING; + miiT.dwTypeData = stext; + miiT.cch = MAX_PATH; + GetMenuItemInfoW(hMenu, i, TRUE, &miiT); + Wh_Log(L"CMENU%s[%s] (submenu) wID=%u", indent, + stext[0] ? stext : L"(no text)", mii.wID); + if (pcm2) + pcm2->HandleMenuMsg(WM_INITMENUPOPUP, (WPARAM)mii.hSubMenu, MAKELPARAM(i, 0)); + DumpContextMenuRecursive(mii.hSubMenu, pcm, pcm2, idCmdFirst, depth + 2); + continue; + } + + UINT offset = mii.wID - idCmdFirst; + CHAR verbA[MAX_PATH] = {}; + pcm->GetCommandString(offset, GCS_VERBA, NULL, verbA, MAX_PATH); + wchar_t verb[MAX_PATH] = {}; + if (verbA[0]) + MultiByteToWideChar(CP_ACP, 0, verbA, -1, verb, MAX_PATH); + wchar_t text[MAX_PATH] = {}; + MENUITEMINFOW miiT = { sizeof(miiT) }; + miiT.fMask = MIIM_STRING; + miiT.dwTypeData = text; + miiT.cch = MAX_PATH; + GetMenuItemInfoW(hMenu, i, TRUE, &miiT); + + // Show normalized text so user knows exactly what substring to type + std::wstring normText = NormalizeForMatch(text); + std::wstring normVerb = NormalizeForMatch(verb); + Wh_Log(L"CMENU%s[%s] wID=%u offset=%u verb=[%s] → match: \"%s\" or \"%s\"", indent, + text[0] ? text : L"(no text)", mii.wID, offset, + verb[0] ? verb : L"(none)", + normText.c_str(), normVerb[0] ? normVerb.c_str() : L""); + } +} + +static bool InvokeFolderContextMenuVerb(PCWSTR folderPath, HWND hwnd, PCWSTR matchText) { + if (!folderPath || !folderPath[0] || !matchText || !matchText[0]) return false; + + // SHParseDisplayName returns an absolute PIDL (relative to Desktop root). + // We bind it directly on the desktop IShellFolder — no PIDL splitting needed. + PIDLIST_ABSOLUTE pidl = nullptr; + if (FAILED(SHParseDisplayName(folderPath, NULL, &pidl, 0, NULL)) || !pidl) + return false; + + IShellFolder* psfDesktop = nullptr; + if (FAILED(SHGetDesktopFolder(&psfDesktop))) { + CoTaskMemFree(pidl); + return false; + } + + IShellFolder* psfFolder = nullptr; + HRESULT hr = psfDesktop->BindToObject(pidl, NULL, IID_IShellFolder, (void**)&psfFolder); + psfDesktop->Release(); + CoTaskMemFree(pidl); + if (FAILED(hr) || !psfFolder) return false; + + // Background context menu for THIS folder (right-click empty space). + IContextMenu* pcm = nullptr; + hr = psfFolder->CreateViewObject(hwnd, IID_IContextMenu, (void**)&pcm); + psfFolder->Release(); + if (FAILED(hr) || !pcm) return false; + + // IContextMenu2 is needed to populate cascading submenus (WM_INITMENUPOPUP). + // QueryInterface for IContextMenu2 also succeeds when only IContextMenu3 exists. + IContextMenu2* pcm2 = nullptr; + pcm->QueryInterface(IID_IContextMenu2, (void**)&pcm2); + + bool found = false; + HMENU hMenu = CreatePopupMenu(); + if (hMenu && SUCCEEDED(pcm->QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_NORMAL))) { + found = EnumContextMenuMatch(hMenu, pcm, pcm2, hwnd, matchText, 1); + if (!found) { + Wh_Log(L"No match for '%s' — dumping all context menu items:", matchText); + DumpContextMenuRecursive(hMenu, pcm, pcm2, 1, 0); + } + } + if (hMenu) DestroyMenu(hMenu); + if (pcm2) pcm2->Release(); + pcm->Release(); + return found; } // ---- Duplicate Tab infrastructure ---- @@ -358,6 +754,7 @@ static HWND g_pendingNavHwnd = NULL; static VOID CALLBACK NavigateNewTabProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime); static VOID CALLBACK MidClickTimerProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime); +static VOID CALLBACK DblClickTimerProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime); // ---- Middle-click double-click detection (timer-based) ---- // Single-click fires immediately if only single is configured. @@ -367,6 +764,14 @@ static VOID CALLBACK MidClickTimerProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, D static HWND g_midClickPendingHwnd = NULL; static UINT_PTR g_midClickTimerId = 0; +// Pending double-click timer — used when triple-click is configured. +// When triple-click is enabled, double-click is delayed by GetDoubleClickTime() +// so a third click can arrive and override it with the triple-click action. +static HWND g_pendingDblClickHwnd = NULL; +static UINT_PTR g_pendingDblClickTimerId = 0; +static std::wstring g_pendingDblClickAction; +static std::wstring g_pendingDblClickCombo; + static bool FindShellTabAndDoAction(HWND hWnd, PCWSTR action); static VOID CALLBACK MidClickTimerProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime) { @@ -390,6 +795,31 @@ static void CancelPendingMidClick() { g_midClickPendingHwnd = NULL; } +static VOID CALLBACK DblClickTimerProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime) { + CHECK_INIT_OR_RETURN_VOID(); + KillTimer(hwnd, idEvent); + g_pendingDblClickTimerId = 0; + if (g_pendingDblClickHwnd && IsWindow(g_pendingDblClickHwnd)) { + if (!g_pendingDblClickAction.empty()) { + if (!TryCustomHotkey(g_pendingDblClickAction.c_str(), g_pendingDblClickCombo)) + FindShellTabAndDoAction(g_pendingDblClickHwnd, g_pendingDblClickAction.c_str()); + } + } + g_pendingDblClickHwnd = NULL; + g_pendingDblClickAction.clear(); + g_pendingDblClickCombo.clear(); +} + +static void CancelPendingDblClick() { + if (g_pendingDblClickTimerId && g_pendingDblClickHwnd && IsWindow(g_pendingDblClickHwnd)) { + KillTimer(g_pendingDblClickHwnd, g_pendingDblClickTimerId); + } + g_pendingDblClickTimerId = 0; + g_pendingDblClickHwnd = NULL; + g_pendingDblClickAction.clear(); + g_pendingDblClickCombo.clear(); +} + // ---- ExplorerWrapper ---- class ExplorerWrapper { @@ -527,6 +957,53 @@ class ExplorerWrapper { } } + // ---- External program launchers (via the folder background context menu) ---- + // These borrow the right-click-on-empty-space menu, so they adapt to whatever + // program registered the corresponding verb (path-independent). + + void OpenInVSCode() { + wchar_t path[MAX_PATH] = {}; + if (!GetCurrentFolderPath(path, MAX_PATH)) return; + // "Code" matches verb "VSCode" and display "Open with Code" / "通过 Code 打开" + if (!InvokeFolderContextMenuVerb(path, hShellTab, L"Code")) + Wh_Log(L"OpenInVSCode: no matching context menu entry found"); + } + + void OpenInTerminal() { + wchar_t path[MAX_PATH] = {}; + if (!GetCurrentFolderPath(path, MAX_PATH)) return; + // Try English first (matches verb "WindowsTerminal" etc.), then Chinese + if (InvokeFolderContextMenuVerb(path, hShellTab, L"Terminal")) return; + if (InvokeFolderContextMenuVerb(path, hShellTab, L"终端")) return; + Wh_Log(L"OpenInTerminal: no matching context menu entry found"); + } + + void OpenInCursor() { + wchar_t path[MAX_PATH] = {}; + if (!GetCurrentFolderPath(path, MAX_PATH)) return; + if (!InvokeFolderContextMenuVerb(path, hShellTab, L"Cursor")) + Wh_Log(L"OpenInCursor: no matching context menu entry found"); + } + + void OpenWithContextMenu() { + // Thread-safe read: copy the match string under the settings lock so + // a concurrent Wh_ModSettingsChanged() cannot free it mid-use (UAF). + std::wstring match; + { + std::lock_guard lock(g_settingsMutex); + PCWSTR s = g_contextMenuMatch.Get(); + if (s) match = s; + } + if (match.empty()) { + Wh_Log(L"OpenWithContextMenu: no match text configured (Context Menu Match setting)"); + return; + } + wchar_t path[MAX_PATH] = {}; + if (!GetCurrentFolderPath(path, MAX_PATH)) return; + if (!InvokeFolderContextMenuVerb(path, hShellTab, match.c_str())) + Wh_Log(L"OpenWithContextMenu: no context menu entry matching '%s'", match.c_str()); + } + // ---- Dispatch ---- void DoAction(PCWSTR action) { @@ -544,6 +1021,10 @@ class ExplorerWrapper { else if (wcscmp(action, L"newFolder") == 0) NewFolder(); else if (wcscmp(action, L"copyPath") == 0) CopyPath(); else if (wcscmp(action, L"paste") == 0) Paste(); + else if (wcscmp(action, L"openInVSCode") == 0) OpenInVSCode(); + else if (wcscmp(action, L"openInTerminal") == 0) OpenInTerminal(); + else if (wcscmp(action, L"openInCursor") == 0) OpenInCursor(); + else if (wcscmp(action, L"openWithContextMenu") == 0) OpenWithContextMenu(); // "none" or unknown — do nothing } }; @@ -592,7 +1073,10 @@ static VOID CALLBACK NavigateNewTabProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, // ---- Helper: find ExplorerWrapper by shellTab HWND and run action ---- static bool FindShellTabAndDoAction(HWND hWnd, PCWSTR action) { - if (!hWnd || !IsWindow(hWnd) || !action) return false; + if (!hWnd || !IsWindow(hWnd) || !action) { + Wh_Log(L"FindShellTabAndDoAction: invalid args hWnd=%p action=%s", hWnd, action ? action : L"null"); + return false; + } int limit = 16; HWND parent = GetParent(hWnd); while (parent && limit-- > 0) { @@ -616,10 +1100,12 @@ static bool FindShellTabAndDoAction(HWND hWnd, PCWSTR action) { tmp.DoAction(action); return true; } + Wh_Log(L"FindShellTabAndDoAction: no wrapper for shellTab=%p, action=%s", shellTab, action); break; } parent = GetParent(parent); } + Wh_Log(L"FindShellTabAndDoAction: ShellTabWindowClass not found from hWnd=%p, action=%s", hWnd, action); return false; } @@ -629,9 +1115,56 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM DWORD_PTR dwRefData) { CHECK_INIT_OR_DEFER(hWnd, uMsg, wParam, lParam); + // Fast path: skip settings copy for messages we don't handle + if (uMsg != WM_LBUTTONDOWN && uMsg != WM_LBUTTONDBLCLK && uMsg != WM_MBUTTONDOWN) + return DefSubclassProc(hWnd, uMsg, wParam, lParam); + SettingsSnapshot s = CopySettings(); - if (uMsg == WM_LBUTTONDBLCLK) { + if (uMsg == WM_LBUTTONDOWN) { + bool ctrlOn = (wcscmp(s.ctrlClick.c_str(), L"none") != 0); + bool altOn = (wcscmp(s.altClick.c_str(), L"none") != 0); + bool shiftOn = (wcscmp(s.shiftClick.c_str(), L"none") != 0); + bool tripleOn = (wcscmp(s.tripleClick.c_str(), L"none") != 0); + + if (!ctrlOn && !altOn && !shiftOn && !tripleOn) + return DefSubclassProc(hWnd, uMsg, wParam, lParam); + + POINT mousePos; + GetCursorPos(&mousePos); + ScreenToClient(hWnd, &mousePos); + LVHITTESTINFO ht = {}; + ht.flags = LVHT_NOWHERE; + ht.pt = mousePos; + if (ListView_SubItemHitTest(hWnd, &ht) != -1) + return DefSubclassProc(hWnd, uMsg, wParam, lParam); // clicked on an item + + // Triple-click override: a pending double-click exists on this HWND; + // cancel the double-click and fire triple-click instead. + if (tripleOn && g_pendingDblClickHwnd == hWnd && g_pendingDblClickTimerId != 0) { + CancelPendingDblClick(); + if (!TryCustomHotkey(s.tripleClick.c_str(), s.tripleClickCombo)) + FindShellTabAndDoAction(hWnd, s.tripleClick.c_str()); + return DefSubclassProc(hWnd, uMsg, wParam, lParam); + } + + // Modifier+click checks + bool ctrlDown = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + bool altDown = (GetKeyState(VK_MENU) & 0x8000) != 0; + bool shiftDown = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + + if (ctrlOn && ctrlDown) { + if (!TryCustomHotkey(s.ctrlClick.c_str(), s.ctrlClickCombo)) + FindShellTabAndDoAction(hWnd, s.ctrlClick.c_str()); + } else if (altOn && altDown) { + if (!TryCustomHotkey(s.altClick.c_str(), s.altClickCombo)) + FindShellTabAndDoAction(hWnd, s.altClick.c_str()); + } else if (shiftOn && shiftDown) { + if (!TryCustomHotkey(s.shiftClick.c_str(), s.shiftClickCombo)) + FindShellTabAndDoAction(hWnd, s.shiftClick.c_str()); + } + + } else if (uMsg == WM_LBUTTONDBLCLK) { if (wcscmp(s.doubleClick.c_str(), L"none") == 0) return DefSubclassProc(hWnd, uMsg, wParam, lParam); @@ -641,7 +1174,21 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM LVHITTESTINFO ht = {}; ht.flags = LVHT_NOWHERE; ht.pt = mousePos; - if (ListView_SubItemHitTest(hWnd, &ht) == -1) { + if (ListView_SubItemHitTest(hWnd, &ht) != -1) + return DefSubclassProc(hWnd, uMsg, wParam, lParam); // clicked on an item + + bool tripleOn = (wcscmp(s.tripleClick.c_str(), L"none") != 0); + + if (tripleOn) { + // Delay double-click to wait for possible third click that overrides it + CancelPendingDblClick(); + g_pendingDblClickHwnd = hWnd; + g_pendingDblClickAction = s.doubleClick; + g_pendingDblClickCombo = s.doubleClickCombo; + g_pendingDblClickTimerId = SetTimer(hWnd, 0x4D45, + GetDoubleClickTime(), DblClickTimerProc); + } else { + // Instant double-click (no triple-click configured) if (!TryCustomHotkey(s.doubleClick.c_str(), s.doubleClickCombo)) FindShellTabAndDoAction(hWnd, s.doubleClick.c_str()); } @@ -688,7 +1235,7 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM struct ClickHelper { DWORD time = 0; - wchar_t className[256]; + std::wstring className; HWND hWnd = NULL; }; @@ -702,6 +1249,10 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, if (uMsg != WM_PARENTNOTIFY) return DefSubclassProc(hWnd, uMsg, wParam, lParam); + // Fast path: only handle mouse button events + if (wParam != WM_LBUTTONDOWN && wParam != WM_MBUTTONDOWN) + return DefSubclassProc(hWnd, uMsg, wParam, lParam); + auto pUIA = GetUIAutomation(); if (!pUIA) return DefSubclassProc(hWnd, uMsg, wParam, lParam); @@ -748,47 +1299,94 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, return DefSubclassProc(hWnd, uMsg, wParam, lParam); } - // Left click — track for double-click detection + // Left click — double-click, triple-click, and modifier+click detection if (wParam == WM_LBUTTONDOWN) { - if (wcscmp(s.doubleClick.c_str(), L"none") == 0) - return DefSubclassProc(hWnd, uMsg, wParam, lParam); + bool dblOn = (wcscmp(s.doubleClick.c_str(), L"none") != 0); + bool tripleOn = (wcscmp(s.tripleClick.c_str(), L"none") != 0); + bool ctrlOn = (wcscmp(s.ctrlClick.c_str(), L"none") != 0); + bool altOn = (wcscmp(s.altClick.c_str(), L"none") != 0); + bool shiftOn = (wcscmp(s.shiftClick.c_str(), L"none") != 0); - DWORD now = GetTickCount(); - g_currentClick.time = now; - g_currentClick.hWnd = hWnd; + if (!dblOn && !tripleOn && !ctrlOn && !altOn && !shiftOn) + return DefSubclassProc(hWnd, uMsg, wParam, lParam); POINT mousePos; GetCursorPos(&mousePos); winrt::com_ptr pElement = NULL; - if (SUCCEEDED(pUIA->ElementFromPoint(mousePos, pElement.put())) && pElement) { - bstr_ptr clsName; - if (SUCCEEDED(pElement->get_CurrentClassName(clsName.GetAddress()))) { - wchar_t* cn = clsName.GetBSTR(); - if (!cn || (wcscmp(cn, L"UIGroupItem") != 0 && wcscmp(cn, L"UIItemsView") != 0)) { - return DefSubclassProc(hWnd, uMsg, wParam, lParam); - } - wcsncpy(g_currentClick.className, cn, 255); - g_currentClick.className[255] = L'\0'; - - DWORD delta = g_currentClick.time - g_lastClick.time; - if (g_currentClick.hWnd == g_lastClick.hWnd && - ((wcscmp(cn, L"UIGroupItem") == 0 && - wcscmp(g_lastClick.className, L"UIGroupItem") == 0) || - (wcscmp(cn, L"UIItemsView") == 0 && - wcscmp(g_lastClick.className, L"UIItemsView") == 0)) && - delta <= GetDoubleClickTime()) { + if (FAILED(pUIA->ElementFromPoint(mousePos, pElement.put())) || !pElement) + return DefSubclassProc(hWnd, uMsg, wParam, lParam); + + bstr_ptr clsName; + if (FAILED(pElement->get_CurrentClassName(clsName.GetAddress()))) + return DefSubclassProc(hWnd, uMsg, wParam, lParam); + + wchar_t* cn = clsName.GetBSTR(); + if (!cn || (wcscmp(cn, L"UIGroupItem") != 0 && wcscmp(cn, L"UIItemsView") != 0)) + return DefSubclassProc(hWnd, uMsg, wParam, lParam); // clicked on an item + + // ========== On empty space ========== + + // 1. Triple-click override: a pending double-click exists on this HWND; + // cancel the double-click and fire triple-click instead. + if (tripleOn && g_pendingDblClickHwnd == hWnd && g_pendingDblClickTimerId != 0) { + CancelPendingDblClick(); + if (!TryCustomHotkey(s.tripleClick.c_str(), s.tripleClickCombo)) + FindShellTabAndDoAction(hWnd, s.tripleClick.c_str()); + return DefSubclassProc(hWnd, uMsg, wParam, lParam); + } + + // 2. Double-click detection + DWORD now = GetTickCount(); + g_currentClick.time = now; + g_currentClick.hWnd = hWnd; + g_currentClick.className = cn; + + DWORD delta = now - g_lastClick.time; + if (g_currentClick.hWnd == g_lastClick.hWnd && + ((wcscmp(cn, L"UIGroupItem") == 0 && + g_lastClick.className == L"UIGroupItem") || + (wcscmp(cn, L"UIItemsView") == 0 && + g_lastClick.className == L"UIItemsView")) && + delta <= (DWORD)GetDoubleClickTime()) { + // This is a double-click + if (dblOn) { + if (tripleOn) { + // Delay double-click to wait for possible third click + CancelPendingDblClick(); + g_pendingDblClickHwnd = hWnd; + g_pendingDblClickAction = s.doubleClick; + g_pendingDblClickCombo = s.doubleClickCombo; + g_pendingDblClickTimerId = SetTimer(hWnd, 0x4D45, + GetDoubleClickTime(), DblClickTimerProc); + } else { + // Instant double-click (no triple-click configured) if (!TryCustomHotkey(s.doubleClick.c_str(), s.doubleClickCombo)) FindShellTabAndDoAction(hWnd, s.doubleClick.c_str()); - g_lastClick.time = 0; // prevent triple-click from double-firing - } else { - g_lastClick.time = now; } - - g_lastClick.hWnd = hWnd; - wcsncpy(g_lastClick.className, cn, 255); - g_lastClick.className[255] = L'\0'; } + g_lastClick.time = 0; // prevent next click from being another double-click + } else { + // Single click — check modifier+click combos + bool ctrlDown = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + bool altDown = (GetKeyState(VK_MENU) & 0x8000) != 0; + bool shiftDown = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + + if (ctrlOn && ctrlDown) { + if (!TryCustomHotkey(s.ctrlClick.c_str(), s.ctrlClickCombo)) + FindShellTabAndDoAction(hWnd, s.ctrlClick.c_str()); + } else if (altOn && altDown) { + if (!TryCustomHotkey(s.altClick.c_str(), s.altClickCombo)) + FindShellTabAndDoAction(hWnd, s.altClick.c_str()); + } else if (shiftOn && shiftDown) { + if (!TryCustomHotkey(s.shiftClick.c_str(), s.shiftClickCombo)) + FindShellTabAndDoAction(hWnd, s.shiftClick.c_str()); + } + + g_lastClick.time = now; } + + g_lastClick.hWnd = hWnd; + g_lastClick.className = cn; } return DefSubclassProc(hWnd, uMsg, wParam, lParam); @@ -968,6 +1566,7 @@ void Wh_ModUninit() { InterlockedExchange(&g_initialized, 0); CancelPendingMidClick(); + CancelPendingDblClick(); if (g_pendingNavHwnd && IsWindow(g_pendingNavHwnd)) { KillTimer(g_pendingNavHwnd, 0x4D43); From 6c18fc0ac0a3ce7b21e6df097cee0e9b31f0a8b7 Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 11:56:41 +0800 Subject: [PATCH 02/12] v2.7.0: Shorten @description --- mods/click-on-empty-explorer.wh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index ee49bbb451..2cce2fd6bd 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -1,7 +1,7 @@ // ==WindhawkMod== // @id click-on-empty-explorer // @name Click on Empty Explorer -// @description Configure double/triple click, middle click, double middle click, and modifier+click actions on empty space in File Explorer. "Open in..." actions reuse the folder's right-click background context menu, so any registered program (VS Code, Terminal, Cursor, Git Bash...) works. +// @description Configure double/triple click, middle click, double middle click, and modifier+click actions on empty space in File Explorer. Supports 17 actions including navigation, tabs, custom hotkeys, and invoking any right-click context menu entry. // @version 2.7.0 // @author LiHua81 // @github https://github.com/LiHua81 From b390c4396e97c19696085fdebbbb665462cc1630 Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 12:02:02 +0800 Subject: [PATCH 03/12] Remove deprecated MIIM_TYPE from MENUITEMINFO fMask --- mods/click-on-empty-explorer.wh.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index 2cce2fd6bd..b587a0ecf5 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -591,7 +591,7 @@ static bool EnumContextMenuMatch(HMENU hMenu, IContextMenu* pcm, IContextMenu2* int count = GetMenuItemCount(hMenu); for (int i = 0; i < count; i++) { MENUITEMINFOW mii = { sizeof(mii) }; - mii.fMask = MIIM_ID | MIIM_TYPE | MIIM_SUBMENU; + mii.fMask = MIIM_ID | MIIM_SUBMENU; if (!GetMenuItemInfoW(hMenu, i, TRUE, &mii)) continue; // Skip separators (wID == 0). Without this, offset = 0 - 1 = UINT_MAX is @@ -652,7 +652,7 @@ static void DumpContextMenuRecursive(HMENU hMenu, IContextMenu* pcm, IContextMen int count = GetMenuItemCount(hMenu); for (int i = 0; i < count; i++) { MENUITEMINFOW mii = { sizeof(mii) }; - mii.fMask = MIIM_ID | MIIM_TYPE | MIIM_SUBMENU; + mii.fMask = MIIM_ID | MIIM_SUBMENU; if (!GetMenuItemInfoW(hMenu, i, TRUE, &mii)) continue; if (mii.wID == 0) continue; // separator if (mii.wID < (UINT)idCmdFirst || mii.wID > 0x7FFF) continue; From cb2a9c63a70146dd78a046654d13536368574090 Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 12:42:37 +0800 Subject: [PATCH 04/12] =?UTF-8?q?v2.7.0:=20Address=20AI=20review=20?= =?UTF-8?q?=E2=80=94=20timer=20safety,=20subclass=20tracking,=20thread=20s?= =?UTF-8?q?afety,=20async=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mods/click-on-empty-explorer.wh.cpp | 215 ++++++++++++++++++---------- 1 file changed, 141 insertions(+), 74 deletions(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index b587a0ecf5..f09ae542f3 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -328,7 +328,9 @@ require Windows 11 for tabbed Explorer support. #include #include +#include #include +#include #include #include @@ -594,21 +596,9 @@ static bool EnumContextMenuMatch(HMENU hMenu, IContextMenu* pcm, IContextMenu2* mii.fMask = MIIM_ID | MIIM_SUBMENU; if (!GetMenuItemInfoW(hMenu, i, TRUE, &mii)) continue; - // Skip separators (wID == 0). Without this, offset = 0 - 1 = UINT_MAX is - // passed to GetCommandString/InvokeCommand → undefined behaviour / crash. - if (mii.wID == 0) continue; - - // Command-ID range guard: the context menu's QueryContextMenu uses - // the range [idCmdFirst..0x7FFF]. Non-context-menu submenus (e.g. - // "New", "View", "Sort by") contain items with system-assigned IDs - // outside this range. Passing an out-of-range offset to GetCommandString - // or InvokeCommand is array-index OOB → instant Explorer crash. - if (mii.wID < (UINT)idCmdFirst || mii.wID > 0x7FFF) continue; - + // Cascading submenu — check before wID guards, since submenu items + // carry an HMENU as wID (not a valid command ID in the context-menu range). if (mii.hSubMenu != NULL) { - // Cascading submenu: populate it by forwarding WM_INITMENUPOPUP to - // IContextMenu2 (e.g. the Windows Terminal / 7-Zip cascades). Without - // this the submenu is empty and its entries can never be matched. if (pcm2) pcm2->HandleMenuMsg(WM_INITMENUPOPUP, (WPARAM)mii.hSubMenu, MAKELPARAM(i, 0)); if (EnumContextMenuMatch(mii.hSubMenu, pcm, pcm2, hwnd, matchText, idCmdFirst)) @@ -616,7 +606,11 @@ static bool EnumContextMenuMatch(HMENU hMenu, IContextMenu* pcm, IContextMenu2* continue; } - // Leaf item: read verb + display text. + // Leaf item: skip separators and items outside the context-menu command range. + if (mii.wID == 0) continue; // separator + if (mii.wID < (UINT)idCmdFirst || mii.wID > 0x7FFF) continue; // outside range + + // Read verb + display text. UINT offset = mii.wID - idCmdFirst; CHAR verbA[MAX_PATH] = {}; @@ -654,13 +648,12 @@ static void DumpContextMenuRecursive(HMENU hMenu, IContextMenu* pcm, IContextMen MENUITEMINFOW mii = { sizeof(mii) }; mii.fMask = MIIM_ID | MIIM_SUBMENU; if (!GetMenuItemInfoW(hMenu, i, TRUE, &mii)) continue; - if (mii.wID == 0) continue; // separator - if (mii.wID < (UINT)idCmdFirst || mii.wID > 0x7FFF) continue; wchar_t indent[64] = {}; for (int d = 0; d < depth && d < 31; d++) indent[d] = L' '; indent[depth < 31 ? depth : 31] = 0; + // Cascading submenu — check before wID guards. if (mii.hSubMenu != NULL) { wchar_t stext[MAX_PATH] = {}; MENUITEMINFOW miiT = { sizeof(miiT) }; @@ -676,6 +669,10 @@ static void DumpContextMenuRecursive(HMENU hMenu, IContextMenu* pcm, IContextMen continue; } + // Leaf item: skip separators and items outside the context-menu command range. + if (mii.wID == 0) continue; + if (mii.wID < (UINT)idCmdFirst || mii.wID > 0x7FFF) continue; + UINT offset = mii.wID - idCmdFirst; CHAR verbA[MAX_PATH] = {}; pcm->GetCommandString(offset, GCS_VERBA, NULL, verbA, MAX_PATH); @@ -748,9 +745,9 @@ static bool InvokeFolderContextMenuVerb(PCWSTR folderPath, HWND hwnd, PCWSTR mat // ---- Duplicate Tab infrastructure ---- -static wchar_t g_pendingNavPath[MAX_PATH] = {}; -static winrt::com_ptr g_pendingNavBrowser; -static HWND g_pendingNavHwnd = NULL; +static thread_local wchar_t g_pendingNavPath[MAX_PATH] = {}; +static thread_local winrt::com_ptr g_pendingNavBrowser; +static thread_local HWND g_pendingNavHwnd = NULL; static VOID CALLBACK NavigateNewTabProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime); static VOID CALLBACK MidClickTimerProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime); @@ -761,19 +758,36 @@ static VOID CALLBACK DblClickTimerProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, D // If both single and double are configured, single is delayed by // GetDoubleClickTime() (~500ms) to detect double clicks. -static HWND g_midClickPendingHwnd = NULL; -static UINT_PTR g_midClickTimerId = 0; +static thread_local HWND g_midClickPendingHwnd = NULL; +static thread_local UINT_PTR g_midClickTimerId = 0; // Pending double-click timer — used when triple-click is configured. // When triple-click is enabled, double-click is delayed by GetDoubleClickTime() // so a third click can arrive and override it with the triple-click action. -static HWND g_pendingDblClickHwnd = NULL; -static UINT_PTR g_pendingDblClickTimerId = 0; -static std::wstring g_pendingDblClickAction; -static std::wstring g_pendingDblClickCombo; +static thread_local HWND g_pendingDblClickHwnd = NULL; +static thread_local UINT_PTR g_pendingDblClickTimerId = 0; +static thread_local std::wstring g_pendingDblClickAction; +static thread_local std::wstring g_pendingDblClickCombo; + +// Private message: dequeues action dispatch from mouse handlers to avoid +// blocking on COM activation inside WM_LBUTTONDOWN/WM_MBUTTONDOWN. +// wParam = (WPARAM)strdup(actionString), lParam = (LPARAM)hWnd +static UINT g_msgDoAction = 0; static bool FindShellTabAndDoAction(HWND hWnd, PCWSTR action); +// Post an action to be handled asynchronously — avoids synchronously activating +// context-menu handlers inside the mouse-down handler. +static void PostDoAction(HWND hWnd, PCWSTR action) { + if (!g_msgDoAction || !action || !*action) return; + size_t len = wcslen(action) + 1; + wchar_t* s = (wchar_t*)HeapAlloc(GetProcessHeap(), 0, len * sizeof(wchar_t)); + if (!s) return; + wcscpy_s(s, len, action); + if (!PostMessage(hWnd, g_msgDoAction, (WPARAM)s, (LPARAM)hWnd)) + HeapFree(GetProcessHeap(), 0, s); +} + static VOID CALLBACK MidClickTimerProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime) { KillTimer(hwnd, idEvent); g_midClickTimerId = 0; @@ -796,8 +810,8 @@ static void CancelPendingMidClick() { } static VOID CALLBACK DblClickTimerProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime) { - CHECK_INIT_OR_RETURN_VOID(); KillTimer(hwnd, idEvent); + CHECK_INIT_OR_RETURN_VOID(); g_pendingDblClickTimerId = 0; if (g_pendingDblClickHwnd && IsWindow(g_pendingDblClickHwnd)) { if (!g_pendingDblClickAction.empty()) { @@ -972,10 +986,8 @@ class ExplorerWrapper { void OpenInTerminal() { wchar_t path[MAX_PATH] = {}; if (!GetCurrentFolderPath(path, MAX_PATH)) return; - // Try English first (matches verb "WindowsTerminal" etc.), then Chinese - if (InvokeFolderContextMenuVerb(path, hShellTab, L"Terminal")) return; - if (InvokeFolderContextMenuVerb(path, hShellTab, L"终端")) return; - Wh_Log(L"OpenInTerminal: no matching context menu entry found"); + if (!InvokeFolderContextMenuVerb(path, hShellTab, L"Terminal")) + Wh_Log(L"OpenInTerminal: no matching context menu entry found"); } void OpenInCursor() { @@ -1031,9 +1043,18 @@ class ExplorerWrapper { // ---- Globals ---- -std::vector g_Wrappers; +// g_Wrappers holds per-thread COM pointers — its destructor would run on the +// CRT shutdown thread, releasing IShellBrowser on STA objects whose threads +// are already dead. Suppress automatic destruction; Wh_ModUninit handles cleanup. +[[clang::no_destroy]] static std::optional> g_Wrappers; static std::mutex g_wrappersMutex; +// Track subclassed windows independently of g_Wrappers so Wh_ModUninit +// can remove subclasses even for windows created after init. +struct SubclassEntry { HWND hWnd; bool isListView; }; +static std::vector g_subclassed; +static std::mutex g_subclassMutex; + // Lazily initialized on first use (Explorer UI thread has COM already). // Intentionally leaked (raw pointer) to avoid Release() during DLL_PROCESS_DETACH. static IUIAutomation* GetUIAutomation() { @@ -1049,8 +1070,8 @@ static IUIAutomation* GetUIAutomation() { // ---- NavigateNewTabProc (timer callback for duplicate tab) ---- static VOID CALLBACK NavigateNewTabProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime) { - CHECK_INIT_OR_RETURN_VOID(); KillTimer(hwnd, 0x4D43); + CHECK_INIT_OR_RETURN_VOID(); if (!g_pendingNavPath[0] || !g_pendingNavBrowser) { g_pendingNavPath[0] = L'\0'; @@ -1088,7 +1109,7 @@ static bool FindShellTabAndDoAction(HWND hWnd, PCWSTR action) { HWND shellTab = parent; { std::lock_guard lock(g_wrappersMutex); - for (ExplorerWrapper& w : g_Wrappers) { + for (ExplorerWrapper& w : *g_Wrappers) { if (w.hShellTab == shellTab) { browser = w.GetBrowser(); break; @@ -1115,6 +1136,23 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM DWORD_PTR dwRefData) { CHECK_INIT_OR_DEFER(hWnd, uMsg, wParam, lParam); + // Remove from subclass tracking on destroy (even during teardown) + if (uMsg == WM_NCDESTROY) { + std::lock_guard lk(g_subclassMutex); + std::erase_if(g_subclassed, [hWnd](const SubclassEntry& e) { return e.hWnd == hWnd; }); + return DefSubclassProc(hWnd, uMsg, wParam, lParam); + } + + // Deferred action dispatch (posted from mouse handlers to avoid blocking) + if (g_msgDoAction && uMsg == g_msgDoAction) { + PCWSTR action = (PCWSTR)wParam; + HWND target = (HWND)lParam; + if (action && *action && target) + FindShellTabAndDoAction(target, action); + HeapFree(GetProcessHeap(), 0, (void*)wParam); + return 0; + } + // Fast path: skip settings copy for messages we don't handle if (uMsg != WM_LBUTTONDOWN && uMsg != WM_LBUTTONDBLCLK && uMsg != WM_MBUTTONDOWN) return DefSubclassProc(hWnd, uMsg, wParam, lParam); @@ -1144,7 +1182,7 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM if (tripleOn && g_pendingDblClickHwnd == hWnd && g_pendingDblClickTimerId != 0) { CancelPendingDblClick(); if (!TryCustomHotkey(s.tripleClick.c_str(), s.tripleClickCombo)) - FindShellTabAndDoAction(hWnd, s.tripleClick.c_str()); + PostDoAction(hWnd, s.tripleClick.c_str()); return DefSubclassProc(hWnd, uMsg, wParam, lParam); } @@ -1155,13 +1193,13 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM if (ctrlOn && ctrlDown) { if (!TryCustomHotkey(s.ctrlClick.c_str(), s.ctrlClickCombo)) - FindShellTabAndDoAction(hWnd, s.ctrlClick.c_str()); + PostDoAction(hWnd, s.ctrlClick.c_str()); } else if (altOn && altDown) { if (!TryCustomHotkey(s.altClick.c_str(), s.altClickCombo)) - FindShellTabAndDoAction(hWnd, s.altClick.c_str()); + PostDoAction(hWnd, s.altClick.c_str()); } else if (shiftOn && shiftDown) { if (!TryCustomHotkey(s.shiftClick.c_str(), s.shiftClickCombo)) - FindShellTabAndDoAction(hWnd, s.shiftClick.c_str()); + PostDoAction(hWnd, s.shiftClick.c_str()); } } else if (uMsg == WM_LBUTTONDBLCLK) { @@ -1190,7 +1228,7 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM } else { // Instant double-click (no triple-click configured) if (!TryCustomHotkey(s.doubleClick.c_str(), s.doubleClickCombo)) - FindShellTabAndDoAction(hWnd, s.doubleClick.c_str()); + PostDoAction(hWnd, s.doubleClick.c_str()); } } else if (uMsg == WM_MBUTTONDOWN) { @@ -1210,7 +1248,7 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM if (singleOn && !doubleOn) { if (!TryCustomHotkey(s.middleClick.c_str(), s.middleClickCombo)) - FindShellTabAndDoAction(hWnd, s.middleClick.c_str()); + PostDoAction(hWnd, s.middleClick.c_str()); return DefSubclassProc(hWnd, uMsg, wParam, lParam); } @@ -1219,7 +1257,7 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM if (isDouble) { CancelPendingMidClick(); if (!TryCustomHotkey(s.doubleMiddleClick.c_str(), s.doubleMiddleClickCombo)) - FindShellTabAndDoAction(hWnd, s.doubleMiddleClick.c_str()); + PostDoAction(hWnd, s.doubleMiddleClick.c_str()); } else { CancelPendingMidClick(); g_midClickPendingHwnd = hWnd; @@ -1239,13 +1277,30 @@ struct ClickHelper { HWND hWnd = NULL; }; -static ClickHelper g_currentClick; -static ClickHelper g_lastClick; +static thread_local ClickHelper g_currentClick; +static thread_local ClickHelper g_lastClick; LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, DWORD_PTR dwRefData) { CHECK_INIT_OR_DEFER(hWnd, uMsg, wParam, lParam); + // Remove from subclass tracking on destroy (even during teardown) + if (uMsg == WM_NCDESTROY) { + std::lock_guard lk(g_subclassMutex); + std::erase_if(g_subclassed, [hWnd](const SubclassEntry& e) { return e.hWnd == hWnd; }); + return DefSubclassProc(hWnd, uMsg, wParam, lParam); + } + + // Deferred action dispatch (posted from mouse handlers to avoid blocking) + if (g_msgDoAction && uMsg == g_msgDoAction) { + PCWSTR action = (PCWSTR)wParam; + HWND target = (HWND)lParam; + if (action && *action && target) + FindShellTabAndDoAction(target, action); + HeapFree(GetProcessHeap(), 0, (void*)wParam); + return 0; + } + if (uMsg != WM_PARENTNOTIFY) return DefSubclassProc(hWnd, uMsg, wParam, lParam); @@ -1277,7 +1332,7 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, if (singleOn && !doubleOn) { if (!TryCustomHotkey(s.middleClick.c_str(), s.middleClickCombo)) - FindShellTabAndDoAction(hWnd, s.middleClick.c_str()); + PostDoAction(hWnd, s.middleClick.c_str()); return DefSubclassProc(hWnd, uMsg, wParam, lParam); } @@ -1286,7 +1341,7 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, if (isDouble) { CancelPendingMidClick(); if (!TryCustomHotkey(s.doubleMiddleClick.c_str(), s.doubleMiddleClickCombo)) - FindShellTabAndDoAction(hWnd, s.doubleMiddleClick.c_str()); + PostDoAction(hWnd, s.doubleMiddleClick.c_str()); } else { CancelPendingMidClick(); g_midClickPendingHwnd = hWnd; @@ -1331,7 +1386,7 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, if (tripleOn && g_pendingDblClickHwnd == hWnd && g_pendingDblClickTimerId != 0) { CancelPendingDblClick(); if (!TryCustomHotkey(s.tripleClick.c_str(), s.tripleClickCombo)) - FindShellTabAndDoAction(hWnd, s.tripleClick.c_str()); + PostDoAction(hWnd, s.tripleClick.c_str()); return DefSubclassProc(hWnd, uMsg, wParam, lParam); } @@ -1361,7 +1416,7 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, } else { // Instant double-click (no triple-click configured) if (!TryCustomHotkey(s.doubleClick.c_str(), s.doubleClickCombo)) - FindShellTabAndDoAction(hWnd, s.doubleClick.c_str()); + PostDoAction(hWnd, s.doubleClick.c_str()); } } g_lastClick.time = 0; // prevent next click from being another double-click @@ -1373,13 +1428,13 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, if (ctrlOn && ctrlDown) { if (!TryCustomHotkey(s.ctrlClick.c_str(), s.ctrlClickCombo)) - FindShellTabAndDoAction(hWnd, s.ctrlClick.c_str()); + PostDoAction(hWnd, s.ctrlClick.c_str()); } else if (altOn && altDown) { if (!TryCustomHotkey(s.altClick.c_str(), s.altClickCombo)) - FindShellTabAndDoAction(hWnd, s.altClick.c_str()); + PostDoAction(hWnd, s.altClick.c_str()); } else if (shiftOn && shiftDown) { if (!TryCustomHotkey(s.shiftClick.c_str(), s.shiftClickCombo)) - FindShellTabAndDoAction(hWnd, s.shiftClick.c_str()); + PostDoAction(hWnd, s.shiftClick.c_str()); } g_lastClick.time = now; @@ -1426,13 +1481,20 @@ HWND WINAPI CreateWindowExW_hook(DWORD dwExStyle, LPCWSTR lpClassName, if (wcscmp(className, L"SysListView32") == 0) { WindhawkUtils::SetWindowSubclassFromAnyThread(hWnd, SysListViewSubclass, 0); + { std::lock_guard lk(g_subclassMutex); + g_subclassed.push_back({ hWnd, true }); + } { std::lock_guard lk(g_wrappersMutex); for (auto& w : g_Wrappers) if (w.hShellTab == shellTab) { w.hListView = hWnd; break; } } } else { - if (IsWindow(defView)) + if (IsWindow(defView)) { WindhawkUtils::SetWindowSubclassFromAnyThread(defView, DUISubclass, 0); + { std::lock_guard lk(g_subclassMutex); + g_subclassed.push_back({ defView, false }); + } + } { std::lock_guard lk(g_wrappersMutex); for (auto& w : g_Wrappers) if (w.hShellTab == shellTab) { w.hListView = defView; break; } @@ -1457,7 +1519,11 @@ HRESULT __cdecl FileCabinet_CreateViewWindow2Hook( if (shellTab && IsWindow(shellTab)) { { std::lock_guard lock(g_wrappersMutex); - g_Wrappers.push_back(ExplorerWrapper(shellTab, pBrowser)); + // Prune dead entries and stale entries for the same HWND + std::erase_if(*g_Wrappers, [shellTab](const ExplorerWrapper& w) { + return w.hShellTab == shellTab || !IsWindow(w.hShellTab); + }); + g_Wrappers->push_back(ExplorerWrapper(shellTab, pBrowser)); } if (g_pendingNavPath[0] && !g_pendingNavBrowser) { g_pendingNavBrowser.copy_from(pBrowser); @@ -1476,10 +1542,9 @@ BOOL CALLBACK InitEnumChildWindowsProc(HWND hWnd, LPARAM lParam) { GetClassName(hWnd, className, 256); if (wcscmp(className, L"SHELLDLL_DefView") == 0) { HWND shellTab = (HWND)lParam; - auto browser = winrt::com_ptr{ - reinterpret_cast((void*)SendMessage(shellTab, WM_USER + 7, 0, 0)), - winrt::take_ownership_from_abi - }; + winrt::com_ptr browser; + browser.copy_from(reinterpret_cast( + (void*)SendMessage(shellTab, WM_USER + 7, 0, 0))); if (browser != NULL) { ExplorerWrapper wrapper(shellTab, browser.get()); HWND lv = FindWindowEx(hWnd, NULL, L"SysListView32", NULL); @@ -1497,7 +1562,7 @@ BOOL CALLBACK InitEnumChildWindowsProc(HWND hWnd, LPARAM lParam) { } if (wrapper.hListView) { std::lock_guard lk(g_wrappersMutex); - g_Wrappers.push_back(wrapper); + g_Wrappers->push_back(wrapper); } else Wh_Log(L"Failed to setup wrapper for %p", shellTab); return FALSE; } @@ -1526,6 +1591,8 @@ BOOL CALLBACK InitEnumWindowsProc(HWND hWnd, LPARAM lParam) { BOOL Wh_ModInit() { Wh_Log(L"Click on Empty Explorer Init"); + g_msgDoAction = RegisterWindowMessage(L"ClickOnEmptyExplorer_DoAction"); + g_Wrappers.emplace(); LoadSettings(); HMODULE hExplorerFrame = LoadLibraryExW(L"explorerframe.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); @@ -1575,24 +1642,24 @@ void Wh_ModUninit() { g_pendingNavBrowser = nullptr; g_pendingNavPath[0] = L'\0'; - // Collect HWNDs under lock, then remove subclasses outside the lock - // (RemoveWindowSubclassFromAnyThread does SendMessage which can deadlock - // if the target thread is waiting on g_wrappersMutex) - struct SubclassInfo { HWND hWnd; bool isListView; }; - std::vector toRemove; + // Collect subclassed HWNDs under lock, remove subclasses outside the lock. + // Use g_subclassed (populated at subclass time and cleaned by WM_NCDESTROY) + // instead of iterating g_Wrappers (which may not have entries for + // post-init windows — see issue 1 in review). + std::vector toRemove; + { + std::lock_guard lk(g_subclassMutex); + std::swap(toRemove, g_subclassed); + } + for (auto& e : toRemove) { + if (e.hWnd && IsWindow(e.hWnd)) + WindhawkUtils::RemoveWindowSubclassFromAnyThread( + e.hWnd, e.isListView ? SysListViewSubclass : DUISubclass); + } + + // Release wrapper COM references { std::lock_guard lk(g_wrappersMutex); - for (ExplorerWrapper& wrapper : g_Wrappers) { - HWND hWnd = wrapper.hListView; - if (hWnd && IsWindow(hWnd)) { - wchar_t className[256]; - if (GetClassName(hWnd, className, 256)) - toRemove.push_back({ hWnd, wcscmp(className, L"SysListView32") == 0 }); - } - } - g_Wrappers.clear(); + g_Wrappers.reset(); } - for (auto& info : toRemove) - WindhawkUtils::RemoveWindowSubclassFromAnyThread( - info.hWnd, info.isListView ? SysListViewSubclass : DUISubclass); } From 3c8bb0dacf5a974fc00ef92e7d7ccb55fed8c4ca Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 12:47:29 +0800 Subject: [PATCH 05/12] Fix missing *g_Wrappers deref for range-for in CreateWindowExW_hook --- mods/click-on-empty-explorer.wh.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index f09ae542f3..3db85845a8 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -1485,7 +1485,7 @@ HWND WINAPI CreateWindowExW_hook(DWORD dwExStyle, LPCWSTR lpClassName, g_subclassed.push_back({ hWnd, true }); } { std::lock_guard lk(g_wrappersMutex); - for (auto& w : g_Wrappers) + for (auto& w : *g_Wrappers) if (w.hShellTab == shellTab) { w.hListView = hWnd; break; } } } else { @@ -1496,7 +1496,7 @@ HWND WINAPI CreateWindowExW_hook(DWORD dwExStyle, LPCWSTR lpClassName, } } { std::lock_guard lk(g_wrappersMutex); - for (auto& w : g_Wrappers) + for (auto& w : *g_Wrappers) if (w.hShellTab == shellTab) { w.hListView = defView; break; } } } From 16777979ed4a31a6111a98edef17c4a151a9f377 Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 13:16:01 +0800 Subject: [PATCH 06/12] =?UTF-8?q?v2.7.0:=20Address=202nd=20AI=20review=20?= =?UTF-8?q?=E2=80=94=20subclass=20tracking,=20WM=5FTIMER,=20drop=20g=5FWra?= =?UTF-8?q?ppers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mods/click-on-empty-explorer.wh.cpp | 151 ++++++++++++---------------- 1 file changed, 62 insertions(+), 89 deletions(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index 3db85845a8..5b1039dd01 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -330,7 +330,6 @@ require Windows 11 for tabbed Explorer support. #include #include -#include #include #include @@ -867,11 +866,12 @@ class ExplorerWrapper { public: HWND hShellTab = NULL; - HWND hListView = NULL; + HWND m_timerHwnd = NULL; // subclassed HWND for timer messages - ExplorerWrapper(HWND hWnd, IShellBrowser* hShellBrowser) { - hShellTab = hWnd; + ExplorerWrapper(HWND shellTab, IShellBrowser* hShellBrowser, HWND timerHwnd = NULL) { + hShellTab = shellTab; hBrowser.copy_from(hShellBrowser); + m_timerHwnd = timerHwnd; } // Return a ref-counted copy of the browser for thread-safe access @@ -931,10 +931,9 @@ class ExplorerWrapper { wcsncpy(g_pendingNavPath, path, MAX_PATH - 1); g_pendingNavPath[MAX_PATH - 1] = L'\0'; g_pendingNavBrowser = nullptr; - g_pendingNavHwnd = hShellTab; + g_pendingNavHwnd = m_timerHwnd; SendKeyCombo(VK_CONTROL, 'T'); - if (IsWindow(hShellTab)) - SetTimer(hShellTab, 0x4D43, 500, NavigateNewTabProc); + SetTimer(m_timerHwnd, 0x4D43, 500, nullptr); } void CloseTab() { @@ -1043,14 +1042,8 @@ class ExplorerWrapper { // ---- Globals ---- -// g_Wrappers holds per-thread COM pointers — its destructor would run on the -// CRT shutdown thread, releasing IShellBrowser on STA objects whose threads -// are already dead. Suppress automatic destruction; Wh_ModUninit handles cleanup. -[[clang::no_destroy]] static std::optional> g_Wrappers; -static std::mutex g_wrappersMutex; - -// Track subclassed windows independently of g_Wrappers so Wh_ModUninit -// can remove subclasses even for windows created after init. +// Track subclassed windows so Wh_ModUninit can remove subclasses +// for windows created both during and after init. struct SubclassEntry { HWND hWnd; bool isListView; }; static std::vector g_subclassed; static std::mutex g_subclassMutex; @@ -1104,24 +1097,18 @@ static bool FindShellTabAndDoAction(HWND hWnd, PCWSTR action) { wchar_t className[256]; if (!GetClassName(parent, className, 256)) break; if (wcscmp(className, L"ShellTabWindowClass") == 0) { - // Copy the browser under lock, then act outside the lock - winrt::com_ptr browser; HWND shellTab = parent; - { - std::lock_guard lock(g_wrappersMutex); - for (ExplorerWrapper& w : *g_Wrappers) { - if (w.hShellTab == shellTab) { - browser = w.GetBrowser(); - break; - } - } - } + // Query the browser on-demand instead of caching in g_Wrappers + // (CWM_GETISHELLBROWSER returns a borrowed pointer, so copy_from to AddRef) + winrt::com_ptr browser; + browser.copy_from(reinterpret_cast( + (void*)SendMessage(shellTab, CWM_GETISHELLBROWSER, 0, 0))); if (browser) { - ExplorerWrapper tmp(shellTab, browser.get()); + ExplorerWrapper tmp(shellTab, browser.get(), hWnd); tmp.DoAction(action); return true; } - Wh_Log(L"FindShellTabAndDoAction: no wrapper for shellTab=%p, action=%s", shellTab, action); + Wh_Log(L"FindShellTabAndDoAction: no browser for shellTab=%p, action=%s", shellTab, action); break; } parent = GetParent(parent); @@ -1153,6 +1140,17 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM return 0; } + // WM_TIMER: handles deferred middle-click, double-click, and duplicate-tab timers. + // Uses nullptr callback (TIMERPROC lives in mod image, unsafe across unload). + if (uMsg == WM_TIMER) { + switch (wParam) { + case 0x4D43: NavigateNewTabProc(hWnd, uMsg, wParam, dwRefData); break; + case 0x4D44: MidClickTimerProc(hWnd, uMsg, wParam, dwRefData); break; + case 0x4D45: DblClickTimerProc(hWnd, uMsg, wParam, dwRefData); break; + } + return 0; + } + // Fast path: skip settings copy for messages we don't handle if (uMsg != WM_LBUTTONDOWN && uMsg != WM_LBUTTONDBLCLK && uMsg != WM_MBUTTONDOWN) return DefSubclassProc(hWnd, uMsg, wParam, lParam); @@ -1224,7 +1222,7 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM g_pendingDblClickAction = s.doubleClick; g_pendingDblClickCombo = s.doubleClickCombo; g_pendingDblClickTimerId = SetTimer(hWnd, 0x4D45, - GetDoubleClickTime(), DblClickTimerProc); + GetDoubleClickTime(), nullptr); } else { // Instant double-click (no triple-click configured) if (!TryCustomHotkey(s.doubleClick.c_str(), s.doubleClickCombo)) @@ -1262,7 +1260,7 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM CancelPendingMidClick(); g_midClickPendingHwnd = hWnd; g_midClickTimerId = SetTimer(hWnd, 0x4D44, - GetDoubleClickTime(), MidClickTimerProc); + GetDoubleClickTime(), nullptr); } } @@ -1301,6 +1299,16 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, return 0; } + // WM_TIMER: handles deferred middle-click, double-click, and duplicate-tab timers. + if (uMsg == WM_TIMER) { + switch (wParam) { + case 0x4D43: NavigateNewTabProc(hWnd, uMsg, wParam, dwRefData); break; + case 0x4D44: MidClickTimerProc(hWnd, uMsg, wParam, dwRefData); break; + case 0x4D45: DblClickTimerProc(hWnd, uMsg, wParam, dwRefData); break; + } + return 0; + } + if (uMsg != WM_PARENTNOTIFY) return DefSubclassProc(hWnd, uMsg, wParam, lParam); @@ -1346,7 +1354,7 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, CancelPendingMidClick(); g_midClickPendingHwnd = hWnd; g_midClickTimerId = SetTimer(hWnd, 0x4D44, - GetDoubleClickTime(), MidClickTimerProc); + GetDoubleClickTime(), nullptr); } } } @@ -1412,7 +1420,7 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, g_pendingDblClickAction = s.doubleClick; g_pendingDblClickCombo = s.doubleClickCombo; g_pendingDblClickTimerId = SetTimer(hWnd, 0x4D45, - GetDoubleClickTime(), DblClickTimerProc); + GetDoubleClickTime(), nullptr); } else { // Instant double-click (no triple-click configured) if (!TryCustomHotkey(s.doubleClick.c_str(), s.doubleClickCombo)) @@ -1484,10 +1492,6 @@ HWND WINAPI CreateWindowExW_hook(DWORD dwExStyle, LPCWSTR lpClassName, { std::lock_guard lk(g_subclassMutex); g_subclassed.push_back({ hWnd, true }); } - { std::lock_guard lk(g_wrappersMutex); - for (auto& w : *g_Wrappers) - if (w.hShellTab == shellTab) { w.hListView = hWnd; break; } - } } else { if (IsWindow(defView)) { WindhawkUtils::SetWindowSubclassFromAnyThread(defView, DUISubclass, 0); @@ -1495,10 +1499,6 @@ HWND WINAPI CreateWindowExW_hook(DWORD dwExStyle, LPCWSTR lpClassName, g_subclassed.push_back({ defView, false }); } } - { std::lock_guard lk(g_wrappersMutex); - for (auto& w : *g_Wrappers) - if (w.hShellTab == shellTab) { w.hListView = defView; break; } - } } return hWnd; } @@ -1517,14 +1517,6 @@ HRESULT __cdecl FileCabinet_CreateViewWindow2Hook( HWND shellTab = GetParent(*hWnd); if (shellTab && IsWindow(shellTab)) { - { - std::lock_guard lock(g_wrappersMutex); - // Prune dead entries and stale entries for the same HWND - std::erase_if(*g_Wrappers, [shellTab](const ExplorerWrapper& w) { - return w.hShellTab == shellTab || !IsWindow(w.hShellTab); - }); - g_Wrappers->push_back(ExplorerWrapper(shellTab, pBrowser)); - } if (g_pendingNavPath[0] && !g_pendingNavBrowser) { g_pendingNavBrowser.copy_from(pBrowser); } @@ -1541,31 +1533,22 @@ BOOL CALLBACK InitEnumChildWindowsProc(HWND hWnd, LPARAM lParam) { wchar_t className[256]; GetClassName(hWnd, className, 256); if (wcscmp(className, L"SHELLDLL_DefView") == 0) { - HWND shellTab = (HWND)lParam; - winrt::com_ptr browser; - browser.copy_from(reinterpret_cast( - (void*)SendMessage(shellTab, WM_USER + 7, 0, 0))); - if (browser != NULL) { - ExplorerWrapper wrapper(shellTab, browser.get()); - HWND lv = FindWindowEx(hWnd, NULL, L"SysListView32", NULL); - HWND dui = FindWindowEx(hWnd, NULL, L"DirectUIHWND", NULL); - if (lv) { - if (WindhawkUtils::SetWindowSubclassFromAnyThread(lv, SysListViewSubclass, 0)) { - Wh_Log(L"SysListView32 Subclassed %p", lv); - wrapper.hListView = lv; - } - } else if (dui) { - if (WindhawkUtils::SetWindowSubclassFromAnyThread(hWnd, DUISubclass, 0)) { - Wh_Log(L"DirectUIHWND Subclassed %p", hWnd); - wrapper.hListView = hWnd; - } + HWND lv = FindWindowEx(hWnd, NULL, L"SysListView32", NULL); + HWND dui = FindWindowEx(hWnd, NULL, L"DirectUIHWND", NULL); + if (lv) { + if (WindhawkUtils::SetWindowSubclassFromAnyThread(lv, SysListViewSubclass, 0)) { + Wh_Log(L"SysListView32 Subclassed %p", lv); + std::lock_guard slk(g_subclassMutex); + g_subclassed.push_back({ lv, true }); + } + } else if (dui) { + if (WindhawkUtils::SetWindowSubclassFromAnyThread(hWnd, DUISubclass, 0)) { + Wh_Log(L"DirectUIHWND Subclassed %p", hWnd); + std::lock_guard slk(g_subclassMutex); + g_subclassed.push_back({ hWnd, false }); } - if (wrapper.hListView) { - std::lock_guard lk(g_wrappersMutex); - g_Wrappers->push_back(wrapper); - } else Wh_Log(L"Failed to setup wrapper for %p", shellTab); - return FALSE; } + return FALSE; } } return TRUE; @@ -1592,7 +1575,6 @@ BOOL Wh_ModInit() { Wh_Log(L"Click on Empty Explorer Init"); g_msgDoAction = RegisterWindowMessage(L"ClickOnEmptyExplorer_DoAction"); - g_Wrappers.emplace(); LoadSettings(); HMODULE hExplorerFrame = LoadLibraryExW(L"explorerframe.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); @@ -1632,34 +1614,25 @@ void Wh_ModUninit() { // Block subclass callbacks and hook code before cleanup InterlockedExchange(&g_initialized, 0); - CancelPendingMidClick(); - CancelPendingDblClick(); - - if (g_pendingNavHwnd && IsWindow(g_pendingNavHwnd)) { - KillTimer(g_pendingNavHwnd, 0x4D43); - } g_pendingNavHwnd = NULL; g_pendingNavBrowser = nullptr; g_pendingNavPath[0] = L'\0'; - // Collect subclassed HWNDs under lock, remove subclasses outside the lock. - // Use g_subclassed (populated at subclass time and cleaned by WM_NCDESTROY) - // instead of iterating g_Wrappers (which may not have entries for - // post-init windows — see issue 1 in review). + // Collect subclassed HWNDs under lock, kill timers + remove subclasses + // outside the lock. Timers use nullptr callback now (WM_TIMER handled in + // subclass proc), so KillTimer here is clean — no mod-image callback fire. std::vector toRemove; { std::lock_guard lk(g_subclassMutex); std::swap(toRemove, g_subclassed); } for (auto& e : toRemove) { - if (e.hWnd && IsWindow(e.hWnd)) + if (e.hWnd && IsWindow(e.hWnd)) { + KillTimer(e.hWnd, 0x4D43); + KillTimer(e.hWnd, 0x4D44); + KillTimer(e.hWnd, 0x4D45); WindhawkUtils::RemoveWindowSubclassFromAnyThread( e.hWnd, e.isListView ? SysListViewSubclass : DUISubclass); - } - - // Release wrapper COM references - { - std::lock_guard lk(g_wrappersMutex); - g_Wrappers.reset(); + } } } From 33195c7a40eccff6974405952c4f3d3e620520e4 Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 13:20:27 +0800 Subject: [PATCH 07/12] =?UTF-8?q?Fix=20CWM=5FGETISHELLBROWSER=20undeclared?= =?UTF-8?q?=20=E2=80=94=20use=20WM=5FUSER+7=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mods/click-on-empty-explorer.wh.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index 5b1039dd01..6c1f714669 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -1099,10 +1099,10 @@ static bool FindShellTabAndDoAction(HWND hWnd, PCWSTR action) { if (wcscmp(className, L"ShellTabWindowClass") == 0) { HWND shellTab = parent; // Query the browser on-demand instead of caching in g_Wrappers - // (CWM_GETISHELLBROWSER returns a borrowed pointer, so copy_from to AddRef) + // (WM_USER+7 returns a borrowed pointer, so copy_from to AddRef) winrt::com_ptr browser; browser.copy_from(reinterpret_cast( - (void*)SendMessage(shellTab, CWM_GETISHELLBROWSER, 0, 0))); + (void*)SendMessage(shellTab, WM_USER + 7, 0, 0))); if (browser) { ExplorerWrapper tmp(shellTab, browser.get(), hWnd); tmp.DoAction(action); From 4783e84f046f0b0c8cdd6751d9d1099a4e8131ec Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 13:39:40 +0800 Subject: [PATCH 08/12] v2.7.0: WM_TIMER passthrough, modifier release, multi-tab hook --- mods/click-on-empty-explorer.wh.cpp | 38 +++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index 6c1f714669..6068908a95 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -445,6 +445,17 @@ static bool TryCustomHotkey(PCWSTR action, const std::wstring& combo) { // ---- Helper: Send key combination ---- static void SendKeyCombo(WORD vk1, WORD vk2, WORD vk3 = 0) { + // Release any physically-held modifiers before injecting, then restore after. + // Prevents Shift+Click→NewTab from injecting Ctrl+Shift+T (= reopen tab, not new tab). + std::vector heldMods; + for (WORD vk : {VK_CONTROL, VK_MENU, VK_SHIFT, VK_LWIN}) { + if (GetKeyState(vk) & 0x8000) heldMods.push_back(vk); + } + for (WORD vk : heldMods) { + INPUT up = {INPUT_KEYBOARD, {.ki = {.wVk = vk, .dwFlags = KEYEVENTF_KEYUP}}}; + SendInput(1, &up, sizeof(INPUT)); + } + INPUT inputs[6] = {}; int count = 0; auto Press = [&](WORD vk) { @@ -465,6 +476,11 @@ static void SendKeyCombo(WORD vk1, WORD vk2, WORD vk3 = 0) { if (vk3) Release(vk3); Release(vk1); SendInput(count, inputs, sizeof(INPUT)); // single atomic call + + for (WORD vk : heldMods) { + INPUT down = {INPUT_KEYBOARD, {.ki = {.wVk = vk}}}; + SendInput(1, &down, sizeof(INPUT)); + } } // ---- Custom hotkey parsing ---- @@ -1144,11 +1160,11 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM // Uses nullptr callback (TIMERPROC lives in mod image, unsafe across unload). if (uMsg == WM_TIMER) { switch (wParam) { - case 0x4D43: NavigateNewTabProc(hWnd, uMsg, wParam, dwRefData); break; - case 0x4D44: MidClickTimerProc(hWnd, uMsg, wParam, dwRefData); break; - case 0x4D45: DblClickTimerProc(hWnd, uMsg, wParam, dwRefData); break; + case 0x4D43: NavigateNewTabProc(hWnd, uMsg, wParam, dwRefData); return 0; + case 0x4D44: MidClickTimerProc(hWnd, uMsg, wParam, dwRefData); return 0; + case 0x4D45: DblClickTimerProc(hWnd, uMsg, wParam, dwRefData); return 0; } - return 0; + return DefSubclassProc(hWnd, uMsg, wParam, lParam); // let Explorer handle its own timers } // Fast path: skip settings copy for messages we don't handle @@ -1302,11 +1318,11 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, // WM_TIMER: handles deferred middle-click, double-click, and duplicate-tab timers. if (uMsg == WM_TIMER) { switch (wParam) { - case 0x4D43: NavigateNewTabProc(hWnd, uMsg, wParam, dwRefData); break; - case 0x4D44: MidClickTimerProc(hWnd, uMsg, wParam, dwRefData); break; - case 0x4D45: DblClickTimerProc(hWnd, uMsg, wParam, dwRefData); break; + case 0x4D43: NavigateNewTabProc(hWnd, uMsg, wParam, dwRefData); return 0; + case 0x4D44: MidClickTimerProc(hWnd, uMsg, wParam, dwRefData); return 0; + case 0x4D45: DblClickTimerProc(hWnd, uMsg, wParam, dwRefData); return 0; } - return 0; + return DefSubclassProc(hWnd, uMsg, wParam, lParam); // let Explorer handle its own timers } if (uMsg != WM_PARENTNOTIFY) @@ -1561,9 +1577,11 @@ BOOL CALLBACK InitEnumWindowsProc(HWND hWnd, LPARAM lParam) { wchar_t className[256]; GetClassName(hWnd, className, 256); if (wcscmp(className, L"CabinetWClass") == 0) { - HWND shellTab = FindWindowEx(hWnd, NULL, L"ShellTabWindowClass", NULL); - if (shellTab != NULL) + for (HWND shellTab = FindWindowEx(hWnd, NULL, L"ShellTabWindowClass", NULL); + shellTab; + shellTab = FindWindowEx(hWnd, shellTab, L"ShellTabWindowClass", NULL)) { EnumChildWindows(shellTab, InitEnumChildWindowsProc, (LPARAM)shellTab); + } } } return TRUE; From e74a5874f511e13ea7af193d42a373ea369a712f Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 14:17:52 +0800 Subject: [PATCH 09/12] v2.7.0: Triple-click fix, side-VK mod release, virtual-folder CtxMenu, drop brand actions --- mods/click-on-empty-explorer.wh.cpp | 187 +++++++++++----------------- 1 file changed, 76 insertions(+), 111 deletions(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index 6068908a95..128c917c30 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -65,10 +65,7 @@ no file or folder is located) and performs the action you've configured. - **Custom Hotkey** — Send a custom key combination, configured per trigger (see below) - **Go to Desktop** — Navigate to the Desktop - **Go to Home** — Navigate to Quick Access / Home -- **Open in VS Code** — Invoke the "Open in VS Code" entry from the folder's right-click background context menu (no hard-coded path; works if the verb is registered) -- **Open in Terminal** — Invoke the "Open in Terminal" / Windows Terminal entry from the context menu -- **Open in Cursor** — Invoke the "Open in Cursor" entry from the context menu -- **Open Context Menu Item** — Invoke any right-click background context menu entry by matching its text/verb (configured via "Context Menu Match" setting). Lets you open the folder in Git Bash, PowerShell 7, any editor, etc. +- **Open Context Menu Item** — Invoke any right-click background context menu entry by matching its text/verb (configured via "Context Menu Match" setting). Use this for VS Code (`Code`), Terminal (`Terminal`), Cursor (`Cursor`), Git Bash (`gitbash`), or any program that registered a context menu entry. - **None** — Do nothing ## Custom Hotkey @@ -149,9 +146,6 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home - - openInVSCode: Open in VS Code - - openInTerminal: Open in Terminal - - openInCursor: Open in Cursor - openWithContextMenu: Open Context Menu Item - none: None - doubleClickCustomHotkey: "" @@ -174,9 +168,6 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home - - openInVSCode: Open in VS Code - - openInTerminal: Open in Terminal - - openInCursor: Open in Cursor - openWithContextMenu: Open Context Menu Item - none: None - tripleClickCustomHotkey: "" @@ -199,9 +190,6 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home - - openInVSCode: Open in VS Code - - openInTerminal: Open in Terminal - - openInCursor: Open in Cursor - openWithContextMenu: Open Context Menu Item - none: None - middleClickCustomHotkey: "" @@ -224,9 +212,6 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home - - openInVSCode: Open in VS Code - - openInTerminal: Open in Terminal - - openInCursor: Open in Cursor - openWithContextMenu: Open Context Menu Item - none: None - doubleMiddleClickCustomHotkey: "" @@ -249,9 +234,6 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home - - openInVSCode: Open in VS Code - - openInTerminal: Open in Terminal - - openInCursor: Open in Cursor - openWithContextMenu: Open Context Menu Item - none: None - ctrlClickCustomHotkey: "" @@ -274,9 +256,6 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home - - openInVSCode: Open in VS Code - - openInTerminal: Open in Terminal - - openInCursor: Open in Cursor - openWithContextMenu: Open Context Menu Item - none: None - altClickCustomHotkey: "" @@ -299,9 +278,6 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home - - openInVSCode: Open in VS Code - - openInTerminal: Open in Terminal - - openInCursor: Open in Cursor - openWithContextMenu: Open Context Menu Item - none: None - shiftClickCustomHotkey: "" @@ -445,42 +421,27 @@ static bool TryCustomHotkey(PCWSTR action, const std::wstring& combo) { // ---- Helper: Send key combination ---- static void SendKeyCombo(WORD vk1, WORD vk2, WORD vk3 = 0) { - // Release any physically-held modifiers before injecting, then restore after. - // Prevents Shift+Click→NewTab from injecting Ctrl+Shift+T (= reopen tab, not new tab). - std::vector heldMods; - for (WORD vk : {VK_CONTROL, VK_MENU, VK_SHIFT, VK_LWIN}) { - if (GetKeyState(vk) & 0x8000) heldMods.push_back(vk); - } - for (WORD vk : heldMods) { - INPUT up = {INPUT_KEYBOARD, {.ki = {.wVk = vk, .dwFlags = KEYEVENTF_KEYUP}}}; - SendInput(1, &up, sizeof(INPUT)); - } - - INPUT inputs[6] = {}; - int count = 0; - auto Press = [&](WORD vk) { - inputs[count].type = INPUT_KEYBOARD; - inputs[count].ki.wVk = vk; - count++; - }; - auto Release = [&](WORD vk) { - inputs[count].type = INPUT_KEYBOARD; - inputs[count].ki.wVk = vk; - inputs[count].ki.dwFlags = KEYEVENTF_KEYUP; - count++; + // Release held modifiers before injecting, restore after — all in one + // atomic SendInput batch so nothing can interleave. Side-specific VKs + // handle Left/Right variants (VK_CONTROL only reports combined state). + static constexpr WORD kSideMods[] = {VK_LCONTROL, VK_RCONTROL, VK_LMENU, + VK_RMENU, VK_LSHIFT, VK_RSHIFT}; + std::vector in; + auto Key = [&](WORD vk, DWORD flags) { + in.push_back(INPUT{INPUT_KEYBOARD, {.ki = {.wVk = vk, .dwFlags = flags}}}); }; - Press(vk1); - Press(vk2); - if (vk3) Press(vk3); - Release(vk2); - if (vk3) Release(vk3); - Release(vk1); - SendInput(count, inputs, sizeof(INPUT)); // single atomic call - for (WORD vk : heldMods) { - INPUT down = {INPUT_KEYBOARD, {.ki = {.wVk = vk}}}; - SendInput(1, &down, sizeof(INPUT)); - } + std::vector held; + for (WORD vk : kSideMods) + if (GetKeyState(vk) & 0x8000) held.push_back(vk); + + for (WORD vk : held) Key(vk, KEYEVENTF_KEYUP); + Key(vk1, 0); Key(vk2, 0); if (vk3) Key(vk3, 0); + if (vk3) Key(vk3, KEYEVENTF_KEYUP); + Key(vk2, KEYEVENTF_KEYUP); Key(vk1, KEYEVENTF_KEYUP); + for (WORD vk : held) Key(vk, 0); + + SendInput((UINT)in.size(), in.data(), sizeof(INPUT)); } // ---- Custom hotkey parsing ---- @@ -711,6 +672,40 @@ static void DumpContextMenuRecursive(HMENU hMenu, IContextMenu* pcm, IContextMen } } +// Get the background context menu directly from the shell view. +// Works for virtual folders (This PC, Libraries, Recycle Bin, search results) +// where SHGetPathFromIDListW returns FALSE. +static bool InvokeFolderContextMenuFromBrowser(IShellBrowser* browser, HWND hwnd, PCWSTR matchText) { + if (!browser || !matchText || !matchText[0]) return false; + + IShellView* psv = nullptr; + if (FAILED(browser->QueryActiveShellView(&psv)) || !psv) return false; + + IContextMenu* pcm = nullptr; + HRESULT hr = psv->GetItemObject(SVGIO_BACKGROUND, IID_IContextMenu, (void**)&pcm); + psv->Release(); + if (FAILED(hr) || !pcm) return false; + + IContextMenu2* pcm2 = nullptr; + pcm->QueryInterface(IID_IContextMenu2, (void**)&pcm2); + + bool found = false; + HMENU hMenu = CreatePopupMenu(); + if (hMenu && SUCCEEDED(pcm->QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_NORMAL))) { + found = EnumContextMenuMatch(hMenu, pcm, pcm2, hwnd, matchText, 1); + if (!found) { + Wh_Log(L"No match for '%s' — dumping all context menu items:", matchText); + DumpContextMenuRecursive(hMenu, pcm, pcm2, 1, 0); + } + } + if (hMenu) DestroyMenu(hMenu); + if (pcm2) pcm2->Release(); + pcm->Release(); + return found; +} + +// Legacy path-based fallback — only used by brand-specific actions (OpenInVSCode etc.) +// which are kept for backward compatibility. The browser-based version above is preferred. static bool InvokeFolderContextMenuVerb(PCWSTR folderPath, HWND hwnd, PCWSTR matchText) { if (!folderPath || !folderPath[0] || !matchText || !matchText[0]) return false; @@ -986,35 +981,9 @@ class ExplorerWrapper { } } - // ---- External program launchers (via the folder background context menu) ---- - // These borrow the right-click-on-empty-space menu, so they adapt to whatever - // program registered the corresponding verb (path-independent). - - void OpenInVSCode() { - wchar_t path[MAX_PATH] = {}; - if (!GetCurrentFolderPath(path, MAX_PATH)) return; - // "Code" matches verb "VSCode" and display "Open with Code" / "通过 Code 打开" - if (!InvokeFolderContextMenuVerb(path, hShellTab, L"Code")) - Wh_Log(L"OpenInVSCode: no matching context menu entry found"); - } - - void OpenInTerminal() { - wchar_t path[MAX_PATH] = {}; - if (!GetCurrentFolderPath(path, MAX_PATH)) return; - if (!InvokeFolderContextMenuVerb(path, hShellTab, L"Terminal")) - Wh_Log(L"OpenInTerminal: no matching context menu entry found"); - } - - void OpenInCursor() { - wchar_t path[MAX_PATH] = {}; - if (!GetCurrentFolderPath(path, MAX_PATH)) return; - if (!InvokeFolderContextMenuVerb(path, hShellTab, L"Cursor")) - Wh_Log(L"OpenInCursor: no matching context menu entry found"); - } + // ---- Context-menu action (via browser — works for virtual folders too) ---- void OpenWithContextMenu() { - // Thread-safe read: copy the match string under the settings lock so - // a concurrent Wh_ModSettingsChanged() cannot free it mid-use (UAF). std::wstring match; { std::lock_guard lock(g_settingsMutex); @@ -1025,9 +994,8 @@ class ExplorerWrapper { Wh_Log(L"OpenWithContextMenu: no match text configured (Context Menu Match setting)"); return; } - wchar_t path[MAX_PATH] = {}; - if (!GetCurrentFolderPath(path, MAX_PATH)) return; - if (!InvokeFolderContextMenuVerb(path, hShellTab, match.c_str())) + // Use browser directly — works for virtual folders too (This PC, Libraries, etc.) + if (!InvokeFolderContextMenuFromBrowser(hBrowser.get(), hShellTab, match.c_str())) Wh_Log(L"OpenWithContextMenu: no context menu entry matching '%s'", match.c_str()); } @@ -1048,9 +1016,6 @@ class ExplorerWrapper { else if (wcscmp(action, L"newFolder") == 0) NewFolder(); else if (wcscmp(action, L"copyPath") == 0) CopyPath(); else if (wcscmp(action, L"paste") == 0) Paste(); - else if (wcscmp(action, L"openInVSCode") == 0) OpenInVSCode(); - else if (wcscmp(action, L"openInTerminal") == 0) OpenInTerminal(); - else if (wcscmp(action, L"openInCursor") == 0) OpenInCursor(); else if (wcscmp(action, L"openWithContextMenu") == 0) OpenWithContextMenu(); // "none" or unknown — do nothing } @@ -1217,7 +1182,9 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM } } else if (uMsg == WM_LBUTTONDBLCLK) { - if (wcscmp(s.doubleClick.c_str(), L"none") == 0) + bool dblOn = (wcscmp(s.doubleClick.c_str(), L"none") != 0); + bool tripleOn = (wcscmp(s.tripleClick.c_str(), L"none") != 0); + if (!dblOn && !tripleOn) return DefSubclassProc(hWnd, uMsg, wParam, lParam); POINT mousePos; @@ -1229,13 +1196,12 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM if (ListView_SubItemHitTest(hWnd, &ht) != -1) return DefSubclassProc(hWnd, uMsg, wParam, lParam); // clicked on an item - bool tripleOn = (wcscmp(s.tripleClick.c_str(), L"none") != 0); - if (tripleOn) { - // Delay double-click to wait for possible third click that overrides it + // Delay double-click to wait for possible third click that overrides it. + // Store empty action when dbl=none — DblClickTimerProc already guards on .empty(). CancelPendingDblClick(); g_pendingDblClickHwnd = hWnd; - g_pendingDblClickAction = s.doubleClick; + g_pendingDblClickAction = dblOn ? s.doubleClick : L""; g_pendingDblClickCombo = s.doubleClickCombo; g_pendingDblClickTimerId = SetTimer(hWnd, 0x4D45, GetDoubleClickTime(), nullptr); @@ -1428,20 +1394,19 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, g_lastClick.className == L"UIItemsView")) && delta <= (DWORD)GetDoubleClickTime()) { // This is a double-click - if (dblOn) { - if (tripleOn) { - // Delay double-click to wait for possible third click - CancelPendingDblClick(); - g_pendingDblClickHwnd = hWnd; - g_pendingDblClickAction = s.doubleClick; - g_pendingDblClickCombo = s.doubleClickCombo; - g_pendingDblClickTimerId = SetTimer(hWnd, 0x4D45, - GetDoubleClickTime(), nullptr); - } else { - // Instant double-click (no triple-click configured) - if (!TryCustomHotkey(s.doubleClick.c_str(), s.doubleClickCombo)) - PostDoAction(hWnd, s.doubleClick.c_str()); - } + if (tripleOn) { + // Delay to wait for possible third click. + // Store empty action when dbl=none (DblClickTimerProc guards on .empty()). + CancelPendingDblClick(); + g_pendingDblClickHwnd = hWnd; + g_pendingDblClickAction = dblOn ? s.doubleClick : L""; + g_pendingDblClickCombo = s.doubleClickCombo; + g_pendingDblClickTimerId = SetTimer(hWnd, 0x4D45, + GetDoubleClickTime(), nullptr); + } else if (dblOn) { + // Instant double-click (no triple-click configured) + if (!TryCustomHotkey(s.doubleClick.c_str(), s.doubleClickCombo)) + PostDoAction(hWnd, s.doubleClick.c_str()); } g_lastClick.time = 0; // prevent next click from being another double-click } else { From 15cee97cdb33bd00f7c413f2efa79bfff5a0bee0 Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 14:21:32 +0800 Subject: [PATCH 10/12] =?UTF-8?q?Restore=20openInVSCode=20and=20openInTerm?= =?UTF-8?q?inal=20=E2=80=94=20browser-based,=20per-trigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mods/click-on-empty-explorer.wh.cpp | 34 ++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index 128c917c30..151af04620 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -23,7 +23,7 @@ # Click on Empty Explorer Configure what happens when you double click, triple click, middle click, double middle click, -or modifier+click (Ctrl/Alt/Shift+Click) on empty space in File Explorer. Supports 14 different +or modifier+click (Ctrl/Alt/Shift+Click) on empty space in File Explorer. Supports 16 different actions. ## How it works @@ -65,7 +65,9 @@ no file or folder is located) and performs the action you've configured. - **Custom Hotkey** — Send a custom key combination, configured per trigger (see below) - **Go to Desktop** — Navigate to the Desktop - **Go to Home** — Navigate to Quick Access / Home -- **Open Context Menu Item** — Invoke any right-click background context menu entry by matching its text/verb (configured via "Context Menu Match" setting). Use this for VS Code (`Code`), Terminal (`Terminal`), Cursor (`Cursor`), Git Bash (`gitbash`), or any program that registered a context menu entry. +- **Open in VS Code** — Invoke "Open with Code" from the context menu; uses browser-based menu (works in virtual folders) +- **Open in Terminal** — Invoke "Open in Terminal" from the context menu +- **Open Context Menu Item** — Invoke any right-click background context menu entry by matching its text/verb (configured via "Context Menu Match" setting). Use this for Cursor, Git Bash, PowerShell, or any program that registered an entry. - **None** — Do nothing ## Custom Hotkey @@ -146,6 +148,8 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal - openWithContextMenu: Open Context Menu Item - none: None - doubleClickCustomHotkey: "" @@ -168,6 +172,8 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal - openWithContextMenu: Open Context Menu Item - none: None - tripleClickCustomHotkey: "" @@ -190,6 +196,8 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal - openWithContextMenu: Open Context Menu Item - none: None - middleClickCustomHotkey: "" @@ -212,6 +220,8 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal - openWithContextMenu: Open Context Menu Item - none: None - doubleMiddleClickCustomHotkey: "" @@ -234,6 +244,8 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal - openWithContextMenu: Open Context Menu Item - none: None - ctrlClickCustomHotkey: "" @@ -256,6 +268,8 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal - openWithContextMenu: Open Context Menu Item - none: None - altClickCustomHotkey: "" @@ -278,6 +292,8 @@ require Windows 11 for tabbed Explorer support. - customHotkey: Custom Hotkey - goToDesktop: Go to Desktop - goToHome: Go to Home + - openInVSCode: Open in VS Code + - openInTerminal: Open in Terminal - openWithContextMenu: Open Context Menu Item - none: None - shiftClickCustomHotkey: "" @@ -981,7 +997,17 @@ class ExplorerWrapper { } } - // ---- Context-menu action (via browser — works for virtual folders too) ---- + // ---- External program launchers (via browser — works for virtual folders too) ---- + + void OpenInVSCode() { + if (!InvokeFolderContextMenuFromBrowser(hBrowser.get(), hShellTab, L"Code")) + Wh_Log(L"OpenInVSCode: no matching context menu entry found"); + } + + void OpenInTerminal() { + if (!InvokeFolderContextMenuFromBrowser(hBrowser.get(), hShellTab, L"Terminal")) + Wh_Log(L"OpenInTerminal: no matching context menu entry found"); + } void OpenWithContextMenu() { std::wstring match; @@ -1016,6 +1042,8 @@ class ExplorerWrapper { else if (wcscmp(action, L"newFolder") == 0) NewFolder(); else if (wcscmp(action, L"copyPath") == 0) CopyPath(); else if (wcscmp(action, L"paste") == 0) Paste(); + else if (wcscmp(action, L"openInVSCode") == 0) OpenInVSCode(); + else if (wcscmp(action, L"openInTerminal") == 0) OpenInTerminal(); else if (wcscmp(action, L"openWithContextMenu") == 0) OpenWithContextMenu(); // "none" or unknown — do nothing } From 76006f2f0a323de9847dd70a0b44789a0daf9742 Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 14:31:50 +0800 Subject: [PATCH 11/12] =?UTF-8?q?Per-trigger=20contextMenuMatch=20?= =?UTF-8?q?=E2=80=94=20each=20trigger=20has=20its=20own=20match=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mods/click-on-empty-explorer.wh.cpp | 75 +++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index 151af04620..36890337f0 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -155,6 +155,9 @@ require Windows 11 for tabbed Explorer support. - doubleClickCustomHotkey: "" $name: Double Click Custom Hotkey $description: "Format: modifier keys + main key. Modifiers: Ctrl, Shift, Alt, Win (can combine multiple, e.g. Ctrl+Shift+N, Win+Shift+S). Main key: letter, F1-F24, Tab, Enter, Escape, arrows, Backspace, Delete, Home, End, PageUp, PageDown, Insert" +- doubleClickContextMenuMatch: "" + $name: Double Click Context Menu Match + $description: "When Double Click Action is 'Open Context Menu Item', use this match text instead of the global 'Context Menu Match'. Leave empty to use the global setting." - tripleClickAction: none $name: Triple Click Action $description: What to do when triple left clicking empty space. When enabled, double-click is delayed ~500ms; if a third click arrives, only the triple-click action fires (double-click is cancelled). @@ -179,6 +182,9 @@ require Windows 11 for tabbed Explorer support. - tripleClickCustomHotkey: "" $name: Triple Click Custom Hotkey $description: "Same format as Double Click Custom Hotkey. Supports multiple modifiers. Ex: Ctrl+W, Win+D, Ctrl+Shift+Esc" +- tripleClickContextMenuMatch: "" + $name: Triple Click Context Menu Match + $description: "Match text override for 'Open Context Menu Item' on this trigger. Leave empty to use global setting." - middleClickAction: none $name: Middle Click Action $description: What to do when single middle clicking empty space. If only single click is set, fires instantly. If both single and double are set, single is delayed ~500ms to detect double clicks. @@ -203,6 +209,9 @@ require Windows 11 for tabbed Explorer support. - middleClickCustomHotkey: "" $name: Middle Click Custom Hotkey $description: "Same format as Double Click Custom Hotkey. Supports multiple modifiers. Ex: Ctrl+V, Ctrl+T, Win+D, Ctrl+Shift+Esc" +- middleClickContextMenuMatch: "" + $name: Middle Click Context Menu Match + $description: "Match text override for 'Open Context Menu Item' on this trigger. Leave empty to use global setting." - doubleMiddleClickAction: none $name: Double Middle Click Action $description: What to do when double middle clicking empty space. Two middle clicks within ~500ms count as a double click. If only double is set, single middle clicks are ignored. @@ -227,6 +236,9 @@ require Windows 11 for tabbed Explorer support. - doubleMiddleClickCustomHotkey: "" $name: Double Middle Click Custom Hotkey $description: "Same format as Double Click Custom Hotkey. Supports multiple modifiers. Ex: Ctrl+W, Alt+Tab, Win+E, Alt+Shift+F4" +- doubleMiddleClickContextMenuMatch: "" + $name: Double Middle Click Context Menu Match + $description: "Match text override for 'Open Context Menu Item' on this trigger. Leave empty to use global setting." - ctrlClickAction: none $name: Ctrl+Click Action $description: What to do when Ctrl+left clicking empty space. Hold Ctrl and single-click on empty area. @@ -251,6 +263,9 @@ require Windows 11 for tabbed Explorer support. - ctrlClickCustomHotkey: "" $name: Ctrl+Click Custom Hotkey $description: "Same format as Double Click Custom Hotkey. Ex: Ctrl+N, Ctrl+Shift+E" +- ctrlClickContextMenuMatch: "" + $name: Ctrl+Click Context Menu Match + $description: "Match text override for 'Open Context Menu Item' on this trigger. Leave empty to use global setting." - altClickAction: none $name: Alt+Click Action $description: What to do when Alt+left clicking empty space. Hold Alt and single-click on empty area. @@ -275,6 +290,9 @@ require Windows 11 for tabbed Explorer support. - altClickCustomHotkey: "" $name: Alt+Click Custom Hotkey $description: "Same format as Double Click Custom Hotkey. Ex: Alt+F4, Alt+Tab" +- altClickContextMenuMatch: "" + $name: Alt+Click Context Menu Match + $description: "Match text override for 'Open Context Menu Item' on this trigger. Leave empty to use global setting." - shiftClickAction: none $name: Shift+Click Action $description: What to do when Shift+left clicking empty space. Hold Shift and single-click on empty area. @@ -299,6 +317,9 @@ require Windows 11 for tabbed Explorer support. - shiftClickCustomHotkey: "" $name: Shift+Click Custom Hotkey $description: "Same format as Double Click Custom Hotkey. Ex: Shift+F10, Ctrl+Shift+N" +- shiftClickContextMenuMatch: "" + $name: Shift+Click Context Menu Match + $description: "Match text override for 'Open Context Menu Item' on this trigger. Leave empty to use global setting." - contextMenuMatch: "" $name: Context Menu Match $description: "Used by the 'Open Context Menu Item' action. Text to match (case-insensitive substring) against the folder background right-click menu entries' display text or verb. Ex: VS Code, Terminal, Git Bash, PowerShell, Cursor. Any program that registered an 'Open in ...' entry works regardless of install path." @@ -366,6 +387,13 @@ static StringSetting g_doubleMiddleClickCustomCombo; static StringSetting g_ctrlClickCustomCombo; static StringSetting g_altClickCustomCombo; static StringSetting g_shiftClickCustomCombo; +static StringSetting g_doubleClickCtxMatch; +static StringSetting g_tripleClickCtxMatch; +static StringSetting g_middleClickCtxMatch; +static StringSetting g_doubleMiddleClickCtxMatch; +static StringSetting g_ctrlClickCtxMatch; +static StringSetting g_altClickCtxMatch; +static StringSetting g_shiftClickCtxMatch; static StringSetting g_contextMenuMatch; static void LoadSettings() { @@ -384,6 +412,13 @@ static void LoadSettings() { g_ctrlClickCustomCombo.Load(L"ctrlClickCustomHotkey"); g_altClickCustomCombo.Load(L"altClickCustomHotkey"); g_shiftClickCustomCombo.Load(L"shiftClickCustomHotkey"); + g_doubleClickCtxMatch.Load(L"doubleClickContextMenuMatch"); + g_tripleClickCtxMatch.Load(L"tripleClickContextMenuMatch"); + g_middleClickCtxMatch.Load(L"middleClickContextMenuMatch"); + g_doubleMiddleClickCtxMatch.Load(L"doubleMiddleClickContextMenuMatch"); + g_ctrlClickCtxMatch.Load(L"ctrlClickContextMenuMatch"); + g_altClickCtxMatch.Load(L"altClickContextMenuMatch"); + g_shiftClickCtxMatch.Load(L"shiftClickContextMenuMatch"); g_contextMenuMatch.Load(L"contextMenuMatch"); } @@ -427,6 +462,14 @@ static SettingsSnapshot CopySettings() { static void SendParsedHotkey(const std::wstring& combo); +// Helper: set per-trigger contextMenuMatch before dispatching "openWithContextMenu". +// Falls back to global setting if per-trigger is empty. +static void SetCtxMenuMatch(PCWSTR action, StringSetting& perTriggerMatch) { + if (action && wcscmp(action, L"openWithContextMenu") == 0) { + g_pendingCtxMenuMatch = perTriggerMatch.Get() ? perTriggerMatch.Get() : L""; + } +} + // Helper: execute custom hotkey if the selected action is "customHotkey" static bool TryCustomHotkey(PCWSTR action, const std::wstring& combo) { if (wcscmp(action, L"customHotkey") != 0) return false; @@ -795,6 +838,10 @@ static thread_local UINT_PTR g_pendingDblClickTimerId = 0; static thread_local std::wstring g_pendingDblClickAction; static thread_local std::wstring g_pendingDblClickCombo; +// Bridge: per-trigger contextMenuMatch set by subclass proc before PostDoAction, +// consumed by OpenWithContextMenu. Cleared after use. +static thread_local std::wstring g_pendingCtxMenuMatch; + // Private message: dequeues action dispatch from mouse handlers to avoid // blocking on COM activation inside WM_LBUTTONDOWN/WM_MBUTTONDOWN. // wParam = (WPARAM)strdup(actionString), lParam = (LPARAM)hWnd @@ -1010,8 +1057,11 @@ class ExplorerWrapper { } void OpenWithContextMenu() { - std::wstring match; - { + // Prefer per-trigger match (set by subclass proc before dispatching), + // fall back to global Context Menu Match setting. + std::wstring match = std::move(g_pendingCtxMenuMatch); + g_pendingCtxMenuMatch.clear(); + if (match.empty()) { std::lock_guard lock(g_settingsMutex); PCWSTR s = g_contextMenuMatch.Get(); if (s) match = s; @@ -1189,6 +1239,8 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM if (tripleOn && g_pendingDblClickHwnd == hWnd && g_pendingDblClickTimerId != 0) { CancelPendingDblClick(); if (!TryCustomHotkey(s.tripleClick.c_str(), s.tripleClickCombo)) + SetCtxMenuMatch(s.tripleClick.c_str(), g_tripleClickCtxMatch); + SetCtxMenuMatch(s.tripleClick.c_str(), g_tripleClickCtxMatch); PostDoAction(hWnd, s.tripleClick.c_str()); return DefSubclassProc(hWnd, uMsg, wParam, lParam); } @@ -1200,12 +1252,15 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM if (ctrlOn && ctrlDown) { if (!TryCustomHotkey(s.ctrlClick.c_str(), s.ctrlClickCombo)) + SetCtxMenuMatch(s.ctrlClick.c_str(), g_ctrlClickCtxMatch); PostDoAction(hWnd, s.ctrlClick.c_str()); } else if (altOn && altDown) { if (!TryCustomHotkey(s.altClick.c_str(), s.altClickCombo)) + SetCtxMenuMatch(s.altClick.c_str(), g_altClickCtxMatch); PostDoAction(hWnd, s.altClick.c_str()); } else if (shiftOn && shiftDown) { if (!TryCustomHotkey(s.shiftClick.c_str(), s.shiftClickCombo)) + SetCtxMenuMatch(s.shiftClick.c_str(), g_shiftClickCtxMatch); PostDoAction(hWnd, s.shiftClick.c_str()); } @@ -1236,6 +1291,7 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM } else { // Instant double-click (no triple-click configured) if (!TryCustomHotkey(s.doubleClick.c_str(), s.doubleClickCombo)) + SetCtxMenuMatch(s.doubleClick.c_str(), g_doubleClickCtxMatch); PostDoAction(hWnd, s.doubleClick.c_str()); } @@ -1256,6 +1312,7 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM if (singleOn && !doubleOn) { if (!TryCustomHotkey(s.middleClick.c_str(), s.middleClickCombo)) + SetCtxMenuMatch(s.middleClick.c_str(), g_middleClickCtxMatch); PostDoAction(hWnd, s.middleClick.c_str()); return DefSubclassProc(hWnd, uMsg, wParam, lParam); } @@ -1265,6 +1322,7 @@ LRESULT CALLBACK SysListViewSubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM if (isDouble) { CancelPendingMidClick(); if (!TryCustomHotkey(s.doubleMiddleClick.c_str(), s.doubleMiddleClickCombo)) + SetCtxMenuMatch(s.doubleMiddleClick.c_str(), g_doubleMiddleClickCtxMatch); PostDoAction(hWnd, s.doubleMiddleClick.c_str()); } else { CancelPendingMidClick(); @@ -1350,6 +1408,7 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, if (singleOn && !doubleOn) { if (!TryCustomHotkey(s.middleClick.c_str(), s.middleClickCombo)) + SetCtxMenuMatch(s.middleClick.c_str(), g_middleClickCtxMatch); PostDoAction(hWnd, s.middleClick.c_str()); return DefSubclassProc(hWnd, uMsg, wParam, lParam); } @@ -1357,9 +1416,11 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, bool isDouble = (g_midClickTimerId != 0 && g_midClickPendingHwnd == hWnd); if (isDouble) { - CancelPendingMidClick(); - if (!TryCustomHotkey(s.doubleMiddleClick.c_str(), s.doubleMiddleClickCombo)) + CancelPendingMidClick(); + if (!TryCustomHotkey(s.doubleMiddleClick.c_str(), s.doubleMiddleClickCombo)) { + SetCtxMenuMatch(s.doubleMiddleClick.c_str(), g_doubleMiddleClickCtxMatch); PostDoAction(hWnd, s.doubleMiddleClick.c_str()); + } } else { CancelPendingMidClick(); g_midClickPendingHwnd = hWnd; @@ -1404,6 +1465,8 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, if (tripleOn && g_pendingDblClickHwnd == hWnd && g_pendingDblClickTimerId != 0) { CancelPendingDblClick(); if (!TryCustomHotkey(s.tripleClick.c_str(), s.tripleClickCombo)) + SetCtxMenuMatch(s.tripleClick.c_str(), g_tripleClickCtxMatch); + SetCtxMenuMatch(s.tripleClick.c_str(), g_tripleClickCtxMatch); PostDoAction(hWnd, s.tripleClick.c_str()); return DefSubclassProc(hWnd, uMsg, wParam, lParam); } @@ -1434,7 +1497,8 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, } else if (dblOn) { // Instant double-click (no triple-click configured) if (!TryCustomHotkey(s.doubleClick.c_str(), s.doubleClickCombo)) - PostDoAction(hWnd, s.doubleClick.c_str()); + SetCtxMenuMatch(s.doubleClick.c_str(), g_doubleClickCtxMatch); + PostDoAction(hWnd, s.doubleClick.c_str()); } g_lastClick.time = 0; // prevent next click from being another double-click } else { @@ -1451,6 +1515,7 @@ LRESULT CALLBACK DUISubclass(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, PostDoAction(hWnd, s.altClick.c_str()); } else if (shiftOn && shiftDown) { if (!TryCustomHotkey(s.shiftClick.c_str(), s.shiftClickCombo)) + SetCtxMenuMatch(s.shiftClick.c_str(), g_shiftClickCtxMatch); PostDoAction(hWnd, s.shiftClick.c_str()); } From 31e461108787633fcf0df4bbbae1e7f177245930 Mon Sep 17 00:00:00 2001 From: LiHua81 Date: Fri, 31 Jul 2026 15:42:27 +0800 Subject: [PATCH 12/12] Fix extern/static conflict for g_pendingCtxMenuMatch --- mods/click-on-empty-explorer.wh.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mods/click-on-empty-explorer.wh.cpp b/mods/click-on-empty-explorer.wh.cpp index 36890337f0..4be0bb201e 100644 --- a/mods/click-on-empty-explorer.wh.cpp +++ b/mods/click-on-empty-explorer.wh.cpp @@ -462,6 +462,9 @@ static SettingsSnapshot CopySettings() { static void SendParsedHotkey(const std::wstring& combo); +// Bridge: per-trigger contextMenuMatch (defined below near other thread_locals) +extern thread_local std::wstring g_pendingCtxMenuMatch; + // Helper: set per-trigger contextMenuMatch before dispatching "openWithContextMenu". // Falls back to global setting if per-trigger is empty. static void SetCtxMenuMatch(PCWSTR action, StringSetting& perTriggerMatch) { @@ -840,7 +843,7 @@ static thread_local std::wstring g_pendingDblClickCombo; // Bridge: per-trigger contextMenuMatch set by subclass proc before PostDoAction, // consumed by OpenWithContextMenu. Cleared after use. -static thread_local std::wstring g_pendingCtxMenuMatch; +thread_local std::wstring g_pendingCtxMenuMatch; // Private message: dequeues action dispatch from mouse handlers to avoid // blocking on COM activation inside WM_LBUTTONDOWN/WM_MBUTTONDOWN.