Skip to content

Add Click on Empty Explorer mod - #4500

Merged
m417z merged 7 commits into
ramensoftware:mainfrom
LiHua81:explorer-middle-click-duplicate
Jun 27, 2026
Merged

Add Click on Empty Explorer mod#4500
m417z merged 7 commits into
ramensoftware:mainfrom
LiHua81:explorer-middle-click-duplicate

Conversation

@LiHua81

@LiHua81 LiHua81 commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

A unified mod that lets you configure what double left click, single middle click, and double middle click do when clicking on empty space in File Explorer.

Each trigger can be set to any of 11 actions: Go Up, Go Back, Go Forward, Go to Desktop, Go to Home, Refresh, New Tab, Duplicate Tab, Close Tab, New Folder, or Copy Path.

Double left click uses native Windows double-click detection (no delay). Double middle click uses a timer-based fallback (~500ms) since Windows doesn't support it natively — single middle click fires instantly when only single-click is configured, and is only delayed when both single and double middle click are enabled.

Tested on Windows 11 with both SysListView32 and DirectUIHWND views.

Test plan

  • Open File Explorer on Windows 11, navigate to any folder
  • Set double click action to "Go Up" → double click empty space → verify navigates up
  • Set middle click action to "Refresh" → middle click empty space → verify refreshes
  • Set double middle click action to "Duplicate Tab" → double middle click empty space → verify new tab opens with same folder
  • Middle click on a file/folder item → verify nothing happens (not empty space)
  • Set all three triggers to various actions → verify each triggers the correct action
  • Verify changing settings in Windhawk editor updates behavior immediately

Mod authorship

If this pull request introduces a new mod, please complete the section below.

This mod was created by:

    • The submitter, without AI assistance
    • The submitter, with AI assistance
    • Claude
    • Gemini
    • ChatGPT
    • Another AI (please specify):
    • Other (please specify):

Please select the options that best apply. Your selection does not affect the acceptance criteria, but it helps reviewers understand the context of the code and provide relevant feedback.

@m417z

m417z commented Jun 22, 2026

Copy link
Copy Markdown
Member

Thanks for the submission. Instead of having many variations based on the Explorer Double Click Up mod (another example: #4048), it'd be preferable to have a single mod that allows to customize the middle click and double click actions, similar to the way Click on empty taskbar space does it for the taskbar. Will you be willing to enhance the mod and maintain it?

@LiHua81 LiHua81 closed this Jun 24, 2026
@LiHua81 LiHua81 reopened this Jun 24, 2026
@LiHua81

LiHua81 commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the submission. Instead of having many variations based on the Explorer Double Click Up mod (another example: #4048), it'd be preferable to have a single mod that allows to customize the middle click and double click actions, similar to the way Click on empty taskbar space does it for the taskbar. Will you be willing to enhance the mod and maintain it?

Thanks for the feedback! I've replaced the single-purpose mod with a unified one — click-on-empty-explorer. It now supports three trigger types (double left click, single middle click, double middle click) and each can be set to any of 11 actions: Go Up, Go Back, Go Forward, Go to Desktop, Go to Home, Refresh, New Tab, Duplicate Tab, Close Tab, New Folder, Copy Path.

@LiHua81 LiHua81 changed the title Add explorer-middle-click-duplicate mod Add Click on Empty Explorer mo Jun 24, 2026
@m417z

m417z commented Jun 24, 2026

Copy link
Copy Markdown
Member

Submission review

Note: This review was done by Claude, and then refined manually. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


  • g_Wrappers (and the other shared globals) are mutated from multiple Explorer UI threads with no synchronization. Each Explorer window typically runs on its own thread, so FileCabinet_CreateViewWindow2Hook / CreateWindowExW_hook can push_back/iterate g_Wrappers on one thread while a subclass proc on another thread is inside FindWrapper / FindShellTabAndDoAction. A push_back that reallocates the vector while another thread holds the ExplorerWrapper* returned by FindWrapper is a use-after-free:
ExplorerWrapper* wrapper = FindWrapper(parent);   // pointer into g_Wrappers
if (wrapper) {
    wrapper->DoAction(action);                     // another thread may have reallocated by now

This is inherited from the base mod (which has the same latent race), so it's low-probability in practice, but the new FindWrapper-returns-a-pointer path makes it easier to hit. Guard all access to g_Wrappers with a std::mutex, and don't hold a raw element pointer across the lock — copy out what you need (the IShellBrowser/hShellTab) under the lock, then act on the copy. Note the lock-and-SendMessage caveat: don't call RemoveWindowSubclassFromAnyThread / DoAction (which can SendInput/BrowseObject) while holding the lock — copy under the lock, release, then call.

  • The subclass guard returns 0 instead of chaining to DefSubclassProc. CHECK_INIT_OR_RETURN_ZERO() sits at the top of both subclass procs, before the message filter, so when !g_initialized every message returns 0 without calling DefSubclassProc. This only matters in the brief teardown window (you set g_initialized = 0 first thing in Wh_ModUninit, then remove the subclasses), but during that window messages are swallowed. Make the macro fall through to the default proc instead, e.g. if (!g_initialized) return DefSubclassProc(hWnd, uMsg, wParam, lParam);.

  • Settings strings are freed and reassigned while subclass procs read them. LoadSettings() (on the settings-change thread) calls Wh_FreeStringSetting(g_middleClickAction) etc. while a subclass proc on a window thread may be doing wcscmp(g_middleClickAction, ...) — a use-after-free on settings change. Since it can only happen on a settings change it's a lower priority, but WindhawkUtils::StringSetting (RAII) plus reading into a local at the top of each proc, or swapping under the same mutex as above, would close it. StringSetting would also replace the manual s_firstCall / Wh_FreeStringSetting bookkeeping in LoadSettings.

  • Global COM objects with non-trivial destructors run at process shutdown. g_pUIAutomation, g_pendingNavBrowser, the IShellBrowser pointers inside g_Wrappers, and g_comInitializer all Release()/CoUninitialize() from their destructors during DLL_PROCESS_DETACH (Explorer restart/sign-out), when Wh_ModUninit does not run. This mirrors the accepted base mod and is generally tolerated, just something to be aware of.

Optional improvements

Minor polish — none of this affects users in normal operation, so it's your call.

  • CopyPath leaks hMem if SetClipboardData fails. Once OpenClipboard succeeds you unconditionally CloseClipboard, but if SetClipboardData returns NULL the global isn't freed and ownership wasn't transferred. Free it on that path.

  • Trim unused deps. -lshlwapi is linked but no shlwapi function is used (SHGetPathFromIDListW / SHParseDisplayName / SHGetFolderLocation are shell32). #include <UIAnimation.h> and #include <shlwapi.h> also appear unused. CI doesn't catch unused deps, so worth a pass.

  • SHGetFolderLocation is deprecated in favor of SHGetKnownFolderIDList(FOLDERID_Desktop, ...). Cosmetic.

  • clsName.GetBSTR() could be NULL. If get_CurrentClassName returns S_OK with a NULL BSTR, the subsequent wcscmp(cn, ...) dereferences NULL. A NULL check before comparing would be safe (also inherited from the base mod).

Functionality notes

Non-critical observations about the feature behavior itself.

  • Tab actions rely on synthetic input reaching the focused window. New Tab / Close Tab / New Folder / Duplicate Tab are driven by SendInput of Ctrl+T / Ctrl+W / Ctrl+Shift+N, which goes to whatever has keyboard focus. It works because you've just clicked the Explorer window, but it's inherently fragile (any focus change, or a global hotkey handler, between the click and the injection sends the keystrokes elsewhere). There's no clean alternative without deeper command hooks, so this is just an FYI.

  • DuplicateTab is timing-dependent. It fires Ctrl+T, stashes the path in global g_pendingNavPath, and sets a 500 ms timer that navigates whatever browser FileCabinet_CreateViewWindow2Hook recorded in the meantime. If the new tab takes longer than 500 ms to materialize, or another tab is created concurrently, it can navigate the wrong tab / nothing. Works in the common case; worth knowing the failure mode.

  • Middle-click detection state is global, not per-window. g_midClickPendingHwnd / g_midClickTimerId are single globals, so only one middle-click can be pending across all Explorer windows — rapidly middle-clicking in two windows can drop the first window's single-click action. Edge case.

  • Group headers count as empty space. ListView_SubItemHitTest returns -1 over a group header as well as truly empty space, so an action can fire when clicking a collapsed/expanded group header (same FIXME as the base mod's SysListView32 path).

@LiHua81 LiHua81 changed the title Add Click on Empty Explorer mo Add Click on Empty Explorer mod Jun 25, 2026
@LiHua81

LiHua81 commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

Submission review

Note: This review was done by Claude, and then refined manually. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.

  • g_Wrappers (and the other shared globals) are mutated from multiple Explorer UI threads with no synchronization. Each Explorer window typically runs on its own thread, so FileCabinet_CreateViewWindow2Hook / CreateWindowExW_hook can push_back/iterate g_Wrappers on one thread while a subclass proc on another thread is inside FindWrapper / FindShellTabAndDoAction. A push_back that reallocates the vector while another thread holds the ExplorerWrapper* returned by FindWrapper is a use-after-free:
ExplorerWrapper* wrapper = FindWrapper(parent);   // pointer into g_Wrappers
if (wrapper) {
    wrapper->DoAction(action);                     // another thread may have reallocated by now

This is inherited from the base mod (which has the same latent race), so it's low-probability in practice, but the new FindWrapper-returns-a-pointer path makes it easier to hit. Guard all access to g_Wrappers with a std::mutex, and don't hold a raw element pointer across the lock — copy out what you need (the IShellBrowser/hShellTab) under the lock, then act on the copy. Note the lock-and-SendMessage caveat: don't call RemoveWindowSubclassFromAnyThread / DoAction (which can SendInput/BrowseObject) while holding the lock — copy under the lock, release, then call.

  • The subclass guard returns 0 instead of chaining to DefSubclassProc. CHECK_INIT_OR_RETURN_ZERO() sits at the top of both subclass procs, before the message filter, so when !g_initialized every message returns 0 without calling DefSubclassProc. This only matters in the brief teardown window (you set g_initialized = 0 first thing in Wh_ModUninit, then remove the subclasses), but during that window messages are swallowed. Make the macro fall through to the default proc instead, e.g. if (!g_initialized) return DefSubclassProc(hWnd, uMsg, wParam, lParam);.
  • Settings strings are freed and reassigned while subclass procs read them. LoadSettings() (on the settings-change thread) calls Wh_FreeStringSetting(g_middleClickAction) etc. while a subclass proc on a window thread may be doing wcscmp(g_middleClickAction, ...) — a use-after-free on settings change. Since it can only happen on a settings change it's a lower priority, but WindhawkUtils::StringSetting (RAII) plus reading into a local at the top of each proc, or swapping under the same mutex as above, would close it. StringSetting would also replace the manual s_firstCall / Wh_FreeStringSetting bookkeeping in LoadSettings.
  • Global COM objects with non-trivial destructors run at process shutdown. g_pUIAutomation, g_pendingNavBrowser, the IShellBrowser pointers inside g_Wrappers, and g_comInitializer all Release()/CoUninitialize() from their destructors during DLL_PROCESS_DETACH (Explorer restart/sign-out), when Wh_ModUninit does not run. This mirrors the accepted base mod and is generally tolerated, just something to be aware of.

Optional improvements

Minor polish — none of this affects users in normal operation, so it's your call.

  • CopyPath leaks hMem if SetClipboardData fails. Once OpenClipboard succeeds you unconditionally CloseClipboard, but if SetClipboardData returns NULL the global isn't freed and ownership wasn't transferred. Free it on that path.
  • Trim unused deps. -lshlwapi is linked but no shlwapi function is used (SHGetPathFromIDListW / SHParseDisplayName / SHGetFolderLocation are shell32). #include <UIAnimation.h> and #include <shlwapi.h> also appear unused. CI doesn't catch unused deps, so worth a pass.
  • SHGetFolderLocation is deprecated in favor of SHGetKnownFolderIDList(FOLDERID_Desktop, ...). Cosmetic.
  • clsName.GetBSTR() could be NULL. If get_CurrentClassName returns S_OK with a NULL BSTR, the subsequent wcscmp(cn, ...) dereferences NULL. A NULL check before comparing would be safe (also inherited from the base mod).

Functionality notes

All issues addressed:

  1. Thread safety — Added std::mutex for all g_Wrappers access. push_back in hooks, hListView assignments, and the Wh_ModUninit cleanup loop are all guarded. FindShellTabAndDoAction copies winrt::com_ptr under the lock and calls DoAction() outside it, so BrowseObject/SendInput never run while holding the mutex.

  2. Init guard — Replaced CHECK_INIT_OR_RETURN_ZERO() with CHECK_INIT_OR_DEFER(hWnd, uMsg, wParam, lParam) which falls through to DefSubclassProc instead of swallowing messages.

  3. Settings UAF — Replaced raw PCWSTR globals with an RAII StringSetting class plus a SettingsSnapshot struct. LoadSettings takes the settings mutex; subclass procs call CopySettings() under the lock and use the stack copy. The s_firstCall / manual Wh_FreeStringSetting bookkeeping is gone.

@m417z

m417z commented Jun 27, 2026

Copy link
Copy Markdown
Member

Submission review

Note: This review was done by Claude, and then refined manually. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


  • COM teardown ordering in Wh_ModUninit. You added an explicit g_comInitializer.Uninit() (CoUninitialize) but never reset g_pUIAutomation, so the global com_ptr<IUIAutomation> is Release()d by its destructor after CoUninitialize has already run (and from a possibly different thread/apartment than the one that created it in Wh_ModInit). Reset it before tearing COM down — g_pUIAutomation = nullptr; ahead of g_comInitializer.Uninit(); — or just drop the explicit Uninit() and rely on the global destructors like the base mod does (g_comInitializer is declared before g_pUIAutomation, so they already destruct in the correct order). Likewise the IShellBrowser refs in g_Wrappers are released on the arbitrary uninit thread; they're cross-apartment releases, generally harmless here since Explorer keeps the objects alive, but worth being aware of.

Related: "Global COM objects with non-trivial destructors run at process shutdown." from the previous review isn't fixed.

I think the simplest solution here is the following:

  • Remove g_comInitializer completely - using it in Wh_ModInit or Wh_ModUninit is incorrect, you shouldn't assume the thread they're running on, and both might run on different threads.
  • Initialize g_pUIAutomation lazily when needed - create a helper function that does:
    static IUIAutomation* g_pUIAutomation = [](){
      // Init...
    };
    return g_pUIAutomation;
    Make sure to run it in the Exlporer thread, where you can assume COM is initialized.
  • g_pUIAutomation will be leaked. It's better than the current situation where the destructor might cause a crash, as described in the previous review.

Deadlock on unload — g_wrappersMutex is held across RemoveWindowSubclassFromAnyThread. In Wh_ModUninit:

{
    std::lock_guard<std::mutex> lk(g_wrappersMutex);     // line 846
    for (ExplorerWrapper& wrapper : g_Wrappers) {
        ...
        WindhawkUtils::RemoveWindowSubclassFromAnyThread(hWnd, SysListViewSubclass);  // 853
        ...
        WindhawkUtils::RemoveWindowSubclassFromAnyThread(hWnd, DUISubclass);          // 855
    }
    g_Wrappers.clear();
}

RemoveWindowSubclassFromAnyThread is implemented via SendMessage to the window's owning thread. Meanwhile FindShellTabAndDoAction (called from both subclass procs) locks the same g_wrappersMutex (line 474). Classic lock-and-SendMessage deadlock: Wh_ModUninit (arbitrary thread) holds the mutex and blocks in SendMessage; the Explorer UI thread is inside FindShellTabAndDoAction waiting for the mutex; the message is never dispatched and the process hangs. The g_initialized guard doesn't save you — a subclass proc that already passed the guard and is blocked acquiring the lock is exactly the stuck thread.

Fix is the standard copy-then-release-then-call pattern — collect the handles under the lock, drop it, then remove the subclasses:

std::vector<std::pair<HWND, bool>> toRemove;  // {hwnd, isSysListView}
{
    std::lock_guard<std::mutex> lk(g_wrappersMutex);
    for (ExplorerWrapper& w : g_Wrappers) {
        HWND hWnd = w.hListView;
        if (hWnd && IsWindow(hWnd)) {
            wchar_t cn[256];
            if (GetClassName(hWnd, cn, 256))
                toRemove.push_back({hWnd, wcscmp(cn, L"SysListView32") == 0});
        }
    }
    g_Wrappers.clear();
}
for (auto& [hWnd, isLV] : toRemove)
    WindhawkUtils::RemoveWindowSubclassFromAnyThread(
        hWnd, isLV ? SysListViewSubclass : DUISubclass);

(The reference mod gets away with calling RemoveWindowSubclassFromAnyThread directly in Wh_ModUninit precisely because it has no mutex; once you add one and take it in the subclass path, you have to release it before the cross-thread call.)

Optional improvements

Minor polish — none of this affects users in the common case, so it's your call.

  • Settings snapshot returns dangling pointers (use-after-free on settings change). CopySettings() copies the pointers out of the StringSetting objects, not the strings:
    struct SettingsSnapshot { PCWSTR doubleClick; PCWSTR middleClick; PCWSTR doubleMiddleClick; };
    static SettingsSnapshot CopySettings() {
        std::lock_guard<std::mutex> lock(g_settingsMutex);
        return { g_doubleClickAction.Get(), ... };   // raw pointers escape the lock
    }
    When Wh_ModSettingsChangedLoadSettings() runs concurrently, StringSetting::Load calls Wh_FreeStringSetting(m_str) and frees the very string a subclass proc is about to wcscmp. It's a narrow window (requires a settings change mid-click) so it's not a common-case crash, but the whole snapshot/mutex machinery exists to prevent exactly this and currently doesn't. Fix: snapshot the values as std::wstring (deep copy under the lock) instead of raw PCWSTR.
  • Reinventing WindhawkUtils::StringSetting. The custom StringSetting class duplicates the RAII one in windhawk_utils.h. You can drop the custom class and store std::wstring snapshots (see the first bullet), or use WindhawkUtils::StringSetting directly.
  • wcsncpy(g_currentClick.className, cn, 256) / wcsncpy(g_lastClick.className, cn, 256) won't null-terminate if a class name is ≥256 chars. Not reachable in practice (these class names are short), but std::wstring or an explicit terminator is cleaner.

Functionality notes

Non-critical observations about the feature behavior itself.

  • SendInput-based actions are fragile. New Tab / Close Tab / New Folder / Duplicate Tab synthesize global keystrokes (Ctrl+T, Ctrl+W, Ctrl+Shift+N). That depends on the clicked Explorer window being the foreground/focused window and on the user not having remapped those shortcuts, and the synthesized input goes to whatever has focus rather than to a specific browser. For the Win11 tab operations there's admittedly no clean public API, so this may be unavoidable — but where a direct API exists (e.g. New Folder via the shell IContextMenu/SHCreateDirectory, or navigation via IShellBrowser which you already use), it would be more robust than synthesizing keys. Just flagging the trade-off.
  • Duplicate Tab relies on a 500ms timer race. DuplicateTab fires Ctrl+T, then SetTimer(..., 500, NavigateNewTabProc) and hopes the new tab's FileCabinet_CreateViewWindow2 runs within that window to capture g_pendingNavBrowser. On a slow machine the view may not be created in time and the duplicate silently doesn't navigate. An event-driven hook-off (navigate from inside the FileCabinet_CreateViewWindow2 hook when a duplicate is pending, instead of a fixed delay) would be more reliable. Also, g_pendingNavPath / g_pendingNavBrowser / g_pendingNavHwnd are read/written from the subclass proc, the FileCabinet_CreateViewWindow2 hook, and the timer callback without synchronization — fine for a single Explorer window (one UI thread), but racy if duplicate-tab is triggered in two windows on different threads at once.
  • g_Wrappers grows without bound. FileCabinet_CreateViewWindow2Hook pushes a new ExplorerWrapper (holding an AddRef'd IShellBrowser) on every view creation and nothing ever removes them, so the vector and the browser references accumulate over a long session of opening tabs/navigating, and FindShellTabAndDoAction always matches the oldest (first) entry for a given ShellTabWindowClass. This is inherited from explorer-double-click-up, but the new tab/navigation features exercise it much more. Consider pruning the wrapper when its ShellTabWindowClass is destroyed (e.g. on WM_NCDESTROY) or de-duplicating by hShellTab on insert.
  • Triple-click double-fires. The DirectUI left-click double-click detection doesn't reset g_lastClick after firing, so three rapid clicks trigger the action twice (also inherited from the base mod). Resetting lastClick.time = 0 after a successful double-click match avoids it.
  • The effect is behavioral rather than visual, but a short GIF in the README (e.g. double-clicking empty space to go up, middle-click refresh) would help users understand it at a glance.

@LiHua81

LiHua81 commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

All three fixes pushed. Deadlock resolved — collect HWNDs under lock, unlock subclasses without holding the mutex. Settings snapshot deep-copied with std::wstring. COM cleanup removed. wcsncpy fixed with explicit terminator and triple-click reset. Nothing more to address from this round.

@m417z

m417z commented Jun 27, 2026

Copy link
Copy Markdown
Member

g_comInitializer is still initialized in Wh_ModInit. Since you're developing with Claude, please work with my suggestion above:

I think the simplest solution here is the following: ...

@m417z

m417z commented Jun 27, 2026

Copy link
Copy Markdown
Member

The reason I asked to use static IUIAutomation* g_pUIAutomation and not static winrt::com_ptr<IUIAutomation> s_pUIAutomation is because of this review point: "Global COM objects with non-trivial destructors run at process shutdown".

Please use static IUIAutomation*.

@m417z
m417z merged commit 17537dd into ramensoftware:main Jun 27, 2026
3 checks passed
@emvaized

Copy link
Copy Markdown

Wonderful mod! Please also consider adding "Paste" action for middle click, or a possibility of running any custom hotkey (so that I could assign it to Ctrl+V).

@LiHua81

LiHua81 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Wonderful mod! Please also consider adding "Paste" action for middle click, or a possibility of running any custom hotkey (so that I could assign it to Ctrl+V).

Thanks a lot for your suggestion! I’ve updated the mod, now middle click paste and custom hotkey binding (including Ctrl+V assignment) are fully supported.

@emvaized

emvaized commented Jul 1, 2026

Copy link
Copy Markdown

@LiHua81
Wow, that was fast! Thanks a lot 🙏

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants