Skip to content

Add Taskbar Folder Hover Tray mod (folder manager + Explorer pin) - #5003

Open
Kiploom wants to merge 10 commits into
ramensoftware:mainfrom
Kiploom:add-taskbar-folder-hover-tray-v125
Open

Add Taskbar Folder Hover Tray mod (folder manager + Explorer pin)#5003
Kiploom wants to merge 10 commits into
ramensoftware:mainfrom
Kiploom:add-taskbar-folder-hover-tray-v125

Conversation

@Kiploom

@Kiploom Kiploom commented Aug 4, 2026

Copy link
Copy Markdown

Summary

Supersedes #4936 with a substantially expanded version of taskbar-folder-hover-tray.
The previous PR already shipped the core hover-tray experience (flush taskbar
buttons, cascading icon grids, acrylic popups, secondary monitors, and several
rounds of AI-review hardening through v1.24). This update keeps that foundation
and replaces how folders are managed.

What shipped in #4936 (still here)

  • Folder shortcut buttons carved into the Windows 11 app icon strip, sized
    from a live taskbar button
  • Hover-opened cascading icon grids with a mouse corridor, subfolder badges,
    shell context menus, and left-click launch
  • Primary + secondary taskbar injection, acrylic blur, theme/accent awareness
  • shell: filesystem targets, env-var expansion, LRU folder cache, async icon
    extraction, remote-path guards, and the review fixes already landed on that PR
image image

What this PR adds on top of #4936

1. Taskbar Folders manager (replaces the settings-page folder list)

  • Folders are no longer edited as a Windhawk settings array (mods cannot write
    settings back, and Explorer runs unelevated against a read-only HKLM key)
  • New native Taskbar Folders window is the single source of truth, persisted
    in mod Storage (Wh_SetStringValue / Wh_SetIntValue)
  • Open it via taskbar button right-click → Manage folders..., or by toggling
    Open the folder manager in settings and saving
  • Add / Edit / Remove, drag-reorder rows (left-to-right taskbar order), and
    Pinned to the taskbar as a draft flag — unpin keeps name/icon without
    deleting the entry
Screenshot 2026-08-03 232523 Screenshot 2026-08-03 232536

2. Explorer / Desktop integration

  • Shell context-menu hook adds a Taskbar Folders submenu:
    • Pin "…" to Taskbar for folders not already in the store
    • Move to… / Copy to… / Create shortcut in… into existing pinned folders
  • Pins pick up the folder's name and custom icon (Properties → Customize)
  • Duplicate pins reuse an existing draft entry instead of creating a second one
image image

3. Migration from the old settings list

  • First run of this version imports previous settings-page folders and any older
    pinned[] storage entries into the manager store
  • Previously "hidden/unpinned" settings rows come across as drafts

4. Settings / appearance cleanup

  • Removed the folders: settings array; added manageFolders open-manager action
  • Replaced raw cellWidth / cellHeight / iconSize with an Item size
    preset (small / medium / large)
  • Added item + title font family and font weight controls
  • Grouped settings under Behavior / Content / Appearance
  • Known-conflicts note trimmed to Taskbar Styler (Fluent Media Player note removed)

5. UX polish tied to the new workflow

  • Taskbar button context menu: Unpin / Manage folders
  • Manager list drag insertion line, edit dialog with folder/icon browse,
    press-bounce / highlight polish on tray interaction

Scope / size

Note for automated reviewers

Catalog overlap with taskbar-folder-menus is acknowledged and documented in the
mod readme for end users. Do not re-raise coexistence in subsequent AI review
rounds; that decision is for the human maintainer. Focus on new technical defects
in the manager / storage / Explorer-menu paths.

Test plan

  • Install from this PR on Windows 11; confirm buttons still sit flush with apps
  • Migrate: with folders configured from Add Taskbar Folder Hover Tray mod #4936 settings, upgrade and confirm they appear in Taskbar Folders
  • Open manager via settings toggle and via taskbar right-click → Manage folders...
  • Add / edit / drag-reorder / unpin-as-draft / remove; confirm taskbar updates live
  • Explorer/Desktop: Pin a folder; Move/Copy/Shortcut into a pinned folder
  • Hover grid still opens instantly; nested folders cascade; right-click shell menu works
  • Secondary monitor taskbar injection still works
  • Acrylic on/off recreates popups cleanly; theme change does not break hover

Mod authorship

This mod was created by:

  • The submitter, with AI assistance (Cursor (Grok / Cursor agent) & Claude (Sonnet + Opus))

The human author and submitter is Grant Benson (@Kiploom).

Hover tray buttons in the app icon strip, plus a Taskbar Folders manager
and Explorer pin/move/copy integration replacing the settings-page list.
@Kiploom Kiploom mentioned this pull request Aug 4, 2026
6 tasks
@windhawk-reviewer windhawk-reviewer Bot added the waiting-for-author The author's turn: request an AI review, or respond to one that was posted. label Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Thanks for the pull request! This repository uses a two-stage review: an AI review that you run yourself, followed by a human review.

To get started, comment /ai-review. Once you're happy with the result, comment /ready-for-reviewer to hand it over to a human reviewer.

See the pull request review process for the full details.

@Kiploom

Kiploom commented Aug 4, 2026

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 4, 2026
Replace SetWindowLongPtr(GWLP_WNDPROC) on the folder manager listbox with
SetWindowSubclassFromAnyThread / DefSubclassProc so CI no longer flags it.
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

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

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

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


The taskbar-side plumbing is careful and clearly battle-tested. The new surfaces added here — the manager window, the store, and the Explorer context-menu hook — have a few real problems:

1. Deadlock: StopScanThread() joins the worker on the taskbar UI thread, without pumping sent messages

DeliverButtonIconToUi delivers icons with RunFromWindowThread(taskbarWnd, ...), which is a blocking SendMessage to the taskbar UI thread. StopScanThread() joins with a plain thread->join(), which does not dispatch inbound sent messages. So if the worker enters SendMessage while the taskbar thread is inside join(), both block forever and Explorer's taskbar hangs permanently.

The path that reaches it from the taskbar UI thread is the right-click unpin:

unpin.Click (taskbar UI thread)
  -> RemovePinnedFolder(path)
  -> ReloadAndRefreshUI()
       -> StopRetryThread()   // pumps QS_SENDMESSAGE — correct
       -> StopScanThread()    // thread->join() — no pump

The check at the top of DeliverButtonIconToUi's caller (!g_scanThreadStop) narrows the window but doesn't close it — the flag can be set between the check and the SendMessage.

StopRetryThread already has the right shape; StopScanThread needs the same. Keep the std::thread's handle (or switch the worker to a HANDLE) and wait with MsgWaitForMultipleObjects(..., QS_SENDMESSAGE) + PeekMessage(..., PM_NOREMOVE) before closing the events.

Related, in the same path: ReloadAndRefreshUI() from unpin.Click runs the whole teardown inline on the UI thread — RemoveAllHostGrids() hides g_buttonMenuFlyout, unhooks the button's handlers and clears its content while you are still inside that flyout item's own Click callback, with the flyout anchored (ShowAt(button)) to the button being torn down. Posting the reload to run after the handler returns (e.g. a WM_APP message to the menu-owner window, or DispatcherQueue/Dispatcher.RunAsync) avoids both this re-entrancy and the join above.

2. g_buttonMenuFlyout is a global strong XAML reference without [[clang::no_destroy]]

MenuFlyout g_buttonMenuFlyout{nullptr};   // line ~6440

Wh_ModUninit does not run when Explorer terminates (sign-out, restart, reboot) — only the CRT destructors of globals do, on the shutdown thread after every other thread has already been killed. Releasing a strong XAML MenuFlyout there is the UI-thread-affinity case. The other XAML-holding globals in the file (g_taskbarHosts) already get this treatment; this one was missed. A WinRT projected type is nullable, so the bare attribute is the right form here:

// Released with `g_buttonMenuFlyout = nullptr;` in ClearButtonState.
[[clang::no_destroy]] MenuFlyout g_buttonMenuFlyout{nullptr};

The existing = nullptr cleanup in ClearButtonState must stay. See https://github.com/ramensoftware/windhawk/wiki/Global-objects-and-process-shutdown (section #4-xaml-and-other-ui-thread-objects).

3. The folder manager window is not DPI-aware

Every dimension in FolderManager is a raw pixel constant — the window ({0, 0, 476, 400}), the listbox (12, 12, 452, 300), all the edit-dialog controls, kRowHeight = 40, kRowIconSize = 24, the drag hit-testing (pt.y / kRowHeight) and the insertion line. explorer.exe is manifested Per-Monitor-DPI-Aware V2, so threads it creates inherit that context and these windows get no automatic scaling, while UiFont() takes its font from SystemParametersInfoW(SPI_GETNONCLIENTMETRICS) — which returns metrics sized for the system DPI. On a 150%/200% display the result is a small window with a large font: clipped labels, text overflowing the 24px edits, and rows too short for their two lines.

The hover grid already does this properly via ScaleForPopup/GetDpiForWindow. The manager needs the same: scale all constants by GetDpiForWindow(hWnd) / 96, use SystemParametersInfoForDpi(SPI_GETNONCLIENTMETRICS, ..., dpi) for the font, and handle WM_DPICHANGED (re-layout + re-create the font) so dragging the window between monitors works.

4. The folder store is mutated from three threads with no synchronization, and an open manager window is never told it changed

FolderStore::Read() + mutate + FolderStore::Write() is a read-modify-write sequence run from the manager thread (CommitStore, MoveRow), the taskbar UI thread (RemovePinnedFolder) and an Explorer window thread (AddPinnedFolder, from the context-menu hook) — with no lock. Write also writes the entries, then deletes the tail, then updates entryCount, so an interleaving can leave the persisted list inconsistent. A std::mutex around Read/Write (and around the read-modify-write callers) fixes it cheaply.

Separately: only Wh_ModSettingsChanged posts WM_APP to an open manager window. ReloadAndRefreshUI() — which is what the Explorer "Pin" and the taskbar unpin go through — does not. So pinning a folder from Explorer while the manager is open silently does nothing visible in the list, and the manager's g_rows (and the indices EditSelected / RemoveSelected / MoveRow commit with, and the name shown in the Remove confirmation) are stale. Posting WM_APP from ReloadAndRefreshUI when FolderManager::g_wnd is set, and matching by path rather than index when committing, would make this consistent.

5. FolderManager::CloseAndWait() can give up while the manager thread is still running mod code

void CloseAndWait() {
    if (HWND existing = g_wnd.load()) PostMessageW(existing, WM_CLOSE, 0, 0);
    for (int i = 0; g_active && i < 250; i++) Sleep(20);   // 5 s, then continue anyway
    ...
}

WM_CLOSE is posted only to the main window. If the user has the Remove confirmation MessageBox up, or the IFileDialog folder/icon picker open from the edit dialog, that message does not dismiss them — the wait expires after 5 s and Wh_ModUninit returns, Windhawk unloads the DLL, and the manager thread is still inside a message loop and window procedures that live in the unmapped image. That's an Explorer crash on mod disable/update, which the comment above the function correctly identifies as the thing to avoid.

Two concrete improvements: post WM_CLOSE to every window on the manager thread (EnumThreadWindows + GetWindowThreadProcessId), which does dismiss MessageBox/IFileDialog; and keep the real thread handle instead of detach()ing, so the wait is on the thread object rather than a polled bool.

6. The Explorer context-menu CBT hook forces dark mode unconditionally

if (IS_INTRESOURCE(cls) && LOWORD((ULONG_PTR)cls) == 32768) {
    BOOL dark = TRUE;
    DwmSetWindowAttribute(hWnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark));

The hook is thread-wide for the duration of TrackPopupMenuEx_orig, so it catches every #32768 menu window created while Explorer's context menu is up — including Explorer's own submenus, which Explorer already themes correctly. On a light theme this darkens menus the mod doesn't own. Gate it on the theme you already track: BOOL dark = IsDarkTheme();.

7. CWM_GETISHELLBROWSER (WM_USER + 7) is sent to every ancestor window

IShellBrowser* browser = (IShellBrowser*)SendMessageW(hwnd, WM_USER + 7, 0, 0);
for (HWND h = hwnd; !browser && h; h = GetParent(h)) {
    browser = (IShellBrowser*)SendMessageW(h, WM_USER + 7, 0, 0);
}

WM_USER-relative messages mean different things to different window classes, so walking the parent chain sends it to whatever happens to be up there (Progman, WorkerW, …). Restrict it to the classes that are actually known to answer it — SHELLDLL_DefView (which IsShellViewWindow already located, so you can return that HWND instead of discarding it), ShellTabWindowClass, CabinetWClass. explorer-status-metadata.wh.cpp's SafeGetShellBrowser is the pattern.

8. The Explorer integration has no off switch

Hooking TrackPopupMenuEx process-wide and appending Pin / Move / Copy / Copy-as-shortcut items to menus the mod doesn't own is a second, independent feature from "folder buttons in the taskbar app strip" — and a user who wants only the hover tray still gets every Explorer and Desktop context menu routed through the mod. Please add a setting to disable it (defaulting either way), and skip installing the hook entirely when it's off. It also keeps the blast radius honest: with the setting off, a bug in GetSelectedPath/IsShellViewWindow can't touch Explorer at all.

Optional improvements

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

  • Use the type-safe wrapper for the one raw hook: WindhawkUtils::SetFunctionHook(TrackPopupMenuEx, AddToTaskbar::TrackPopupMenuExHook, &AddToTaskbar::TrackPopupMenuEx_orig) instead of Wh_SetFunctionHook with three void* casts.
  • WndProc's WM_CREATE swaps the listbox procedure with SetWindowLongPtrW(list, GWLP_WNDPROC, ...). It's the mod's own control so nothing else is chaining onto it, but WindhawkUtils::SetWindowSubclassFromAnyThread / SetWindowSubclass is the idiomatic form and removes the manual restore in WM_DESTROY.
  • The context-menu items are appended before checking flags & TPM_RETURNCMD, and the result is only interpreted when that flag is set. Without it, picking one of the mod's items sends WM_COMMAND with an id in the 0xC901+ range straight to Explorer. Cheapest fix is to skip the whole injection when !(flags & TPM_RETURNCMD).
  • FolderStore::MigrateLegacy()IndexOfPath()ResolveFolderPath() calls SHParseDisplayName from Wh_ModInit, which is exactly what the comment in LoadFolders says not to do there ("no CoInitialize / SHParseDisplayName ... creating then destroying an STA there is unsafe"). It fails harmlessly rather than crashing, but the shell: dedupe during migration silently degrades to a raw string compare. Either skip the resolve during migration or note why it's fine.
  • FolderStore::GetString reads into WCHAR buf[1024], so a stored path longer than 1023 chars is truncated on load and then written back truncated. Long paths are rare, but a std::wstring sized from the return value of Wh_GetStringValue would remove the sharp edge.
  • PopupWndProc calls RefreshThemeCache() on every WM_SETTINGCHANGE broadcast, which constructs a UISettings and probes a Gdiplus::FontFamily each time. Filtering on lParam == L"ImmersiveColorSet" would cut most of those.
  • MoveOrCopy and CreateShortcutIn return a success flag that both callers discard, and FOF_NOERRORUI suppresses the shell's own error dialog — so a failed move/copy (e.g. an unresolved shell: destination, where destDir falls back to the literal shell:Downloads string) is completely silent. Worth at least a Wh_Log on failure.
  • Typo in Wh_ModUninit: L"Uninidt: RunFromWindowThread failed...".

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • MoveOrCopy uses SHFileOperationW with op.hwnd left null and no FOF_ALLOWUNDO. Any progress or collision UI it does show is ownerless, and a move invoked from Explorer's context menu can't be undone with Ctrl+Z — which is what users will expect from something that sits next to Explorer's own Cut/Copy. IFileOperation (or at minimum op.hwnd = hwnd plus FOF_ALLOWUNDO) would match the shell's behaviour. It's also fully synchronous on the Explorer window thread, so a large copy freezes that window.
  • IsAlreadyTaskbarFolder() re-reads the entire store — up to 200 entries × 4 Wh_GetStringValue calls, plus a ResolveFolderPath/SHParseDisplayName per entry for shell: paths — on the Explorer UI thread, before every right-click on a folder. Caching the resolved path set and invalidating it on write would keep the context menu snappy.
  • Every store commit goes through the full ReloadAndRefreshUI(): stop the retry thread, stop and join the scan thread, destroy all host grids and popup windows, reload settings, restart. During a drag-reorder that fires once per drop, so the taskbar buttons are rebuilt and their icons re-extracted each time. A lighter path that only re-orders/rebuilds buttonStates would make reordering feel instant and avoid the churn.
  • ComputeLevelLayout silently drops items beyond cols * rows when the grid is capped to the monitor, on top of the maxItems and MaxCachedItemsPerFolder caps. A folder that gets truncated gives the user no indication that there's more in it — a trailing "…" cell or a count in the title band would help.
  • When the destination for Move/Copy/shortcut is an unresolved shell: entry, folders[idx].resolvedPath is empty and the raw shell:... string is passed to SHFileOperationW/CreateShortcutIn, which just fails. Those entries could be filtered out of the destination submenus.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 4, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Kiploom

Kiploom commented Aug 4, 2026

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

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

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

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


The storage layer is carefully done — the read-modify-write blocks are all scoped under FolderStore::g_mutex, the lock order against g_foldersMutex is documented and respected, edit/remove commit by path rather than by a stale index, and the reorder abandons itself when the store moved underneath the window. Teardown also held up: CloseAndWait posting WM_CLOSE to every window on the manager thread (so an open IFileDialog or MessageBox can't strand it) is the right shape, and the no_destroy + optional<std::thread> pattern is used correctly for both workers. The items below are mostly about ReloadAndRefreshUI now having four callers on four different threads, and about the new Explorer menu path.

1. ReloadAndRefreshUI runs inline on whatever thread called it, and nothing serializes it.

It now has four entry points on four threads:

Caller Thread
Wh_ModSettingsChanged (line 8184) Windhawk engine thread
MenuOwnerWndProc / WM_APP_RELOAD_UI (line 3937) taskbar UI thread
TrackPopupMenuExHook pin (line 8048) Explorer browser window thread
FolderManager::RefreshAfterStoreChange (line 5709) manager thread

Two problems follow:

  • It blocks the calling thread on two thread joins plus a cross-thread SendMessage. For the Explorer pin that is the UI thread of the window the user just right-clicked in. StopScanThread only checks g_scanThreadStop between items in ScanFolderInto (line 2534), so if the worker is inside GetShellIconForPath on a slow shell icon handler the Explorer window is frozen for the duration; ApplyOnWindowThread then blocks again on the taskbar UI thread.
  • Two concurrent runs race. LoadSettings (line 992) assigns std::wstring members of g_settings (position, anchor, titleAlign, itemFontFamily, titleFontFamily) with no lock — two threads doing that concurrently is a genuine data race on the string buffers. And in StopScanThread (line 2857) the second caller finds g_scanThread/g_scanStopEvent/g_scanWorkEvent already swapped out, returns immediately, and clears g_scanThreadJoining while the first caller is still joining. An Explorer pin landing at the same moment as a taskbar unpin or a manager reorder reaches both.

You already have the right mechanism for this — the unpin flyout defers through PostMessageW(g_menuOwnerWnd, WM_APP_RELOAD_UI, 0, 0) (line 6954). Use it for the Explorer-pin and manager paths too, so the reload always runs on the taskbar UI thread and is naturally serialized by that thread's message loop:

// AddToTaskbar::TrackPopupMenuExHook, and FolderManager::RefreshAfterStoreChange
AddPinnedFolder(path, LeafName(path));
if (!g_menuOwnerWnd || !PostMessageW(g_menuOwnerWnd, WM_APP_RELOAD_UI, 0, 0)) {
    ReloadAndRefreshUI();  // fallback only
}
return 0;

Worth calling out explicitly: wrapping ReloadAndRefreshUI in a plain mutex is not a safe alternative. The function SendMessages to the taskbar UI thread via ApplyOnWindowThread; if the taskbar thread is the one blocked in mutex.lock() it never dispatches that send, and both threads hang.

2. Re-pinning an unpinned draft from Explorer is impossible, which contradicts the readme.

The readme says "If it is already in the list as an unpinned draft, pinning it again re-uses that entry rather than creating a second one", and the PR description says "Duplicate pins reuse an existing draft entry instead of creating a second one". The code does neither:

bool canPin = isDir && !IsAlreadyTaskbarFolder(path);   // line 7954

IsAlreadyTaskbarFolder (line 947) matches any stored entry, pinned or draft, so once the user right-clicks a taskbar button → Unpin from taskbar (which deliberately keeps the entry as a draft), the Pin "…" to Taskbar item silently disappears from that folder's Explorer menu with no explanation. AddPinnedFolder (line 955) also bails out rather than flipping pinned back to true. The only way back is Manage folders → Edit → tick the checkbox — which is exactly the round-trip the readme promises works from Explorer.

Fix: only hide the item when a pinned entry exists, and make the pin path re-pin a draft:

bool AddPinnedFolder(const std::wstring& path, const std::wstring& name) {
    std::lock_guard<std::recursive_mutex> lock(FolderStore::g_mutex);
    auto stored = FolderStore::Read();
    int at = FolderStore::IndexOfPath(stored, path);
    if (at >= 0) {
        if (stored[at].pinned) {
            return false;           // already on the taskbar
        }
        stored[at].pinned = true;   // re-pin the draft, keeping name/icon
        FolderStore::Write(stored);
        return true;
    }
    ...
}

3. The renamed-folder recovery is unreachable — a renamed pinned folder just shows an empty grid.

FindRenamedSibling (line 580) is only called from BuildFolderEntry behind if (out->resolveFailed && stored->fileId != 0) (line 874), and resolveFailed is set from:

out->resolvedPath = ResolveFolderPath(out->path);
out->resolveFailed = out->resolvedPath.empty();     // line 870

For a non-shell: path ResolveFolderPath (line 2306) just strips trailing slashes and returns its input — it never returns empty for a non-empty input, and FolderStore::Read already skips entries with an empty path. So resolveFailed is always false on the filesystem branch, FindRenamedSibling never runs, and the entry[i].id field written by AddPinnedFolder / the edit dialog is dead weight. When a pinned folder is renamed the button stays pointing at the old path and the hover grid shows "Empty folder".

Either make the resolve actually validate the folder:

std::wstring resolved = ResolveFolderPath(out->path);
DWORD attrs = resolved.empty() ? INVALID_FILE_ATTRIBUTES
                               : GetFileAttributesW(resolved.c_str());
bool exists = attrs != INVALID_FILE_ATTRIBUTES &&
              (attrs & FILE_ATTRIBUTE_DIRECTORY);
out->resolvedPath = exists ? resolved : L"";
out->resolveFailed = !exists;

(keeping the IsLikelyRemotePath guard first so an offline share doesn't block Wh_ModInit), or drop GetDirFileId / FindRenamedSibling / the id storage field entirely.

4. MoveOrCopy moves files with no undo, no confirmation, and no error feedback.

op.fFlags = FOF_NOCONFIRMMKDIR | FOF_NOERRORUI | FOF_RENAMEONCOLLISION;  // line 7862
return SHFileOperationW(&op) == 0;

A mis-click on Taskbar Folders → Move → <folder> relocates the user's file permanently: FOF_ALLOWUNDO is absent so Ctrl+Z in Explorer won't bring it back, FOF_NOERRORUI swallows access-denied / in-use failures, and op.hwnd is nullptr so any dialog the shell does show is ownerless. At minimum add FOF_ALLOWUNDO, drop FOF_NOERRORUI, and pass the Explorer window as op.hwnd. Better still, SHFileOperation has been superseded by IFileOperation (undo, progress, proper elevation prompts) — mods/copy-queue.wh.cpp is a reference for working with it in this repo.

5. CreateShortcutIn doesn't notify the shell, so the new shortcut may not appear.

persist->Save(lnkPath.c_str(), TRUE) (line 7896) writes the .lnk but nothing tells Explorer about it, so if the destination folder is open in a window the user sees nothing happen until they refresh. Add after a successful save:

SHChangeNotify(SHCNE_CREATE, SHCNF_PATH | SHCNF_FLUSH, lnkPath.c_str(), nullptr);

6. The readme and the explorerMenu description overstate where the Explorer menu appears.

The hook is on TrackPopupMenuEx, which on Windows 11 is the classic context menu — the one behind Show more options / Shift+F10. The default Win11 file, folder and Desktop context menus are the XAML ones and will not show a Taskbar Folders entry. Both the readme ("right click any folder in Explorer or on the Desktop and pick Taskbar Folders -> Pin") and the setting description ("Adds a "Taskbar Folders" submenu to Explorer and Desktop context menus") read as if it's on the default menu, which will generate "the menu item isn't there" reports. Please say explicitly that it appears in the classic Show more options menu (and that mods such as explorer-context-menu-classic make that the default).

Optional improvements

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

  • Three [[clang::no_destroy]] attributes aren't needed, and one of them hides a real leak. FolderStore::g_mutex (line 661), FolderManager::g_threadMutex (line 6740) and FolderManager::g_fontCache (line 5596) all have destructors that are either a no-op or a plain heap free, which is safe on the process-shutdown path — the attribute there is noise that invites cargo-culting elsewhere. See Global objects and process shutdown for which types actually need it (g_scanThread, FolderManager::g_thread, g_taskbarHosts, g_levels, g_folderCache and g_buttonMenuFlyout are all correct as they stand). Separately, the HFONTs in g_fontCache are created by UiFont (line 5598) and never destroyed — a couple of GDI objects leak on every mod load/reload. Free them in Wh_ModUninit:

    for (auto& [dpi, font] : FolderManager::g_fontCache) {
        DeleteObject(font);   // no-op for the DEFAULT_GUI_FONT stock object
    }
    FolderManager::g_fontCache.clear();
  • Use the type-safe hook helper. Wh_ModInit (line 8116) still uses raw Wh_SetFunctionHook with void* casts; since TrackPopupMenuEx_orig is already decltype(&TrackPopupMenuEx), this is a drop-in:

    WindhawkUtils::SetFunctionHook(TrackPopupMenuEx,
                                   AddToTaskbar::TrackPopupMenuExHook,
                                   &AddToTaskbar::TrackPopupMenuEx_orig);
  • EnsureClass unregisters and re-registers on every dialog. RunEditDialog calls EnsureClass(kEditClassName, ...) (line 6125) each time; from the second Add/Edit onward RegisterClassExW fails with ERROR_CLASS_ALREADY_EXISTS, so the stale-class recovery path runs and tears down + re-registers a class this same DLL registered a moment ago. The recovery itself is right (reusing a foreign class would be the bug), it just shouldn't fire in the common case — gate it with a per-class static "registered by this load" flag, the way EnsurePopupClasses (line 4178) does.

  • FolderStore::GetString silently drops long entries. WCHAR buf[1024] (line 674), and Wh_GetStringValue returns an empty string when the buffer is too small. Read then continues past an entry whose path didn't fit (line 690), and the next Write from any manager edit persists the shortened list — so an over-long path/name/icon doesn't just fail to load, it gets permanently deleted. 1023 chars is generous, but a heap buffer sized for the extended-length limit would make the failure impossible.

  • Don't append the menu items when the caller can't return them. TrackPopupMenuExHook adds the submenu unconditionally but only handles the result under flags & TPM_RETURNCMD (line 8042). Without that flag the selection is dispatched as WM_COMMAND(0xC901…) to the shell view, which maps it to an out-of-range IContextMenu::InvokeCommand offset. Bailing out to TrackPopupMenuEx_orig early when the flag is missing costs nothing. (The existing explorer-context-menu-custom-items has the same gap, so this is hardening rather than a regression.)

  • Escape doesn't close either window. IsDialogMessageW turns Esc into WM_COMMAND(IDCANCEL), but EditWndProc only checks kIdEditCancel (1108) and WndProc only kIdClose (1008), so Esc does nothing in the edit dialog or the manager. Handling IDCANCEL alongside those two ids would match what people expect from a dialog.

  • CenterOnMonitor's failure value isn't valid for SetWindowPos. It returns POINT{CW_USEDEFAULT, CW_USEDEFAULT} (line 5575), which ThreadMain feeds straight into SetWindowPos (line 6700) — CW_USEDEFAULT is INT_MIN there, not "pick a position", so the window would land far off-screen. MonitorFromPoint(..., MONITOR_DEFAULTTONEAREST) essentially never fails, but returning the anchor point (or the primary monitor's work-area origin) would be a safer fallback.

  • MigrateLegacy does the thing LoadFolders says must not be done in Wh_ModInit. LoadFolders (line 908) carefully avoids SHParseDisplayName because Wh_ModInit may run before the process starts executing — but MigrateLegacy (line 776), which runs first, calls IndexOfPathMatchKeyResolveFolderPath, which does call SHParseDisplayName for any legacy shell: entry. It fails harmlessly today (no apartment → the comparison falls back to the literal string, which still dedupes correctly), but the two comments contradict each other; either make MatchKey skip the shell resolve when there's no apartment, or update the comment.

  • Typo in the Wh_ModUninit log string (line 8279): "Uninidt: RunFromWindowThread failed".

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The CBT hook re-themes Explorer's own submenus too. The WH_CBT hook installed around TrackPopupMenuEx_orig (line 8016) is thread-wide, so every #32768 window created while the menu is up — Open with, Send to, New, and any shell extension's submenu — gets DWMWA_USE_IMMERSIVE_DARK_MODE and DWMWCP_ROUND applied. The comment acknowledges this and resolving the theme once keeps it from being outright wrong, and I don't see a clean way to tell "our" popup apart at HCBT_CREATEWND, so this is just an FYI: the mod is nudging window attributes on windows it doesn't own.

  • The custom menu id base is a fixed guess. kMinId = 0xC901 (line 7756) sits above the 1..0x7FFF range Explorer passes to QueryContextMenu, so a collision is unlikely, but it's still a hard-coded assumption about someone else's menu. Walking hMenu with GetMenuItemID and starting one past the highest id present would make it collision-proof.

  • A large move/copy runs synchronously on the Explorer window thread. MoveOrCopy (line 7851) is called from inside the TrackPopupMenuEx hook, so moving a big tree pins that Explorer window's UI thread for the duration. IFileOperation (see item 4 above) would also solve this, since it can run the operation with its own progress UI.

  • Drag-reorder has no auto-scroll, and the insertion line is fragile. InsertIndexAt (line 6352) clamps to the visible rows, so with more entries than fit in the 300px list there's no way to drag a row past the viewport. Separately, DrawInsertionLine (line 6366) draws with R2_NOT straight to the DC, so anything that repaints the listbox (a scroll, an overlapping window) leaves the line stranded until the next mouse move. Both are only visible with longer lists.

  • shell: destinations in the Explorer menu may not resolve. destDir falls back to folders[idx].path when resolvedPath is empty (line 8069), so a shell:Downloads entry that hasn't been resolved on an STA yet would be handed to SHFileOperationW / CreateShortcutIn as the literal string shell:Downloads and silently fail. The hook already has an apartment (ComInit at line 7920), so calling ResolveFolderPath on the chosen entry there would close the gap.

  • IsAlreadyTaskbarFolder re-resolves every entry on each right-click. It calls FolderStore::Read() then IndexOfPath, which runs MatchKeyResolveFolderPath per stored entry (line 765), all under FolderStore::g_mutex, on the Explorer UI thread. Cheap for filesystem paths, but it's a SHParseDisplayName per shell: entry every time a folder is right-clicked.

  • Wh_ModUninit waits on the manager thread without a bound. CloseAndWait (line 6776) loops forever, re-posting WM_CLOSE every 2s and logging every 10s. That's the right trade against unloading the DLL under a live message loop, and the re-close covers modals that appear late — just noting that a manager thread wedged in a shell extension would hang the mod's unload rather than time out.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 4, 2026
Reloads now run on one thread. ReloadAndRefreshUI had four entry points on
four threads (Windhawk engine, taskbar UI, an Explorer browser window, the
folder manager) and is not safe to run concurrently or inline on most of
them: it joins the scan and retry workers, so an Explorer pin froze the
window the user had just right-clicked in, and it rebuilds g_settings, which
two threads doing at once races on the string members. Every caller now goes
through RequestReloadUI, which posts WM_APP_RELOAD_UI to the taskbar UI
thread's window and falls back to an inline reload only before that window
exists. A mutex would not do: ReloadAndRefreshUI SendMessages to the taskbar
thread, so that thread blocking on the lock would deadlock both.

Re-pinning an unpinned draft works again, as the readme already promised.
The Explorer menu item hid itself for any stored entry, draft included, and
AddPinnedFolder bailed rather than flipping pinned back, so the only way back
onto the taskbar was the manager's Edit dialog. The check is now pinned-only
and a draft is re-pinned in place, keeping its name and icon.

The renamed-folder recovery was unreachable: it keyed off ResolveFolderPath
returning empty, which never happens for a filesystem path, so a renamed
folder kept pointing at the old path and showed an empty grid. It now
triggers on the directory not existing, skipping remote paths so an offline
share cannot block Wh_ModInit. A miss leaves the entry untouched, so an
unplugged drive still recovers by itself.

Move/copy is undoable and reports failures: FOF_ALLOWUNDO added,
FOF_NOERRORUI dropped, and the Explorer window passed as op.hwnd so the
shell's dialogs have an owner. CreateShortcutIn calls SHChangeNotify so a
window already showing the destination sees the new .lnk.

Readme and the explorerMenu description now say the submenu is on the classic
"Show more options" / Shift+F10 menu, not the short Windows 11 one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Kiploom

Kiploom commented Aug 4, 2026

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

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

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

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


The v1.27 reload serialization holds up well — routing every reload through WM_APP_RELOAD_UI on the taskbar UI thread is the right call, and the lock ordering around g_foldersMutex / FolderStore::g_mutex is documented and consistent. Three things to fix:

1. FolderStore::MigrateLegacy() does blocking file I/O and shell/COM work on Explorer's main thread during Wh_ModInit — and has nothing to migrate.

Wh_ModInitMigrateLegacy() violates the constraint your own LoadFolders() comment spells out ("no CoInitialize / SHParseDisplayName ... Wh_ModInit can run on Explorer's main thread before the process starts executing"), in two ways:

  • entry.fileId = GetDirFileId(entry.path) (and the same call in the pinned[] loop) opens the path with CreateFileW with no IsLikelyRemotePath guard — exactly the stall BuildFolderEntry goes out of its way to avoid ("a stat on an offline share blocks for the SMB timeout, and this runs on Explorer's main thread during Wh_ModInit"). One legacy UNC entry pointing at an offline share stalls Explorer startup for the full SMB timeout.
  • IndexOfPath(entries, entry.path)MatchKeyResolveFolderPath calls SHParseDisplayName for any shell: path, on a thread whose apartment may not exist yet.

And there is nothing for it to do: this mod has never been in the catalog (PR 4936 was closed without merging), so no released build ever wrote pinned[], hidden[] or a folders[] settings array. The settings-array half is dead on its own terms too — folders[%d].path is no longer declared in ==WindhawkModSettings==, so WindhawkUtils::StringSetting::make(L"folders[%d].path", i) returns L"" for all 64 iterations.

Simplest fix: delete MigrateLegacy() and the storeVersion / hiddenCount / pinnedCount reads, and drop the "Folders you had configured on the settings page before this change are imported into it automatically" paragraph from the README. If you'd rather keep it for people who installed from the PR branch, at minimum guard GetDirFileId with IsLikelyRemotePath and move the whole call off Wh_ModInit (e.g. run it once from InjectHostGridsOnTaskbarThread, which already has an STA).

2. EnsurePopupClasses() has no recovery for a class left registered by a previous load, and latches the failure permanently.

ATOM popupAtom = RegisterClassExW(&popupClass);
...
if (!popupAtom || !ownerAtom) { ...; classState = ClassState::Failed; return false; }

If a previous load left WH_TaskbarFolderHoverTray_Grid registered — Wh_ModUninit's RunFromWindowThread(threadWnd, teardownOnUiThread, ...) failed, so the windows survived and UnregisterClassW failed, and the reloaded DLL happens to land on the same module base — RegisterClassExW fails with ERROR_CLASS_ALREADY_EXISTS and classState sticks at Failed for the rest of the Explorer session. That silently kills every hover grid and g_menuOwnerWnd, which in turn makes RequestReloadUI() fall through to its inline path on every caller — the cross-thread inline reload v1.27 exists to avoid.

FolderManager::EnsureClass() already implements exactly the right recovery (unregister the stale registration, re-register with the current WndProc). Reuse it for kPopupClassName and kMenuOwnerClassName instead of the bare RegisterClassExW + permanent latch.

3. "Explorer right-click menu" doesn't take effect when switched on.

The hook is only installed in Wh_ModInit, so turning the setting on and saving does nothing, and the $description has to tell the user to toggle the mod off and on. Windhawk can do that for you — use the BOOL Wh_ModSettingsChanged(BOOL* bReload) variant and request a reload when the value no longer matches what init installed:

bool g_explorerMenuHooked = false;  // set to true in Wh_ModInit when the hook is installed

BOOL Wh_ModSettingsChanged(BOOL* bReload) {
    if ((Wh_GetIntSetting(L"explorerMenu") != 0) != g_explorerMenuHooked) {
        *bReload = TRUE;   // reinstall/remove the TrackPopupMenuEx hook
        return TRUE;
    }
    // ...existing body (RequestReloadUI, manageFolders edge)...
    return TRUE;
}

Then the $description's "takes effect after the mod is reloaded (toggle the mod off and on, or restart Explorer)" sentence can go away.

Optional improvements

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

  • Unneeded [[clang::no_destroy]]. FolderStore::g_mutex (line 677), FolderManager::g_threadMutex (line 6803) and FolderManager::g_fontCache (line 5658) all have destructors that are a no-op or a plain heap free, which is safe at process shutdown — the attribute is noise there and invites cargo-culting it onto types where it matters. The ones on g_scanThread, FolderManager::g_thread, g_folderCache, g_levels, g_taskbarHosts and g_buttonMenuFlyout are all correctly justified. See https://github.com/ramensoftware/windhawk/wiki/Global-objects-and-process-shutdown for the case-by-case rules.
  • g_fontCache never deletes its HFONTs. Each mod load creates one font per DPI the manager window sees and leaks them into explorer.exe on unload. A for (auto& [dpi, f] : g_fontCache) DeleteObject(f); g_fontCache.clear(); in Wh_ModUninit closes it (skip the DEFAULT_GUI_FONT stock object).
  • g_taskbarWnd is a plain HWND (line 2992) written from the retry thread in ApplyOnWindowThread and read on the taskbar UI thread in GetElementScreenRect / ComputeHoverAnchorRect. You already made g_menuOwnerWnd a std::atomic<HWND> for the same reason — worth being consistent.
  • g_themeCache is written off the taskbar thread. TrackPopupMenuExHook calls IsDarkTheme() on an Explorer browser window's thread, which can lazily run RefreshThemeCache() and write the struct while the taskbar UI thread reads it in RebuildLevelBase / PaintLevel. The values are benign, but a std::atomic<bool> for resolved + a small lock (or just resolving it once on the taskbar thread at injection time) would make it defined.
  • TrackPopupMenuExHook appends items even without TPM_RETURNCMD. In that case the selection is posted as WM_COMMAND with 0xC901+ straight to Explorer's own window instead of coming back to you. Shell views always pass TPM_RETURNCMD today, so this is theoretical, but bailing to TrackPopupMenuEx_orig when the flag is absent — before appending anything — costs one line.
  • Destination submenus can list an unresolved shell: entry. destDir falls back to folders[idx].path when resolvedPath is empty (line 8152), so SHFileOperationW / CreateShortcutIn get a literal shell:Downloads as the target. Skipping entries with an empty resolvedPath when building the Move/Copy/Shortcut submenus avoids the confusing failure.
  • The retry backoff runs its full ~60 s cycle when no folders are configured. InjectHostGridsOnTaskbarThread returns true early on the empty-folders path, but RemoveAllHostGrids() has just set g_injectionLive = false, so RetryThreadProc never breaks and walks the whole {0, 500, ..., 30000} table, each step doing an EnumWindows plus a cross-thread SendMessage. Setting g_injectionLive = true before that early return true stops it after one pass.
  • Leftover scratch markers in comments: // ponytail: 64-bit nFileIndex id, NTFS only. (line 595) and // ponytail: SHFileOperation, not IFileOperation (line 7911) read like agent notes that were meant to be cleaned up.
  • Typo: L"Uninidt: RunFromWindowThread failed..." (line 8363).
  • WindhawkUtils::SetFunctionHook is the type-safe form and would let you drop the (void*) casts on the TrackPopupMenuEx hook: WindhawkUtils::SetFunctionHook(TrackPopupMenuEx, AddToTaskbar::TrackPopupMenuExHook, &AddToTaskbar::TrackPopupMenuEx_orig).
  • Missing include: <cstdint> for the uint64_t / uint32_t used in FolderStore::Entry, ScanRequest and ButtonState — currently coming in transitively.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • A reload stalls the taskbar for as long as the scan worker takes to stop. Moving ReloadAndRefreshUI onto the taskbar UI thread fixed the Explorer-window freeze, but it moved the cost rather than removing it: StopScanThread() joins the worker, and the worker only checks g_scanThreadStop between items, so a pin/unpin/manager edit blocks the whole Windows 11 taskbar for however long the in-flight GetShellIconForPath call takes. Usually milliseconds; a slow third-party icon handler makes it visible. The join only exists because the worker reads g_settings directly — handing the worker an immutable settings snapshot per request (or a shared_ptr<const Settings>) would let a reload just swap the snapshot and clear the queue, with no join at all.
  • Wh_ModUninit's wait on the manager thread is unbounded by design. That's the right trade (hanging beats unloading the DLL under a live message loop), but note the one case the WM_CLOSE round can't reach: if the manager thread is wedged inside a COM call — a hung shell icon handler in IconForEntry, say — it never pumps, and the mod becomes undisableable until Explorer is restarted. Nothing obvious to do about it; just worth knowing it's the failure mode.
  • IsPinnedTaskbarFolder() runs a full FolderStore::Read() plus an O(n) MatchKey comparison on the Explorer UI thread before every folder right-click. For filesystem paths MatchKey is just a string trim, so it's cheap; for shell: entries it's a SHParseDisplayName per stored entry, per right-click. Caching the resolved match key on the Entry would make that constant.
  • Folders get "Copy as shortcut" but not Move/Copy (showMoveCopy = !isDir && n > 0). If that's deliberate (avoiding an accidental move of a whole tree from a context menu), fine — it just isn't mentioned in the README, which reads as if the restriction is about files vs. shortcuts.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 4, 2026
Drop FolderStore::MigrateLegacy(). It ran blocking file I/O (GetDirFileId
opens the path with no IsLikelyRemotePath guard) and SHParseDisplayName on
Explorer's main thread during Wh_ModInit, and had nothing to import: no
released build ever wrote pinned[]/hidden[], and folders[%d].path is no
longer declared in the settings schema. Removes the README paragraph about
automatic import.

Give the popup and menu-owner classes the same stale-registration recovery
the manager window already had, by hoisting it into RegisterModClass().
EnsurePopupClasses() no longer latches a failure permanently, so a class
left behind by a previous load can no longer disable every hover grid for
the rest of the Explorer session.

Switch to BOOL Wh_ModSettingsChanged(BOOL* bReload) and request a reload
when the explorerMenu setting no longer matches what Wh_ModInit installed,
so turning the Explorer right-click menu on or off takes effect on save
instead of requiring a manual toggle of the mod.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Kiploom

Kiploom commented Aug 4, 2026

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

This pull request has already had 3 AI reviews in the last 24 hours, which is the limit, so no review was posted this time.

Comment /ai-review again after 2026-08-05 03:24 UTC (in 20 hours) to get another one.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 4, 2026
Was appended at the bottom of the shell context menu; now inserted
right after "Pin to taskbar" (or "Give access to" as a fallback when
Explorer doesn't offer a pin item) so it reads as a related action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@DanRotaru

Copy link
Copy Markdown
Contributor

Cool mod, the only thing is that there is no possibility to set it as tray icons, not taskbar items, for me it would be more useful.

@Kiploom

Kiploom commented Aug 6, 2026

Copy link
Copy Markdown
Author

@DanRotaru There is already a mod that does that, though it doesn't have some of the features this one has, such as being able to simply hover over the icons rather than clicking to open up the folder. That mod is called 'taskbar-folder-menus'.

Add a noise floor to skip retargeting on sub-pixel target changes and
add opt-in ANIM debug logging to trace window/anchor/margin/slide
events, plus a fast-path flag to skip the open/close sync delay when
there is no icon animation to sync with. The slide is closer but not
perfect - taskbar icon animation timing is not fully controllable
from outside explorer.exe.
@Kiploom

Kiploom commented Aug 6, 2026

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 6, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

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

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

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


Three things to fix. The teardown/threading work in this revision is genuinely careful — the reload serialization through WM_APP_RELOAD_UI, the MsgWaitForMultipleObjects(..., QS_SENDMESSAGE) joins, and the [[clang::no_destroy]] std::optional<std::thread> globals are all correct — so the findings below are narrow.

1. An open Explorer/Desktop context menu is not accounted for at unload, and takes Explorer down with it.

TrackPopupMenuExHook blocks inside TrackPopupMenuEx_orig for as long as the classic menu is up (line 8717), and for that whole time it also has a WH_CBT hook installed whose hook proc lives in this DLL (line 8697). Wh_ModUninit waits out the mod's own menu — g_menuActive, raised only in ShowItemContextMenu — but nothing tracks the hooked one. So if a "Show more options" menu is open when the mod is disabled, updated, or reloaded, Windhawk FreeLibrarys the image while that stack frame is live and the CBT hook is still registered; dismissing the menu returns into unmapped memory. This is not purely hypothetical for a reload, because Wh_ModSettingsChanged deliberately requests one whenever explorerMenu changes.

The machinery is already there — extend it to cover this menu too:

std::atomic<int> g_explorerMenuActive{0};
std::atomic<HWND> g_explorerMenuOwner{nullptr};  // the `hwnd` passed to the hook

Bump the counter and stash hwnd around the TrackPopupMenuEx_orig call, and in Wh_ModUninit run the same EndMenu() / WM_CANCELMODE + wait loop you already use at lines 8947-8962, driven onto that window's thread via RunFromWindowThread (EndMenu only cancels the calling thread's menu). Make sure UnhookWindowsHookEx(cbtHook) has run by then as well — scoping it in an RAII guard would also cover the case where TrackPopupMenuEx_orig unwinds unexpectedly.

2. The icon setting will fetch a remote URL, so the mod is not self-contained.

MakeButtonContent line 4943:

if (SUCCEEDED(UrlCreateFromPathW(icon.c_str(), url, &urlLen, 0))) {
    ...
    bitmap.UriSource(winrt::Windows::Foundation::Uri(winrt::hstring(url)));

UrlCreateFromPathW returns S_FALSE — which is SUCCEEDED — when the input is already a URL, and copies it through unchanged. So an Icon value of https://example.com/x.png passes IconSettingIsFile (it contains /), passes the .png extension test, is not caught by IsLikelyRemotePath, and ends up as a BitmapImage.UriSource that XAML downloads from inside explorer.exe. Windhawk mods have to be self-contained — no contacting external servers.

It is also not strictly self-inflicted: ReadFolderCustomIcon lifts IconResource straight out of a folder's desktop.ini (line 691) and stores it as the icon when the folder is pinned from Explorer, so a folder someone else authored can seed the value.

Fix by requiring a real conversion and rejecting anything that is already a URL:

if (UrlCreateFromPathW(icon.c_str(), url, &urlLen, 0) == S_OK) {

3. manageFolders is a boolean setting that is not a setting.

It is a button dressed as a toggle: it fires on the off→on edge, does nothing when switched off, needs a shadow manageFoldersPrev value in storage to detect the edge, and its displayed position is meaningless afterwards — the $description has to spend four lines apologising for that. There is also an edge case: when explorerMenu changed in the same save, reload is true, so the manager never opens even though manageFoldersPrev is written as if it had (line 8889), and the user has to toggle off/save/on/save to get it.

The only reason it exists is that a fresh install has no button to right-click. Injecting a single placeholder button when the store is empty — an "Add a folder" affordance that opens the manager on click — removes the need for the pseudo-setting entirely and gives new users something visible instead of a taskbar that looks unchanged. Then manageFolders and manageFoldersPrev can both go.

Optional improvements

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

  • FolderManager::g_fontCache never frees its HFONTs (line 5787). UiFont creates one HFONT per DPI with CreateFontIndirectW and nothing deletes them, so every mod unload leaks a couple of GDI handles into explorer.exe permanently. Add a teardown that DeleteObjects the created fonts (skipping the GetStockObject fallback) and clears the vector, called from CloseAndWait or Wh_ModUninit.

    While there, the [[clang::no_destroy]] on this one is unnecessary and worth dropping: a std::vector<std::pair<int, HFONT>> has a heap-free-only destructor, which is safe at process shutdown. Same for FolderStore::g_mutex (line 739) and FolderManager::g_threadMutex (line 6932) — std::mutex has a no-op destructor on Windows. The attribute is correct and load-bearing on the four that need it (g_scanThread, g_thread, g_taskbarHosts, g_buttonMenuFlyout), and unneeded suppressions elsewhere just invite cargo-culting. See Global objects and process shutdown.

  • Use WindhawkUtils::SetFunctionHook instead of raw Wh_SetFunctionHook with void* casts (line 8802). You already have the correct type via decltype(&TrackPopupMenuEx), so it's a one-line change that gets compile-time signature checking:

    WindhawkUtils::SetFunctionHook(TrackPopupMenuEx,
                                   AddToTaskbar::TrackPopupMenuExHook,
                                   &AddToTaskbar::TrackPopupMenuEx_orig);
  • Consider whether the [ANIM] tracing ships. ANIM_TRACE, CensusAppStrip, LogRepeaterPositions and CheckHostGridHealth are roughly 250 lines of positioning diagnostics plus a user-facing "9. Debug ▸ Position trace logging" setting whose output only means something to you. It is all correctly gated behind the setting so it costs nothing at runtime, but it's a lot of scaffolding in a catalog mod. If the slide work is still ongoing, keeping it is reasonable — just worth a deliberate decision rather than defaulting to shipped.

  • RefreshThemeCache on every WM_SETTINGCHANGE, in every popup window (line 4141). Top-level windows all receive the broadcast, so one SPI_SET* change constructs a WinRT UISettings and probes a Gdiplus::FontFamily once per live level window (up to 16). Also, g_themeCache is a plain struct written by RefreshThemeCache from both the taskbar UI thread and Explorer window threads (g_menuDark = IsDarkTheme(), line 8696) with no synchronization. Neither is going to bite in practice, but a dirty flag plus one refresh on the owner window would be tidier.

  • FindMenuItemPositionByLabel(hMenu, L"Pin to taskbar") is English-only (line 8654). On a localized Windows neither label matches, so the submenu always falls back to the bottom of the menu — graceful, but the "reads as a related action" placement silently never happens outside English. Matching the shell's command id, or a short comment documenting the limitation, would set expectations.

  • Unlocked reads of g_settings.folders. InjectHostGridForTaskbar (line 7972) and InjectHostGridsOnTaskbarThread (lines 8070, 8123) read .empty() / .size() without g_foldersMutex, while LoadFolders clears and refills that vector under it. In practice both sides are the taskbar UI thread because reloads are serialized through RequestReloadUI, so this only opens up on the inline-reload fallback path — but the accesses are cheap to bring under the lock and it removes the reasoning burden.

  • // ponytail: on lines 656 and 8485 reads like an internal TODO marker rather than a comment for readers.

  • Typo: L"Uninidt: RunFromWindowThread failed..." at line 8978.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The 5-second cache TTL is an expensive refresh trigger. GetFolderDataAndRefresh re-queues a full scan whenever the entry is older than 5 s (line 2836), and a scan re-extracts every icon in the folder — up to MaxCachedItemsPerFolder() (192) shell icon extractions plus 192 GDI+ rescales. Hovering the same button on and off for a minute therefore does that a dozen times for a folder whose contents never changed. SHChangeNotifyRegister (or ReadDirectoryChangesW) on the cached folders would invalidate only on real changes and let the TTL go away; the scan worker already has a message loop to receive the notifications on.

  • The icon cache footprint is large for a shell mod. kMaxCachedFolderBytes is 64 MB of resident bitmaps in explorer.exe, and the defaults get you a good way there: 32 folders × 192 items × a 32 px (4 KB) bitmap is ~24 MB, and at 150 % scaling the same setup is closer to 55 MB. It is documented in the README and bounded, which is the important part — but it may be worth a smaller default ceiling, since the on-screen working set at any moment is one or two grids.

  • PrefetchSubfolders fans out 12 scans per level opened (line 4040), and each of those can be a 192-item icon extraction. Cascading three levels deep through folder-heavy trees queues a lot of work the user will mostly never see. Prefetching the 2-3 cells nearest the cursor, or only on submenu-dwell, would get most of the "instant" feel for a fraction of the work.

  • GetAsyncKeyState(VK_LBUTTON) freezes positioning for any left-button hold (line 7853), not just a taskbar-button drag. Drag-selecting in Explorer, dragging a window, or holding the button on a scrollbar all freeze the overlay, so if a window opens or closes during that hold the folder buttons stay parked over the app icons until release. Gating on WindowFromPoint(cursor) resolving to the taskbar (or on the drag having started over the strip) would scope it to the case you actually meant.

  • OnRootGridLayoutUpdated does real work on every layout pass. Each pass reads ActualWidth, GetChildrenCount, anchor.Parent(), Margin() and a TransformToVisual, and every 250 ms (or on any child-count change) ResolveAnchor walks all realized repeater children calling winrt::get_class_name on each — which is a GetRuntimeClassName + HSTRING allocation per child. Given that carving the gap requires tracking the anchor per-frame there is no obviously better place to put this, so this is an FYI rather than a request: caching the class name per container object would cut the hot part if the taskbar thread ever shows up in a profile.

  • The manager window is the mod's largest stability surface. A dedicated thread running a Win32 message loop, a nested modal loop for the edit dialog, MessageBoxW, and IFileDialog — all inside explorer.exe, and all of it has to be unwound before Wh_ModUninit can return (hence the EnumThreadWindows + WM_CLOSE + unbounded wait in CloseAndWait). The tool-mod pattern exists for exactly this shape of code, but it can't apply here: the taskbar buttons genuinely need injection into explorer.exe, and a single mod has one @include set, so the UI can't be split into its own process without splitting the mod. Noting it for the record rather than asking for a change — the CloseAndWait handling looks sound, including the re-post loop for modals that appear after the first round.

  • An explicit columns value can be silently overridden. ComputeLevelLayout honours g_settings.columns at line 3172, then the height-fit loop at line 3201 increments cols past it to keep rows within maxRows. So "Grid columns: 4" on a tall folder quietly becomes 6 or 8. Reasonable behaviour, but it isn't what the setting description promises.

  • Right-clicking a folder's background offers nothing. GetSelectedPath requires exactly one selected item (line 8468), so you can pin a folder you can see but not the folder you are currently inside — which is often the one you want. SVGIO_BACKGROUND (or IShellBrowser's current folder) would cover that case.

  • Wh_ModInit does per-entry file I/O on Explorer's main thread. BuildFolderEntry calls GetFileAttributesW and GetDriveTypeW for every stored entry, and FindRenamedSibling opens every sibling directory with CreateFileW when a folder has gone missing (lines 883-909). It is guarded against remote paths and bounded, and a rename recovery is a rare path — but it does land in Explorer's startup, so deferring the recovery to the scan worker (and letting the first hover pick up the corrected path) would keep Wh_ModInit to pure string work as LoadFolders' own comment intends.

  • Catalog overlap with taskbar-folder-menus — acknowledged in the PR description and documented in the README with a comparison table, so not re-raised here. That call is the human maintainer's.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 6, 2026
- Track an open Explorer/Desktop context menu. TrackPopupMenuExHook
  blocks in the original for as long as the classic menu is up, keeping
  a frame of this DLL live, so Wh_ModUninit now counts every path
  through the hook, records the owner window, and cancels the menu via
  EndMenu/WM_CANCELMODE driven onto that window's thread before
  teardown. The WH_CBT hook installed for menu theming is now scoped in
  an RAII guard so it also comes out if the original unwinds.
- Do not let an icon setting fetch a remote URL. UrlCreateFromPathW
  returns S_FALSE and passes the input through unchanged when it is
  already a URL, so SUCCEEDED() let an http(s) value reach
  BitmapImage.UriSource. Require S_OK. The value is not always the
  user's own - it can come from a folder's desktop.ini IconResource.
- Drop the manageFolders pseudo-setting. A single "add a folder"
  placeholder button is injected whenever no folder button exists, so
  there is always a route to the Taskbar Folders window, and the
  edge-triggered boolean and its shadow manageFoldersPrev value are
  both gone.
@Kiploom

Kiploom commented Aug 6, 2026

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 6, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

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

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

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


All three items from the previous round are properly fixed — the S_OK-only UrlCreateFromPathW check, the RAII-scoped CBT hook, and the placeholder button that retires the manageFolders pseudo-setting. Two remaining findings, both on the unload path.

1. FolderManager::Open() can start the manager thread after Wh_ModUninit has already waited for it.

The g_unloading check at line 6939 is outside g_threadMutex, and CloseAndWait (line 6967) takes that mutex, swaps the slot out and returns. So this interleaving is open:

  • taskbar UI thread: a /Manage folders... click calls Open(), passes g_unloading (still false), sets g_active, then gets preempted before lock(g_threadMutex);
  • Windhawk thread: Wh_ModUninit sets g_unloading, CloseAndWait locks, finds no thread, returns, and teardown continues;
  • taskbar UI thread: locks, g_thread.emplace(ThreadMain, anchor) — a fresh message loop starts in a DLL that Windhawk FreeLibrarys moments later.

Every other worker in the mod already closes this by testing g_unloading under the same lock its stopper takesStartScanThread at lines 2833-2836 and StartRetryThread at lines 8279-8283. Open() just needs the same:

    try {
        std::lock_guard<std::mutex> lock(g_threadMutex);
        // Re-checked under the lock: CloseAndWait takes this mutex after
        // g_unloading is set, so either it sees our thread and joins it, or
        // we see the flag and never start one.
        if (g_unloading) {
            g_active = false;
            return;
        }
        if (g_thread) { ... }
        g_thread.emplace(ThreadMain, anchor);
    }

2. The uninit wait for g_explorerMenuActive gives up after 2 s, but the frame it is guarding can live for minutes.

MenuActiveGuard (line 8619) spans the whole of TrackPopupMenuExHook, which is right — the return address is in this DLL for the entire call. But that call does not end when the menu does: MoveOrCopySHFileOperationW (line 8835) and CreateShortcutIn (line 8832) run after TrackPopupMenuEx_orig has returned, and a move or copy of a large folder is a multi-minute operation. EndMenu cannot shorten it, so the loop at lines 9037-9050 burns its 100 × 20 ms, logs still active after wait, and teardown proceeds — the image is unmapped while SHFileOperationW is still on the stack of a frame inside it, and the copy returns into unmapped memory. The same applies to g_invokeActive (line 9059) for a shell verb that shows modal UI.

Wh_ModUninit runs on a Windhawk engine thread, not on any Explorer UI thread, so blocking there does not freeze the shell — which is exactly the reasoning FolderManager::CloseAndWait already spells out for its deliberately unbounded wait (lines 7010-7016). Apply it here too: keep the bounded EndMenu/WM_CANCELMODE attempts (they are what dismisses an actual menu), then keep waiting until the counter reaches zero instead of falling through, logging every few seconds the way CloseAndWait does.

While in there, one detail of the tracking: g_explorerMenuOwner is overwritten on every entry and only cleared on the 1→0 transition, so if two Explorer windows (separate threads) ever have menus counted at once and the second finishes first, the owner is left pointing at the finished one and the surviving menu never gets its EndMenu. Uncommon — a popup menu grabs input, so two live classic menus is hard to reach — but a small per-owner map, or simply not overwriting a non-null owner, removes the case.

Optional improvements

Minor polish — none of this affects users, so it's your call. A few of these are carried over from the last round; no objection if you're deliberately leaving them.

  • WindhawkUtils::SetFunctionHook instead of raw Wh_SetFunctionHook with void* casts (line 8878). You already have the type via decltype(&TrackPopupMenuEx), so it's a one-line change that buys compile-time signature checking:

    WindhawkUtils::SetFunctionHook(TrackPopupMenuEx,
                                   AddToTaskbar::TrackPopupMenuExHook,
                                   &AddToTaskbar::TrackPopupMenuEx_orig);
  • FolderManager::g_fontCache still never frees its HFONTs (line 5786). UiFont creates one per DPI with CreateFontIndirectW and nothing deletes them, so each mod unload leaves a couple of GDI handles behind in explorer.exe permanently. A teardown that DeleteObjects the created fonts (skipping the GetStockObject fallback) and clears the vector, called from CloseAndWait, covers it. The [[clang::no_destroy]] on this one is also unnecessary and worth dropping — a std::vector<std::pair<int, HFONT>> has a heap-free-only destructor, which is safe at process shutdown, and an unneeded suppression is noise. See Global objects and process shutdown. (The four that do need it — g_scanThread, g_thread, g_taskbarHosts, g_buttonMenuFlyout — are correct.)

  • A shell: destination that never resolved is passed straight to the shell APIs. At line 8829, destDir falls back to folders[idx].path when resolvedPath is empty — which for a shell: entry means literally shell:Downloads reaching SHFileOperationW / CreateShortcutIn. It fails rather than doing anything harmful, but the user just gets an opaque shell error. Skipping (or greying) entries with an empty resolvedPath when building the destination submenus would be clearer.

  • CreateShortcutIn failures are silent (line 8832) — the return value is discarded and there is no Wh_Log on the failure paths, so a read-only destination looks like the menu item did nothing. MoveOrCopy already lets the shell report its own errors; a log line here would at least make it diagnosable.

  • manageFoldersPrev is now a dead storage value. Anyone upgrading from 1.27 keeps it forever. A one-off Wh_DeleteValue(L"manageFoldersPrev") in Wh_ModInit tidies it up.

  • Unlocked reads of g_settings.folders. InjectHostGridsOnTaskbarThread line 8157 reads .size() without g_foldersMutex while LoadFolders clears and refills that vector under it. Reloads are serialized onto the taskbar UI thread through RequestReloadUI, so this only opens on the inline-reload fallback, and the follow-up FolderPathForButton is bounds-checked under the lock — but bringing the loop bound under the lock costs nothing and removes the reasoning burden.

  • FindMenuItemPositionByLabel(hMenu, L"Pin to taskbar") is English-only (line 8716). On a localized Windows neither label matches, so the submenu always lands at the bottom of the menu — graceful, but the "reads as a related action" placement silently never happens outside English. A short comment documenting the limitation would set expectations.

  • RefreshThemeCache on every WM_SETTINGCHANGE, in every popup window (line 4133). Top-level windows all get the broadcast, so one SPI_SET* change constructs a WinRT UISettings and probes a Gdiplus::FontFamily once per live level window. g_themeCache is also a plain struct written from both the taskbar UI thread and Explorer window threads (g_menuDark = IsDarkTheme(), line 8762) with no synchronization. Neither bites in practice; a dirty flag plus one refresh on the owner window would be tidier.

  • // ponytail: on lines 646 and 8519 reads like an internal TODO marker rather than a comment for readers, and there's still a typo at line 9070: L"Uninidt: RunFromWindowThread failed...".

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • FOF_RENAMEONCOLLISION makes Move/Copy silently duplicate (line 8542). Dropping a file into a folder that already has one produces name (2).ext with no prompt, where Explorer's own move/copy would ask "Replace or Skip". FOF_ALLOWUNDO makes it recoverable, but the behaviour differs from the command sitting right above it in the same menu. Leaving the flag off gets the standard shell conflict dialog.

  • The placeholder cannot be dismissed. With nothing pinned there is always exactly one folder button carving a gap in the app strip, and unpinning your last folder brings it back. That is the point — it is the only route to the manager — but it does mean the mod can never be "installed and invisible", which the settings toggle used to allow. Worth a line in the README's Details, or a way to hide it once the user knows where the manager is.

  • The 5-second cache TTL is an expensive refresh trigger. GetFolderDataAndRefresh re-queues a full scan whenever the entry is older than 5 s (line 2826), and a scan re-extracts every icon in the folder — up to MaxCachedItemsPerFolder() (192) shell extractions plus 192 GDI+ rescales. Hovering the same button on and off for a minute does that a dozen times for a folder whose contents never changed. SHChangeNotifyRegister (or ReadDirectoryChangesW) on the cached folders would invalidate only on real changes and let the TTL go; the scan worker already has a message loop to receive the notifications on.

  • PrefetchSubfolders fans out 12 scans per level opened (line 4030), each of which can be a 192-item icon extraction. Cascading three levels deep through folder-heavy trees queues a lot of work the user will mostly never see. Prefetching the 2-3 cells nearest the cursor, or only on submenu-dwell, would get most of the "instant" feel for a fraction of the work.

  • GetAsyncKeyState(VK_LBUTTON) freezes positioning for any left-button hold (line 8889), not just a taskbar-button drag. Drag-selecting in Explorer, dragging a window, or holding the button on a scrollbar all freeze the overlay, so if a window opens or closes during that hold the folder buttons stay parked over the app icons until release. Gating on WindowFromPoint(cursor) resolving to the taskbar (or on the drag having started over the strip) would scope it to the case you meant.

  • An explicit columns value can be silently overridden. ComputeLevelLayout honours g_settings.columns at line 3161, then the height-fit loop at line 3191 increments cols past it to keep rows within maxRows. So "Grid columns: 4" on a tall folder quietly becomes 6 or 8. Reasonable behaviour, but not what the setting description promises.

  • Right-clicking a folder's background offers nothing. GetSelectedPath requires exactly one selected item (line 8502), so you can pin a folder you can see but not the folder you are currently inside — often the one you want. SVGIO_BACKGROUND (or IShellBrowser's current folder) would cover that.

  • The manager window remains the mod's largest stability surface — a dedicated thread with a Win32 message loop, a nested modal loop for the edit dialog, MessageBoxW and IFileDialog, all inside explorer.exe and all of it unwound before Wh_ModUninit can return. The tool-mod pattern exists for this shape of code but can't apply here: the taskbar buttons genuinely need injection into explorer.exe, and one mod has one @include set. Noting it for the record — the CloseAndWait handling looks sound, including the re-post loop for modals that appear after the first round.

  • Catalog overlap with taskbar-folder-menus — acknowledged in the PR description and documented in the README with a comparison table, so not re-raised here. That call is the human maintainer's.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 6, 2026
- FolderManager::Open re-checks g_unloading under g_threadMutex, the way
  StartScanThread and StartRetryThread already do. The check above the
  lock alone lost the race where CloseAndWait swaps out an empty slot in
  between, leaving a fresh manager message loop running in an image
  about to be unmapped.
- Wh_ModUninit no longer gives up on a live frame after two seconds. The
  TrackPopupMenuExHook frame outlives the menu it opened - a chosen
  Move/Copy runs SHFileOperationW afterwards, which can take minutes,
  and EndMenu cannot shorten it - so the waits for the Explorer menu,
  the mod's own shell context menu and a deferred shell verb now keep
  waiting until they clear, attempting cancellation for the first two
  seconds and logging every five. Uninit runs on a Windhawk engine
  thread, so this does not freeze the shell; the same reasoning
  FolderManager::CloseAndWait already documents.
- Track one owner window per live hook call instead of a count plus a
  single overwritten HWND, so two menus on separate threads cannot leave
  the survivor with no window to drive EndMenu onto.
@Kiploom

Kiploom commented Aug 6, 2026

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 6, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

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

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

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


Both items from the last round are properly fixed — FolderManager::Open() now re-checks g_unloading under g_threadMutex, and the uninit waits are unbounded with a per-owner list driving EndMenu. The waitOut helper reads well and the two-second cancellation cap is the right call for the SHFileOperationW case, which nothing can shorten. One finding left, and it is a small one.

The Explorer submenu is injected before the TPM_RETURNCMD check, so it can be added to a menu whose result the mod never sees.

TrackPopupMenuExHook builds and inserts foldersMenu at lines 8712-8771 unconditionally, and only tests the flag afterwards, at line 8826:

if (!(flags & TPM_RETURNCMD) || result < kMinId) {
    return result;
}

CDefView does pass TPM_RETURNCMD, so the normal path is fine. But the hook is process-wide, and the only gate before injection is FindShellViewWindow(hwnd) walking the parent chain — any other TrackPopupMenuEx caller in explorer.exe whose window happens to sit under a SHELLDLL_DefView (a shell extension showing its own menu, a future shell build, another mod) gets the items too. Without TPM_RETURNCMD the menu posts WM_COMMAND with id 0xC901+ to hwnd instead of returning it: the mod's action silently never runs, and the id is handed to a window proc that may well map an unrecognised command into an IContextMenu verb offset.

One line, right at the top of the injection block:

    HWND defView = (flags & TPM_RETURNCMD) && g_settings.explorerMenu
                       ? FindShellViewWindow(hwnd)
                       : nullptr;

(For reference, explorer-context-menu-custom-items has the same shape, so this is a hardening fix rather than a bug anyone has hit — but it costs nothing.)

Optional improvements

Minor polish — none of this affects users, so it's your call. Several of these are carried over from earlier rounds; no objection if you're deliberately leaving them.

  • FolderManager::g_fontCache still never frees its HFONTs (line 5786). UiFont creates one per DPI with CreateFontIndirectW and nothing deletes them, so each mod unload leaves a couple of GDI handles behind in explorer.exe permanently. A teardown that DeleteObjects the created fonts (skipping the GetStockObject fallback) and clears the vector, called from CloseAndWait, covers it.

  • Three unnecessary [[clang::no_destroy]] suppressions, two of them new in 1.29: g_explorerMenuMutex and g_explorerMenuOwners (lines 8625-8626) and g_fontCache (line 5786). A std::mutex has a no-op destructor on Windows and a std::vector<HWND> / std::vector<std::pair<int, HFONT>> destructor is a heap free — both are safe at process shutdown, so the attribute buys nothing and an unneeded suppression invites cargo-culting. Same for FolderStore::g_mutex (line 729) and FolderManager::g_threadMutex (line 6931). The four that do need it — g_scanThread, FolderManager::g_thread, g_taskbarHosts, g_buttonMenuFlyout — plus the std::optional wrappers on g_levels / g_folderCache are all correct. See Global objects and process shutdown.

  • WindhawkUtils::SetFunctionHook instead of raw Wh_SetFunctionHook with void* casts (line 8905). You already have the type via decltype(&TrackPopupMenuEx), so it's a one-line change that buys compile-time signature checking:

    WindhawkUtils::SetFunctionHook(TrackPopupMenuEx,
                                   AddToTaskbar::TrackPopupMenuExHook,
                                   &AddToTaskbar::TrackPopupMenuEx_orig);
  • FolderManager::EnsureClass re-registers its class on every open (line 5746). Unlike EnsurePopupClasses, it caches nothing, so RegisterClassExW fails with ERROR_CLASS_ALREADY_EXISTS the second time the manager (or the edit dialog, line 6316) is opened in one session, and RegisterModClass unregisters and re-registers. It works, but it logs replaced a stale '%s' class from a previous load every time — which is untrue and misleading in the log — and it turns a benign reopen into a path that can fail outright if any window of that class somehow still exists. A static bool per class, the way EnsurePopupClasses does it, avoids both.

  • EditContext* in GWLP_USERDATA can outlive the object it points at. RunEditDialog stores a pointer to a caller-stack EditContext (line 6117) and, on the GetMessageW returning 0 path (lines 6357-6359), breaks out without destroying the window — so if the thread later exits with that window still alive, USER32 destroys it and EditWndProc's WM_DESTROY reads freed stack. CloseAndWait always posts WM_CLOSE to the child windows before the main one, so this is close to unreachable in practice; a DestroyWindow(hWnd); before the break closes it anyway.

  • A shell: destination that never resolved is passed straight to the shell APIs. At line 8856 destDir falls back to folders[idx].path when resolvedPath is empty — for a shell: entry that means literally shell:Downloads reaching SHFileOperationW / CreateShortcutIn. It fails rather than doing anything harmful, but the user gets an opaque shell error. Skipping (or greying) entries with an empty resolvedPath when building the destination submenus would be clearer. CreateShortcutIn failures are also silent (line 8859) — the return value is discarded with no Wh_Log, so a read-only destination looks like the menu item did nothing.

  • Unlocked read of g_settings.folders. InjectHostGridsOnTaskbarThread line 8169 reads .size() without g_foldersMutex while LoadFolders clears and refills that vector under it. Reloads are serialized onto the taskbar UI thread through RequestReloadUI, so this only opens on the inline-reload fallback, and FolderPathForButton is bounds-checked under the lock — but bringing the loop bound under the lock costs nothing and removes the reasoning burden.

  • FindMenuItemPositionByLabel(hMenu, L"Pin to taskbar") is English-only (line 8743). On a localized Windows neither label matches, so the submenu always lands at the bottom of the menu — graceful, but the "reads as a related action" placement silently never happens outside English. A short comment documenting the limitation would set expectations.

  • RefreshThemeCache runs on every WM_SETTINGCHANGE, in every popup window (line 4133). Top-level windows all get the broadcast, so one SPI_SET* change constructs a WinRT UISettings and probes a Gdiplus::FontFamily once per live level window. g_themeCache is also a plain struct written from both the taskbar UI thread and Explorer window threads (g_menuDark = IsDarkTheme(), line 8789) with no synchronization. Neither bites in practice; a dirty flag plus one refresh on the owner window would be tidier.

  • manageFoldersPrev is dead storage for anyone upgrading from 1.27 — a one-off Wh_DeleteValue(L"manageFoldersPrev") in Wh_ModInit tidies it up.

  • // ponytail: on lines 646 and 8531 still reads like an internal TODO marker rather than a comment for readers, and the typo at line 9114 is still there: L"Uninidt: RunFromWindowThread failed...".

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • FOF_RENAMEONCOLLISION makes Move/Copy silently duplicate (line 8555). Dropping a file into a folder that already has one produces name (2).ext with no prompt, where Explorer's own move/copy would ask "Replace or Skip". FOF_ALLOWUNDO makes it recoverable, but the behaviour differs from the command sitting right above it in the same menu. Leaving the flag off gets the standard shell conflict dialog.

  • The 5-second cache TTL is an expensive refresh trigger. GetFolderDataAndRefresh re-queues a full scan whenever the entry is older than 5 s (line 2826), and a scan re-extracts every icon in the folder — up to MaxCachedItemsPerFolder() (192) shell extractions plus 192 GDI+ rescales. Hovering the same button on and off for a minute does that a dozen times for a folder whose contents never changed. SHChangeNotifyRegister (or ReadDirectoryChangesW) on the cached folders would invalidate only on real changes and let the TTL go; the scan worker already has a message loop to receive the notifications on.

  • PrefetchSubfolders fans out 12 scans per level opened (line 4026), each of which can be a 192-item icon extraction. Cascading three levels deep through folder-heavy trees queues a lot of work the user will mostly never see. Prefetching the 2-3 cells nearest the cursor, or only on submenu-dwell, would get most of the "instant" feel for a fraction of the work.

  • GetAsyncKeyState(VK_LBUTTON) freezes positioning for any left-button hold (line 7901), not just a taskbar-button drag. Drag-selecting in Explorer, dragging a window, or holding the button on a scrollbar all freeze the overlay, so if a window opens or closes during that hold the folder buttons stay parked over the app icons until release. Gating on WindowFromPoint(cursor) resolving to the taskbar (or on the drag having started over the strip) would scope it to the case you meant.

  • The rename recovery walks the parent directory on the taskbar UI thread, not just at init. BuildFolderEntry calls FindRenamedSibling (line 648) whenever a stored folder is missing on disk, and that opens every sibling directory with CreateFileW to compare file ids. LoadFolders runs from ReloadAndRefreshUI as well as Wh_ModInit, so a folder that was genuinely deleted re-walks its parent on every settings change, pin and unpin, on the taskbar thread — and since nothing is written back on a miss, it never stops. Caching the "no match" result per path, or deferring the recovery to the scan worker, would keep the reload path to pure string work.

  • An explicit columns value can be silently overridden, in both directions. ComputeLevelLayout honours g_settings.columns at line 3161, then the height-fit loop at line 3191 increments cols past it to keep rows within maxRows. Separately, a small columns shrinks MaxCachedItemsPerFolder() (line 2169) below maxItemscolumns: 2 caps the grid at 32 items even with maxItems: 60. Both are reasonable behaviours, just not what the setting descriptions promise.

  • Right-clicking a folder's background offers nothing. GetSelectedPath requires exactly one selected item (line 8489), so you can pin a folder you can see but not the folder you are currently inside — often the one you want. SVGIO_BACKGROUND (or IShellBrowser's current folder) would cover that.

  • The placeholder cannot be dismissed. With nothing pinned there is always exactly one folder button carving a gap in the app strip, and unpinning your last folder brings it back. That is the point — it is the only route to the manager — but it does mean the mod can never be "installed and invisible". Worth a line in the README's Details.

  • Consider whether the [ANIM] tracing ships. ANIM_TRACE, CensusAppStrip, LogRepeaterPositions and CheckHostGridHealth are roughly 250 lines of positioning diagnostics plus a user-facing "9. Debug ▸ Position trace logging" setting whose output only means something to you. It is correctly gated behind the setting so it costs nothing at runtime, but it's a lot of scaffolding in a catalog mod. If the slide work is still ongoing, keeping it is reasonable — just worth a deliberate decision.

  • The manager window remains the mod's largest stability surface — a dedicated thread with a Win32 message loop, a nested modal loop for the edit dialog, MessageBoxW and IFileDialog, all inside explorer.exe and all of it unwound before Wh_ModUninit can return. The tool-mod pattern exists for this shape of code but can't apply here: the taskbar buttons genuinely need injection into explorer.exe, and one mod has one @include set. Noting it for the record — the CloseAndWait handling looks sound, including the re-post loop for modals that appear after the first round.

  • Catalog overlap with taskbar-folder-menus — acknowledged in the PR description and documented in the README with a comparison table, so not re-raised here. That call is the human maintainer's.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 6, 2026
…p position tracing

Require TPM_RETURNCMD before injecting the Taskbar Folders submenu, not
just before reading the result. Without the flag the menu posts
WM_COMMAND with the chosen id to the owner window instead of returning
it, so the mod's action would silently never run and an id in the
kMinId range would reach a window proc that may map an unrecognised
command onto an IContextMenu verb offset. CDefView passes the flag, but
the hook is process-wide and FindShellViewWindow only checks that the
window sits under a SHELLDLL_DefView, so any other caller that happens
to satisfy that is now left alone.

Also removes the position trace logging and its setting: the ANIM_TRACE
macro, the gap health check and repeater dump, and the debug-only
counters they fed on.
@Kiploom

Kiploom commented Aug 6, 2026

Copy link
Copy Markdown
Author

All recommended fixes were implemented and I removed the unnecessary logging I had going on so nothing else functional was changed in that last commit.

Overall there were three main big changes/fixes from last human review HERE:

  1. The sliding animation is smoother and folders shouldn't disappear from the taskbar when spamming opening/closing a window. It's still not perfectly smooth, but I plan to fix that in V2. I spent a good amount of time getting it this smooth so I think it's good enough for V1.
  2. The taskbar folders aren't stored in settings anymore as it was very limiting to store them there because I couldn't find a way to write to settings without editing the users registry (syncing whenever a folder was pinned via right clicking the folder was impossible without it), needing admin privileges, which shouldn't be necessary for a mod like this. My solution was to build my own built in taskbar manager that can be opened from settings or by right clicking on an already pinned folder. The manager also allows for rearranging the order of items easily and unpinning items without removing them completely (like an archive).
  3. Users now have the ability to change the size of the icons in the tray (small, medium, large), as well as the font, font size, and font weight of both the title and the labels. This was because the reviewer stated text was hard to see sometimes.

/ready-for-reviewer

@windhawk-reviewer

Copy link
Copy Markdown

@Kiploom /ready-for-reviewer can't be applied here: the most recent AI review covers 20e57a7, but the current head of this pull request is 4f9a5dd. Comment /ai-review to get a review of the current code.

@Kiploom

Kiploom commented Aug 6, 2026

Copy link
Copy Markdown
Author

Disregard previous comment. I just found a new bug that needs to be fixed real quick. I will ai review and try again tomorrow.

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

Labels

waiting-for-author The author's turn: request an AI review, or respond to one that was posted.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants