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
        }
}