Merc.psm1
|
function Invoke-Merc { <# .SYNOPSIS Interactively kill processes using fzf, with window titles for context. .DESCRIPTION Lists all processes with their start time, PID, name, and associated window titles. Uses fzf for fuzzy selection (multi-select with Tab). Selected processes are killed. With -rm, removes a file or directory. If any files are locked by running processes, displays process details (PID, name, window titles) and prompts to kill each one before deletion. Uses the Windows Restart Manager API to detect file locks. Requires fzf to be installed and available in PATH (interactive mode only). .PARAMETER IncludeJunk Include junk/system windows (Default IME, MSCTFIME UI, etc.) in the display. .EXAMPLE Invoke-Merc # Then type "teams" or "gluk" to filter, Tab to select multiple, Enter to kill .EXAMPLE Invoke-Merc -IncludeJunk # Include junk/system windows in the list .EXAMPLE merc -j # Using the alias with short parameter .PARAMETER Remove Path to a file or directory to remove. If any files under the path are locked by running processes, you will be prompted to kill them first. Supports absolute paths, relative paths, and ~ (home directory). .EXAMPLE merc -rm ".\node_modules" # Remove a directory, killing any processes that hold file locks .EXAMPLE merc -rm "~\Downloads\old-project" # Supports ~ for home directory .LINK https://github.com/junegunn/fzf #> [CmdletBinding(DefaultParameterSetName = 'Interactive')] [Alias('merc')] param( [Parameter(ParameterSetName = 'Interactive')] [Alias('j', 'Junk')] [switch]$IncludeJunk, [Parameter(ParameterSetName = 'Remove', Mandatory)] [Alias('rm')] [string]$Remove ) # Use Win32 API to enumerate all visible windows Add-Type -ErrorAction SilentlyContinue @" using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; public class MercWindowEnumerator { [DllImport("user32.dll")] private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll")] private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); [DllImport("user32.dll")] private static extern int GetWindowTextLength(IntPtr hWnd); [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr hWnd); [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); public static Dictionary<uint, List<string>> GetWindowsByProcess() { var result = new Dictionary<uint, List<string>>(); EnumWindows((hWnd, lParam) => { if (IsWindowVisible(hWnd)) { int length = GetWindowTextLength(hWnd); if (length > 0) { StringBuilder sb = new StringBuilder(length + 1); GetWindowText(hWnd, sb, sb.Capacity); string title = sb.ToString(); if (!string.IsNullOrWhiteSpace(title)) { uint pid; GetWindowThreadProcessId(hWnd, out pid); if (!result.ContainsKey(pid)) { result[pid] = new List<string>(); } result[pid].Add(title); } } } return true; }, IntPtr.Zero); return result; } } "@ # Restart Manager P/Invoke for finding processes that lock files Add-Type -ErrorAction SilentlyContinue @" using System; using System.Collections.Generic; using System.Runtime.InteropServices; public class MercFileLockResolver { [StructLayout(LayoutKind.Sequential)] struct FILETIME { public uint dwLowDateTime; public uint dwHighDateTime; } [StructLayout(LayoutKind.Sequential)] struct RM_UNIQUE_PROCESS { public uint dwProcessId; public FILETIME ProcessStartTime; } const int CCH_RM_MAX_APP_NAME = 255; const int CCH_RM_MAX_SVC_NAME = 63; [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] struct RM_PROCESS_INFO { public RM_UNIQUE_PROCESS Process; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CCH_RM_MAX_APP_NAME + 1)] public string strAppName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CCH_RM_MAX_SVC_NAME + 1)] public string strServiceShortName; public uint ApplicationType; public uint AppStatus; public uint TSSessionId; [MarshalAs(UnmanagedType.Bool)] public bool bRestartable; } [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] static extern int RmStartSession(out uint pSessionHandle, int dwSessionFlags, string strSessionKey); [DllImport("rstrtmgr.dll")] static extern int RmEndSession(uint pSessionHandle); [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, [In] RM_UNIQUE_PROCESS[] rgApplications, uint nServices, string[] rgsServiceNames); [DllImport("rstrtmgr.dll")] static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons); public static HashSet<uint> GetLockingProcessIds(string[] filePaths) { var pids = new HashSet<uint>(); const int batchSize = 256; for (int i = 0; i < filePaths.Length; i += batchSize) { int count = Math.Min(batchSize, filePaths.Length - i); string[] batch = new string[count]; Array.Copy(filePaths, i, batch, 0, count); uint sessionHandle; string sessionKey = Guid.NewGuid().ToString(); if (RmStartSession(out sessionHandle, 0, sessionKey) != 0) continue; try { if (RmRegisterResources(sessionHandle, (uint)count, batch, 0, null, 0, null) != 0) continue; uint needed = 0; uint infoCount = 0; uint reasons = 0; int res = RmGetList(sessionHandle, out needed, ref infoCount, null, ref reasons); if (res == 234 && needed > 0) { var info = new RM_PROCESS_INFO[needed]; infoCount = needed; if (RmGetList(sessionHandle, out needed, ref infoCount, info, ref reasons) == 0) { for (int j = 0; j < infoCount; j++) { pids.Add(info[j].Process.dwProcessId); } } } } finally { RmEndSession(sessionHandle); } } return pids; } } "@ # --- Remove mode: find locking processes, prompt, kill, delete --- if ($PSCmdlet.ParameterSetName -eq 'Remove') { # Resolve path (supports ~, relative, and absolute paths) try { $targetPath = (Resolve-Path $Remove -ErrorAction Stop).Path } catch { Write-Error "Path not found: $Remove" return } $isDir = Test-Path $targetPath -PathType Container # Enumerate all files under the target if ($isDir) { $files = @(Get-ChildItem $targetPath -Recurse -File -Force | Select-Object -ExpandProperty FullName) } else { $files = @($targetPath) } # Check for locking processes using Restart Manager $lockingPids = if ($files.Count -gt 0) { [MercFileLockResolver]::GetLockingProcessIds([string[]]$files) } else { [System.Collections.Generic.HashSet[uint32]]::new() } if ($lockingPids.Count -eq 0) { Remove-Item $targetPath -Recurse -Force Write-Host "Removed: $targetPath" -ForegroundColor Green return } # Enrich locking processes with Merc's window-title metadata $windowsByPid = [MercWindowEnumerator]::GetWindowsByProcess() Write-Host "" Write-Host " Blocked! These processes hold files under ${targetPath}:" -ForegroundColor Red Write-Host "" $processesToKill = [System.Collections.Generic.List[int]]::new() $skippedCount = 0 foreach ($pid in $lockingPids) { $proc = Get-Process -Id $pid -ErrorAction SilentlyContinue if (-not $proc) { continue } # Build display line matching merc's standard format $windows = "" if ($windowsByPid.ContainsKey([uint32]$pid)) { $titles = $windowsByPid[[uint32]$pid] | Select-Object -Unique $windows = $titles -join " | " if ($windows.Length -gt 60) { $windows = $windows.Substring(0, 57) + "..." } } $time = if ($null -ne $proc.StartTime) { $proc.StartTime.ToString("yyyy-MM-dd HH:mm:ss") } else { " " } Write-Host (" {0} {1,6} {2,-25} {3}" -f $time, $proc.Id, $proc.ProcessName, $windows) -ForegroundColor Yellow $answer = Read-Host " Kill $($proc.ProcessName) (PID $($proc.Id))? [y/N]" if ($answer -match '^[Yy]') { Write-Host " Killing $($proc.ProcessName) (PID $($proc.Id))..." -ForegroundColor DarkYellow Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue $processesToKill.Add($proc.Id) } else { $skippedCount++ } Write-Host "" } # Wait briefly for OS to release file handles if ($processesToKill.Count -gt 0) { Start-Sleep -Milliseconds 500 } if ($skippedCount -gt 0) { Write-Host " Skipped $skippedCount process(es). Attempting removal anyway..." -ForegroundColor DarkYellow } # Attempt removal try { Remove-Item $targetPath -Recurse -Force -ErrorAction Stop Write-Host " Removed: $targetPath" -ForegroundColor Green } catch { Write-Error "Failed to remove ${targetPath}: $_" } return } # --- Interactive mode --- # Get all windows grouped by PID $windowsByPid = [MercWindowEnumerator]::GetWindowsByProcess() # Junk window titles to filter out $junkTitles = @( 'Default IME', 'MSCTFIME UI', 'DesktopWindowXamlSource', 'Program Manager', 'Battery Meter' ) Get-Process | Where-Object { $null -ne $_.StartTime } | ForEach-Object { $proc = $_ $windows = "" # Look up windows for this PID if ($windowsByPid.ContainsKey([uint32]$proc.Id)) { $titles = $windowsByPid[[uint32]$proc.Id] | Where-Object { $_ -and ($IncludeJunk -or ($_ -notin $junkTitles)) } | Select-Object -Unique $windows = $titles -join " | " } # Truncate long window titles if ($windows.Length -gt 80) { $windows = $windows.Substring(0, 77) + "..." } [PSCustomObject]@{ StartTime = $proc.StartTime Id = $proc.Id ProcessName = $proc.ProcessName Windows = $windows } } | Sort-Object StartTime | ForEach-Object { $time = $_.StartTime.ToString("yyyy-MM-dd HH:mm:ss") $display = "{0} {1,6} {2,-25} {3}" -f $time, $_.Id, $_.ProcessName, $_.Windows # Embed the PID at the start with a delimiter for easy extraction "$($_.Id)`t$display" } | fzf -m --with-nth=2.. --delimiter="`t" --header="Select processes to kill (Tab=multi, Enter=confirm)" | ForEach-Object { $procId = $_.Split("`t")[0] $procName = (Get-Process -Id $procId -ErrorAction SilentlyContinue).ProcessName Write-Host "Killing $procName (PID $procId)..." -ForegroundColor Yellow Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue } } |