diff --git a/CLAUDE.md b/CLAUDE.md index 9a678a5..f1796b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) | `ImportExportService` | Read/write a full `AppState` to a JSON file (settings + sessions backup) | | `SessionConfigEditor` | Diffs/applies a `SessionConfigDraft` onto a `ShellSession`; decides whether the change needs a PTY restart | | `SidebarDropResolver` | WPF-free: maps a drop Y over the rendered sidebar rows to "before / after session X" (+ the group, in inline-headers mode) and the Y of the insertion line. See "Sidebar order and drag-and-drop" | +| `WindowPlacementMath` | WPF-free: which monitor saved window bounds belong on, whether the caption is still reachable, the fallback when not, and workspace↔screen conversion — all in physical pixels. See "DPI awareness and the WebView2 data folder" | | `SessionOrder` | WPF-free order helpers: id-relative moves, re-aligning the grid to the sidebar order, and where a new worktree sibling should be placed. See "Sidebar order and drag-and-drop" | | `DbGate` | Serializes every use of the shared `output.db` `SqliteConnection`. That one connection is handed to `SearchService` *and* to every `OutputIndexer`, and it is not thread-safe — concurrent create/dispose corrupts its internal command list. Acquire as `using var _ = await DbGate.AcquireAsync();` at the top of anything touching it. See issue #102 | | `PwshLocator` | Single answer to "pwsh or powershell?", shared by `RunInstance` (run commands) and `PseudoTerminal.BuildCmdLine` (session wrapper) so they can't disagree. Resolves via `where.exe`. An ordinary executable is accepted from metadata alone (no spawn). A **zero-byte reparse point is ambiguous, not bad**: that shape is a Store App Execution Alias, and a *working* Store install of PowerShell 7 looks identical to a stub left by an uninstalled app — so only that case is settled by actually probing execution. Rejecting the shape outright silently downgraded Store-PS7 users to 5.1 on every session launch | @@ -439,6 +440,34 @@ contains `PerMonitorV2`: removing `` from the csproj would warning is suppressed in the csproj deliberately — it recommends WinForms' `Application.SetHighDpiMode`, which a WPF app never calls. +**Window bounds are restored before the window is shown, in physical pixels** (#181, +`MainWindow.Placement.cs`). Restore used to set `Left/Top/Width/Height` from `OnLoaded` — +after the window was already up, maximized, on the primary — and clamp against +`SystemParameters.VirtualScreen*`, which is in the *primary's* DIPs. On a secondary monitor +of a different scale the move raised a DPI change mid-way, so the size drifted run to run. +Now the constructor reads only the window fields of `state.json` synchronously +(`StateService.TryReadWindowState`; the full load is still awaited in `OnLoaded`), and +`OnSourceInitialized` — HWND created, not yet shown — applies them with `SetWindowPlacement`. +If that lands on a monitor of a different DPI, WPF applies Windows' scaled suggested rect, so +the placement is **applied twice** when `GetDpiForWindow` changed: the second call is what +makes the saved size exact. `AppState.LastPlacement` is the normal rect in physical screen +pixels plus the DPI it was saved at. A **Normal** window saves `GetWindowRect`, not +`rcNormalPosition`: a snapped window reports Normal while `rcNormalPosition` is its pre-snap +floating rect, possibly on another monitor at another DPI (both APIs use the same frame +convention, invisible resize borders included, so the round trip is exact). Maximized and +minimized save `rcNormalPosition`. Either way the offset and the DPI come from the monitor +the *rect* is on, never `MonitorFromWindow`. On restore a Normal window's landing spot is +checked with `GetWindowRect` and corrected with `SetWindowPos`, so how Windows offsets +workspace coordinates on a secondary monitor cannot move it. `LastNormalBounds` (DIPs) is +still written for older builds and read only when `LastPlacement` is absent. Its scale is +unknown: v0.10.0 saved pixels ÷ the DPI of *the window's* monitor, older system-DPI builds +pixels ÷ the primary's. So each monitor's DPI is tried, and the one whose scaled rect lands +on that same monitor wins, with the primary as tie-break (`FromLegacyDips`). Which monitor, whether the caption is still +reachable on some *work area*, and the fallback when it isn't (monitor unplugged: fit into +the nearest monitor and move to its nearest edge) are decided by the WPF-free +`WindowPlacementMath`, covered by `WindowPlacementMathTests`. A reachable window — even one +spanning two monitors or hanging off an edge — is left exactly where it was. + ## Bundled ConPTY Terminals run on a **bundled ConPTY** — `conpty.dll` + `OpenConsole.exe` from the NuGet package @@ -995,6 +1024,10 @@ detached HEAD → `(null, false)` included. The extra fields (`GitUpstream`, `Gi `GitBehind`, `GitChangeCount`) follow the same rule as `GitBranch`: plain properties, announced only through `GitInfoVersion`. An upstream that is configured but whose remote branch is gone produces no `# branch.ab` line, so it reads "upstream gone", not "up to date". +Unmerged (`u`) entries keep their real XY (`AA`, `DU`, `UD`, `AU`, `UA`, `DD`, `UU`), and +paths are run through `UnquoteCPath`: even with `core.quotePath=false` git still C-quotes a +name containing `"`, `\`, a tab or a newline, and its octal escapes are UTF-8 *bytes*. A +rename's tab separator is split off before unquoting — a tab inside a name is always quoted. **Environment for every git spawn** (`GitService.GitEnvironment`): - `GIT_TERMINAL_PROMPT=0` — git has no console, so an HTTPS credential prompt would wait for @@ -1016,7 +1049,16 @@ branch literally named `-x`). `--no-guess` stops git silently creating a trackin name that exists only on a remote. **One operation per repo at a time** (`_gitBusyRepos`, keyed by `RepoRoot` so worktrees of one -repo share the slot); the chip shows `⟳` meanwhile. Afterwards *every* live pane on that repo +repo share the slot); the chip shows `⟳` meanwhile. The slot is **claimed** with +`ResolveGitRepoKeyAsync`, which resolves `RepoRoot` on demand (off the UI thread, via +`SessionViewModel.EnsureRepoRootAsync`) when the poll hasn't landed it yet — falling back to +the folder there let two panes of one repo hold different keys for the first seconds after +launch and run git concurrently (#181). Keys go through `GitWorktrees.BusyKey`, which folds +case and slashes per Windows/WSL rules. For one repo to give one key, `GetRepoRootAsync` +returns one spelling: the result is canonicalised (`GitWorktrees.Canonical`, so an 8.3 or +junction session folder in the main checkout matches git's long path from a worktree), and +`RepoRootFromCommonDir` maps a Linux-absolute `--git-common-dir` from a WSL worktree back to +`\\wsl$\\…`. Afterwards *every* live pane on that repo re-reads git state explicitly: a fetch moves only remote refs, which `GitRepoWatcher` (HEAD + index) never sees. Success → a toast; failure → the git panel (docked under the toolbar, like notes) opens with git's own output plus the recognised hint. diff --git a/src/CodeShellManager/MainWindow.Git.cs b/src/CodeShellManager/MainWindow.Git.cs index b84121d..e2ddfbf 100644 --- a/src/CodeShellManager/MainWindow.Git.cs +++ b/src/CodeShellManager/MainWindow.Git.cs @@ -51,7 +51,7 @@ private sealed class GitPaneUi /// from a second pane of the same repo would be pure duplication. Keyed by RepoRoot, so /// worktrees of one repository (which share refs) share the slot. /// - private readonly HashSet _gitBusyRepos = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _gitBusyRepos = new(StringComparer.Ordinal); // keys are pre-folded by GitWorktrees.BusyKey private const int MaxChipBranchChars = 32; @@ -67,8 +67,21 @@ private static SolidColorBrush Frozen(byte r, byte g, byte b) return br; } + /// + /// The busy-slot key as currently known — for display and peer matching only. A slot is + /// CLAIMED with , never with this: before RepoRoot + /// lands it falls back to the folder, and two panes of one repo then hold different keys. + /// private static string GitRepoKey(SessionViewModel vm) => - string.IsNullOrEmpty(vm.RepoRoot) ? vm.WorkingFolder : vm.RepoRoot; + GitWorktrees.BusyKey(vm.RepoRoot, vm.WorkingFolder); + + /// + /// The busy-slot key with RepoRoot resolved on demand (off the UI thread), so a click in + /// the first seconds after launch claims the same slot as every other pane on the repo + /// (#181). Continues on the UI thread, where the slot set lives. + /// + private static async Task ResolveGitRepoKeyAsync(SessionViewModel vm) => + GitWorktrees.BusyKey(await vm.EnsureRepoRootAsync(), vm.WorkingFolder); /// /// Builds the chip (docked into the terminal toolbar) and the output panel (docked under @@ -218,7 +231,7 @@ private void UpdateGitChip(SessionViewModel vm) private void UpdateGitChipsForRepo(string repoKey) { foreach (var s in _vm.Sessions) - if (string.Equals(GitRepoKey(s), repoKey, StringComparison.OrdinalIgnoreCase)) + if (GitRepoKey(s) == repoKey) UpdateGitChip(s); } @@ -360,7 +373,7 @@ private async Task SwitchBranchAsync(SessionViewModel vm, string branch) private async Task RunGitOpAsync( SessionViewModel vm, string verb, Func> op) { - string key = GitRepoKey(vm); + string key = await ResolveGitRepoKeyAsync(vm); if (!_gitBusyRepos.Add(key)) { ToastHelper.Show("Git busy", "A git operation is already running for this repository."); @@ -381,7 +394,7 @@ private async Task SwitchBranchAsync(SessionViewModel vm, string branch) // A fetch moves only remote refs, which GitRepoWatcher (HEAD + index) never sees, so // every pane on this repo — worktrees included, they share refs — re-reads explicitly. var peers = _vm.Sessions - .Where(s => string.Equals(GitRepoKey(s), key, StringComparison.OrdinalIgnoreCase)) + .Where(s => GitRepoKey(s) == key) .ToList(); await Task.WhenAll(peers.Select(s => s.RefreshGitInfoAsync())); UpdateGitChipsForRepo(key); @@ -616,7 +629,7 @@ private async Task RemoveWorktreeAsync( MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) != MessageBoxResult.Yes) return; - string key = GitRepoKey(vm); + string key = await ResolveGitRepoKeyAsync(vm); if (!_gitBusyRepos.Add(key)) { ToastHelper.Show("Git busy", "A git operation is already running for this repository."); @@ -681,7 +694,7 @@ private async Task RemoveWorktreeAsync( { _gitBusyRepos.Remove(key); var peers = _vm.Sessions - .Where(s => string.Equals(GitRepoKey(s), key, StringComparison.OrdinalIgnoreCase)).ToList(); + .Where(s => GitRepoKey(s) == key).ToList(); await Task.WhenAll(peers.Select(s => s.RefreshGitInfoAsync())); UpdateGitChipsForRepo(key); } diff --git a/src/CodeShellManager/MainWindow.Placement.cs b/src/CodeShellManager/MainWindow.Placement.cs new file mode 100644 index 0000000..6324b6a --- /dev/null +++ b/src/CodeShellManager/MainWindow.Placement.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; +using CodeShellManager.Models; +using CodeShellManager.Services; + +namespace CodeShellManager; + +/// +/// Saving and restoring the main window's bounds under per-monitor DPI v2 (#181). +/// +/// Restore used to set Left/Top/Width/Height from OnLoaded — after the window was +/// already shown, maximized, on the primary monitor — and clamp them against +/// SystemParameters.VirtualScreen*, which is in the PRIMARY monitor's DIPs. On a +/// secondary monitor with a different scale, moving there triggered a DPI change and a resize +/// mid-move, so the size drifted from run to run and the clamp could misplace it. +/// +/// Now: the bounds are read synchronously in the constructor, and applied in +/// OnSourceInitialized — the HWND exists but has not been shown — with +/// SetWindowPlacement, in physical pixels, against the real monitor work areas. +/// The decisions themselves live in the WPF-free . +/// +public partial class MainWindow +{ + /// Read in the constructor; consumed once by . + private StateService.SavedWindowState? _savedWindowState; + + /// + /// Called from the constructor, before WPF shows the window. A saved Normal window must + /// not start out maximized (the XAML default, kept for a first run): WPF would create the + /// HWND maximized on the primary monitor, and the placement below would then move a + /// maximized window rather than set its normal rectangle. + /// + private void LoadSavedWindowState() + { + _savedWindowState = StateService.TryReadWindowState(); + if (_savedWindowState is { LastPlacement: not null } or { LastNormalBounds: not null }) + WindowState = WindowState.Normal; + } + + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + try { ApplySavedPlacement(); } + catch (Exception ex) { StateService.Log($"window placement restore failed: {ex.Message}"); } + } + + private void ApplySavedPlacement() + { + var saved = _savedWindowState; + _savedWindowState = null; + if (saved is null) return; // first run: XAML default (maximized on the primary) + + var hwnd = new WindowInteropHelper(this).Handle; + if (hwnd == IntPtr.Zero) return; + + var monitors = EnumerateMonitors(); + var resolved = WindowPlacementMath.Resolve(saved.LastPlacement, saved.LastNormalBounds, monitors, MinWidth, MinHeight); + if (resolved is null) + { + if (saved.WindowMaximized) WindowState = WindowState.Maximized; + return; + } + + var (rect, monitor) = resolved.Value; + var ws = WindowPlacementMath.ScreenToWorkspace(rect, monitor); + var wp = new WINDOWPLACEMENT + { + length = Marshal.SizeOf(), + flags = 0, + // SetWindowPlacement shows the window, and WPF picks up the state from WM_SIZE; + // its own ShowWindow afterwards is then a no-op. Never restore minimized. + showCmd = saved.WindowMaximized ? SW_SHOWMAXIMIZED : SW_SHOWNORMAL, + rcNormalPosition = new RECT { Left = ws.Left, Top = ws.Top, Right = ws.Right, Bottom = ws.Bottom }, + }; + + uint dpiBefore = GetDpiForWindow(hwnd); + SetWindowPlacement(hwnd, ref wp); + // Landing on a monitor of a different scale raises WM_DPICHANGED, and WPF applies the + // rect Windows suggests — the saved size scaled by the DPI ratio. Applying again, now + // that the window is already on that monitor, sets the exact saved rectangle. This + // second call is what stops the size drifting run to run in a mixed-DPI setup. + if (GetDpiForWindow(hwnd) != dpiBefore) + SetWindowPlacement(hwnd, ref wp); + + // A normal window was saved from GetWindowRect, i.e. in true screen coordinates. The + // workspace conversion above is exact on the primary, but how Windows offsets + // workspace coordinates on a secondary with its own top/left taskbar is not something + // to bet on — so check where it actually landed and correct with screen coordinates. + if (!saved.WindowMaximized && GetWindowRect(hwnd, out var actual) + && (actual.Left != rect.Left || actual.Top != rect.Top + || actual.Right != rect.Right || actual.Bottom != rect.Bottom)) + SetWindowPos(hwnd, IntPtr.Zero, rect.Left, rect.Top, rect.Width, rect.Height, + SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOOWNERZORDER); + } + + /// + /// Pushes the current bounds into the view model: the DIP fields for older builds, and + /// the physical normal rectangle. + /// + /// Normal state saves GetWindowRect, not rcNormalPosition: for a snapped + /// window WPF reports Normal but rcNormalPosition is the pre-snap floating rect — + /// possibly on another monitor at another DPI. Saving what is on screen keeps the old + /// behaviour (the snapped area comes back) and keeps rect and DPI from the same monitor. + /// Both APIs use the same frame convention — the full window rect, invisible resize + /// borders included (not the DWM extended-frame bounds) — so the round trip through + /// SetWindowPlacement is exact. Maximized/minimized save rcNormalPosition, + /// the only place the restore size still exists. Either way the monitor (offset and DPI) + /// is the one the RECT is on, chosen by . + /// + private void CaptureWindowState() + { + WindowPlacementState? placement = null; + bool maximized = WindowState == WindowState.Maximized; + try + { + var hwnd = new WindowInteropHelper(this).Handle; + if (hwnd != IntPtr.Zero) + { + var monitors = EnumerateMonitors(); + var wp = new WINDOWPLACEMENT { length = Marshal.SizeOf() }; + bool haveWp = GetWindowPlacement(hwnd, ref wp); + if (WindowState == WindowState.Normal) + { + if (GetWindowRect(hwnd, out var r)) + placement = WindowPlacementMath.SaveFromScreen(ToPixelRect(r), monitors); + } + else if (haveWp) + { + placement = WindowPlacementMath.SaveFromWorkspace(ToPixelRect(wp.rcNormalPosition), monitors); + } + if (haveWp && WindowState == WindowState.Minimized) + maximized = (wp.flags & WPF_RESTORETOMAXIMIZED) != 0; + } + } + catch { /* keep the DIP fields; a failed native read must not break saving */ } + + _vm.UpdateWindowState(WindowState, Left, Top, Width, Height, placement, maximized); + } + + private static List EnumerateMonitors() + { + var list = new List(); + EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, (h, _, _, _) => + { + var m = MonitorAreaFor(h); + if (m != null) list.Add(m); + return true; + }, IntPtr.Zero); + return list; + } + + private static MonitorArea? MonitorAreaFor(IntPtr hMonitor) + { + if (hMonitor == IntPtr.Zero) return null; + var mi = new MONITORINFO { cbSize = Marshal.SizeOf() }; + if (!GetMonitorInfo(hMonitor, ref mi)) return null; + int dpi = GetDpiForMonitor(hMonitor, MDT_EFFECTIVE_DPI, out uint dx, out _) == 0 && dx > 0 ? (int)dx : 96; + return new MonitorArea(ToPixelRect(mi.rcMonitor), ToPixelRect(mi.rcWork), dpi, + (mi.dwFlags & MONITORINFOF_PRIMARY) != 0); + } + + private static PixelRect ToPixelRect(RECT r) => new(r.Left, r.Top, r.Right - r.Left, r.Bottom - r.Top); + + // ── Win32 ──────────────────────────────────────────────────────────────────────── + + private const int SW_SHOWNORMAL = 1; + private const int SW_SHOWMAXIMIZED = 3; + private const int WPF_RESTORETOMAXIMIZED = 0x2; + private const uint SWP_NOZORDER = 0x0004, SWP_NOACTIVATE = 0x0010, SWP_NOOWNERZORDER = 0x0200; + private const uint MONITORINFOF_PRIMARY = 1; + private const int MDT_EFFECTIVE_DPI = 0; + + [StructLayout(LayoutKind.Sequential)] + private struct RECT { public int Left, Top, Right, Bottom; } + + [StructLayout(LayoutKind.Sequential)] + private struct POINT { public int X, Y; } + + [StructLayout(LayoutKind.Sequential)] + private struct WINDOWPLACEMENT + { + public int length; + public int flags; + public int showCmd; + public POINT ptMinPosition; + public POINT ptMaxPosition; + public RECT rcNormalPosition; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MONITORINFO + { + public int cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + } + + private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdc, IntPtr lprcMonitor, IntPtr data); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetWindowPlacement(IntPtr hWnd, ref WINDOWPLACEMENT lpwndpl); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetWindowPlacement(IntPtr hWnd, ref WINDOWPLACEMENT lpwndpl); + + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi); + + [DllImport("shcore.dll")] + private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY); +} diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 6970627..5a1d0b8 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -138,6 +138,7 @@ private enum SortField { None, Name, Folder, LastActive, Branch, Dirty, Repo } public MainWindow() { InitializeComponent(); + LoadSavedWindowState(); // before Show — see MainWindow.Placement.cs _vm = new MainViewModel(_sessionManager, _stateService); _vm.SessionClosed += OnSessionVmClosed; @@ -226,7 +227,7 @@ private void OnWindowBoundsChanged() private void SaveWindowBounds() { if (!_windowStateReady) return; - _vm.UpdateWindowState(WindowState, Left, Top, Width, Height); + CaptureWindowState(); _ = _vm.SaveStateAsync(); } @@ -255,8 +256,9 @@ private async void OnLoaded(object sender, RoutedEventArgs e) // Before any terminal starts: PseudoTerminal resolves its ConPTY implementation once, // on the first Start, from this flag. See "Bundled ConPTY" in CLAUDE.md. Terminal.PseudoTerminal.PreferBundledConPty = _vm.Settings.UseBundledConPty; - RestoreWindowState(); EnsureOpenWithSeeded(); // first run after #178: detect editors once, off the UI thread + // Window bounds were already applied before the window was shown + // (MainWindow.Placement.cs); only start saving them from here on. _windowStateReady = true; // Unattributed UI-thread latency baseline (issue #70). Started after settings load @@ -710,32 +712,6 @@ private static bool IsWebView2AccessDenied(Exception ex) => ex is UnauthorizedAccessException && (ex.StackTrace?.Contains("WebView2", StringComparison.Ordinal) ?? false); - private void RestoreWindowState() - { - var bounds = _vm.GetSavedWindowBounds(); - if (bounds != null) - { - // Validate bounds are at least partially on-screen - var screenWidth = SystemParameters.VirtualScreenWidth; - var screenHeight = SystemParameters.VirtualScreenHeight; - var screenLeft = SystemParameters.VirtualScreenLeft; - var screenTop = SystemParameters.VirtualScreenTop; - - double left = Math.Max(screenLeft, Math.Min(bounds.Left, screenLeft + screenWidth - 100)); - double top = Math.Max(screenTop, Math.Min(bounds.Top, screenTop + screenHeight - 100)); - double width = Math.Max(400, Math.Min(bounds.Width, screenWidth)); - double height = Math.Max(300, Math.Min(bounds.Height, screenHeight)); - - Left = left; - Top = top; - Width = width; - Height = height; - } - - if (_vm.IsWindowMaximized()) - WindowState = WindowState.Maximized; - } - private async Task InitDatabaseAsync() { string dbPath = Path.Combine( @@ -6367,7 +6343,7 @@ await Dispatcher.InvokeAsync(() => { }, _windowStateTimer.Stop(); if (_windowStateReady) - _vm.UpdateWindowState(WindowState, Left, Top, Width, Height); + CaptureWindowState(); await _vm.SaveStateAsync(); // Non-Claude sessions don't fight over ~/.claude.json — dispose them in parallel. diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs index ac8c87c..8c17094 100644 --- a/src/CodeShellManager/Models/AppState.cs +++ b/src/CodeShellManager/Models/AppState.cs @@ -199,6 +199,28 @@ public class AppState public AppSettings Settings { get; set; } = new(); // Window state persistence + /// + /// Normal (restored) bounds in WPF DIPs. Still written so an older build can read it, + /// but only READ as a fallback when is absent — DIPs are + /// ambiguous across monitors of different scale, and pre-v0.10 builds saved them in + /// system-DPI space. See "DPI awareness" in CLAUDE.md. + /// public WindowBounds? LastNormalBounds { get; set; } + /// Normal bounds in physical screen pixels, plus the DPI they were saved at (#181). + public WindowPlacementState? LastPlacement { get; set; } public bool WindowMaximized { get; set; } = false; } + +/// +/// The window's normal (restored) rectangle in PHYSICAL screen pixels, and the DPI of the +/// monitor it was on when saved. Pixels are unambiguous under per-monitor DPI v2; the DPI +/// lets a restore keep the same apparent size if that monitor's scale has changed since. +/// +public class WindowPlacementState +{ + public int Left { get; set; } + public int Top { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public int Dpi { get; set; } +} diff --git a/src/CodeShellManager/Services/GitService.cs b/src/CodeShellManager/Services/GitService.cs index e7f593a..6d81ab9 100644 --- a/src/CodeShellManager/Services/GitService.cs +++ b/src/CodeShellManager/Services/GitService.cs @@ -76,7 +76,7 @@ internal static GitRepoStatus ParseStatusV2(string output) } else if (line[0] == '#') continue; else if (line.StartsWith("? ", StringComparison.Ordinal)) - changes.Add(new GitFileChange("??", line[2..])); + changes.Add(new GitFileChange("??", UnquoteCPath(line[2..]))); else if (line.StartsWith("! ", StringComparison.Ordinal)) continue; // ignored files — only listed with --ignored, never by us else @@ -91,10 +91,17 @@ internal static GitRepoStatus ParseStatusV2(string output) string path = f[fieldsBeforePath]; if (line[0] == '2') { + // A tab or newline INSIDE a name is always C-quoted (\t, \n), so the first + // literal tab is the separator — split before unquoting, never after. int tab = path.IndexOf('\t'); - if (tab >= 0) path = $"{path[(tab + 1)..]} → {path[..tab]}"; + path = tab >= 0 + ? $"{UnquoteCPath(path[(tab + 1)..])} → {UnquoteCPath(path[..tab])}" + : UnquoteCPath(path); } - changes.Add(new GitFileChange(line[0] == 'u' ? "UU" : f[1].Replace('.', ' '), path)); + else path = UnquoteCPath(path); + // Unmerged entries keep their real XY: AA, DU, UD, AU, UA, DD and UU each say + // something different about the conflict (both added, deleted by us, …). + changes.Add(new GitFileChange(f[1].Replace('.', ' '), path)); } } @@ -102,6 +109,62 @@ internal static GitRepoStatus ParseStatusV2(string output) return new GitRepoStatus(detached ? null : head, detached, upstream, ahead, behind, changes); } + /// + /// Undoes git's C-style path quoting. core.quotePath=false stops git escaping + /// non-ASCII, but a name containing ", \ or a control character (tab, + /// newline, …) is still wrapped in quotes with backslash escapes — and when quoting is on, + /// octal escapes (\303\246) are raw BYTES of a UTF-8 name, so the result is decoded + /// as bytes, not char by char. Anything not wrapped in " is returned unchanged; a + /// malformed escape is kept literally rather than dropped. + /// + internal static string UnquoteCPath(string s) + { + if (s.Length < 2 || s[0] != '"' || s[^1] != '"') return s; + int end = s.Length - 1; // index of the closing quote + var bytes = new List(s.Length); + var enc = Encoding.UTF8; + for (int i = 1; i < end; i++) + { + char c = s[i]; + if (c != '\\' || i + 1 >= end) + { + // Ordinary text (already decoded by the process reader): re-encode as UTF-8 + // so it joins any octal-escaped bytes around it. Keep surrogate pairs whole. + int len = char.IsHighSurrogate(c) && i + 1 < end && char.IsLowSurrogate(s[i + 1]) ? 2 : 1; + bytes.AddRange(enc.GetBytes(s.Substring(i, len))); + i += len - 1; + continue; + } + char e = s[++i]; + switch (e) + { + case 'a': bytes.Add(0x07); break; + case 'b': bytes.Add(0x08); break; + case 't': bytes.Add(0x09); break; + case 'n': bytes.Add(0x0A); break; + case 'v': bytes.Add(0x0B); break; + case 'f': bytes.Add(0x0C); break; + case 'r': bytes.Add(0x0D); break; + case '"': bytes.Add((byte)'"'); break; + case '\\': bytes.Add((byte)'\\'); break; + default: + if (e is >= '0' and <= '3' && i + 2 < end + && s[i + 1] is >= '0' and <= '7' && s[i + 2] is >= '0' and <= '7') + { + bytes.Add((byte)(((e - '0') << 6) | ((s[i + 1] - '0') << 3) | (s[i + 2] - '0'))); + i += 2; + } + else + { + bytes.Add((byte)'\\'); + i--; // not an escape we know: keep the backslash, re-read e as text + } + break; + } + } + return enc.GetString(bytes.ToArray()); + } + // ── User-initiated operations (the toolbar git menu) ───────────────────────────── // // None of these ever run unprompted, and none of them write history: no commit, no push, @@ -241,27 +304,52 @@ internal static string SummarizePull(string output) try { string? commonDir = await RunGitAsync(folderPath, "rev-parse", "--git-common-dir").ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(commonDir)) return null; - string trimmed = commonDir.Trim(); + string? root = RepoRootFromCommonDir(commonDir, folderPath); + // One spelling per repo: from the main checkout git answers relative to the cwd + // (so an 8.3 or junction spelling of the session folder survives), from a linked + // worktree with whatever `worktree add` recorded. RepoRoot keys the git busy slot, + // sibling detection and the accent colour, so both must agree. Canonical touches + // the disk; we are past a ConfigureAwait(false), i.e. on the pool. + return root is null ? null : GitWorktrees.Canonical(root); + } + catch { return null; } + } + /// + /// Turns rev-parse --git-common-dir output into the main working tree's root. Pure. + /// + /// git prints a path relative to the cwd from the main worktree (.git) but an + /// absolute one from a linked worktree — and under WSL that absolute path is a LINUX path. + /// normally maps it to \\wsl$\… already, but if one + /// ever got through, Path.IsPathRooted("/home/u/repo") is true on Windows and the + /// root would come back as /home/u/repo, while the main worktree's comes back as + /// UNC: two worktrees of one repo with different git busy-slot keys (#181). So a + /// Linux-absolute result for a WSL folder is mapped to UNC here, unconditionally. + /// + internal static string? RepoRootFromCommonDir(string? commonDir, string folderPath) + { + if (string.IsNullOrWhiteSpace(commonDir)) return null; + string trimmed = commonDir.Trim(); + + var (distro, _) = TryParseWslUnc(folderPath); + string absolute; + if (distro != null && trimmed.StartsWith('/')) + absolute = WslDiscoveryService.ToUncPath(distro, trimmed); + else // git may return a path relative to the cwd (e.g. ".git" for a plain repo) // or an absolute path (e.g. "C:/repo/.git" when called from a worktree). - // Resolve to an absolute path either way. - string absolute = System.IO.Path.IsPathRooted(trimmed) + absolute = System.IO.Path.IsPathRooted(trimmed) ? trimmed : System.IO.Path.GetFullPath(trimmed, folderPath); - // Strip the trailing ".git" segment to get the repo's working tree root. - string normalized = absolute.Replace('\\', '/').TrimEnd('/'); - if (normalized.EndsWith("/.git", StringComparison.OrdinalIgnoreCase)) - normalized = normalized[..^"/.git".Length]; - else if (normalized.EndsWith(".git", StringComparison.OrdinalIgnoreCase) - && !normalized.EndsWith("/.git", StringComparison.OrdinalIgnoreCase)) - normalized = normalized[..^".git".Length].TrimEnd('/'); + // Strip the trailing ".git" segment to get the repo's working tree root. + string normalized = absolute.Replace('\\', '/').TrimEnd('/'); + if (normalized.EndsWith("/.git", StringComparison.OrdinalIgnoreCase)) + normalized = normalized[..^"/.git".Length]; + else if (normalized.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + normalized = normalized[..^".git".Length].TrimEnd('/'); - return string.IsNullOrEmpty(normalized) ? null : normalized; - } - catch { return null; } + return string.IsNullOrEmpty(normalized) ? null : normalized; } /// Describes one git worktree as reported by `git worktree list --porcelain`. diff --git a/src/CodeShellManager/Services/GitWorktrees.cs b/src/CodeShellManager/Services/GitWorktrees.cs index 7a1822b..335b0dd 100644 --- a/src/CodeShellManager/Services/GitWorktrees.cs +++ b/src/CodeShellManager/Services/GitWorktrees.cs @@ -98,6 +98,18 @@ private static extern SafeFileHandle CreateFileW(string name, uint access, uint [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern uint GetFinalPathNameByHandleW(SafeFileHandle h, [Out] char[] buf, uint len, uint flags); + /// + /// The key a git operation's busy slot is held under: the repo root (the MAIN working + /// tree, so every worktree of one repository shares it) when known, else the folder. + /// Case-folded per 's rules so one HashSet with ordinal comparison can + /// hold Windows and WSL keys side by side. + /// + public static string BusyKey(string? repoRoot, string? workingFolder) + { + var (k, cmp) = Key(string.IsNullOrEmpty(repoRoot) ? workingFolder ?? "" : repoRoot); + return cmp == StringComparison.OrdinalIgnoreCase ? k.ToLowerInvariant() : k; + } + /// /// True when is or below it. A plain /// prefix test is wrong: C:/repo-wt starts with C:/repo but is not inside it. diff --git a/src/CodeShellManager/Services/StateService.cs b/src/CodeShellManager/Services/StateService.cs index 64a1185..0bb6168 100644 --- a/src/CodeShellManager/Services/StateService.cs +++ b/src/CodeShellManager/Services/StateService.cs @@ -68,6 +68,39 @@ public async Task LoadAsync() return new AppState(); } + /// The window fields of , and nothing else. + public sealed class SavedWindowState + { + public WindowPlacementState? LastPlacement { get; set; } + public WindowBounds? LastNormalBounds { get; set; } + public bool WindowMaximized { get; set; } + } + + /// + /// Reads only the saved window bounds, synchronously. The window must be placed BEFORE + /// it is first shown (#181), which is before can run — that is + /// awaited from OnLoaded, by which point the window is already on screen on the + /// primary monitor. Falls back to the backup like ; never throws, + /// and returns null on a first run. The full load still happens later as before. + /// + public static SavedWindowState? TryReadWindowState() + { + foreach (var path in new[] { StatePath, BackupPath }) + { + try + { + if (!File.Exists(path)) continue; + var s = JsonSerializer.Deserialize(File.ReadAllBytes(path), Options); + if (s != null) return s; + } + catch (Exception ex) + { + Log($"window bounds: failed to read '{path}': {ex.Message}"); + } + } + return null; + } + private static async Task TryLoadAsync(string path) { try @@ -181,7 +214,7 @@ private static async Task WriteAsync(byte[] json, long seq) } } - private static void Log(string message) + internal static void Log(string message) { try { diff --git a/src/CodeShellManager/Services/WindowPlacementMath.cs b/src/CodeShellManager/Services/WindowPlacementMath.cs new file mode 100644 index 0000000..bfdbcc6 --- /dev/null +++ b/src/CodeShellManager/Services/WindowPlacementMath.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CodeShellManager.Models; + +namespace CodeShellManager.Services; + +/// A rectangle in physical screen pixels. +public readonly record struct PixelRect(int Left, int Top, int Width, int Height) +{ + public int Right => Left + Width; + public int Bottom => Top + Height; + + public PixelRect Intersect(PixelRect o) + { + int l = Math.Max(Left, o.Left), t = Math.Max(Top, o.Top); + int r = Math.Min(Right, o.Right), b = Math.Min(Bottom, o.Bottom); + return r > l && b > t ? new PixelRect(l, t, r - l, b - t) : default; + } + + public long Area => (long)Width * Height; +} + +/// One display: its full bounds and work area (physical pixels) and its DPI. +public sealed record MonitorArea(PixelRect Bounds, PixelRect Work, int Dpi, bool IsPrimary); + +/// +/// WPF-free decisions behind restoring the main window's bounds (#181): which monitor the +/// saved rectangle belongs on, whether it is still reachable, and what to do when it isn't. +/// Everything is in physical pixels — the only coordinate space that means the same thing on +/// every monitor of a per-monitor-DPI-v2 process. MainWindow feeds it the live monitor +/// list and applies the answer with SetWindowPlacement before the window is shown. +/// +public static class WindowPlacementMath +{ + /// Height of the strip at the top of the window that must stay reachable (DIPs). + public const int CaptionDip = 32; + + /// How much of that strip must be on a work area for the window to count as visible (DIPs). + public const int MinVisibleWidthDip = 120; + + /// + /// The normal rectangle to restore, and the monitor it lands on. Null when there is + /// nothing usable saved (first run, or garbage) — the caller then keeps its defaults. + /// + /// Physical-pixel bounds saved by this build. Preferred. + /// + /// DIP bounds saved by an older build, in an unknown scale — see . + /// A wrong guess only misplaces the window by the scale ratio, and the visibility rules + /// below still guarantee it ends up reachable. + /// + public static (PixelRect Rect, MonitorArea Monitor)? Resolve( + WindowPlacementState? placement, WindowBounds? legacy, + IReadOnlyList monitors, double minWidthDip = 0, double minHeightDip = 0) + { + if (monitors.Count == 0) return null; + var primary = monitors.FirstOrDefault(m => m.IsPrimary) ?? monitors[0]; + + PixelRect rect; + int savedDpi; + if (placement is { Width: > 0, Height: > 0, Dpi: > 0 }) + { + rect = new PixelRect(placement.Left, placement.Top, placement.Width, placement.Height); + savedDpi = placement.Dpi; + } + else if (legacy is not null && IsFinite(legacy.Left) && IsFinite(legacy.Top) + && IsFinite(legacy.Width) && IsFinite(legacy.Height) + && legacy.Width > 0 && legacy.Height > 0) + { + (rect, savedDpi) = FromLegacyDips(legacy, monitors, primary); + } + else return null; + + var home = Home(rect, monitors); + + var target = home ?? Nearest(rect, monitors) ?? primary; + + // Same apparent size if that monitor's scale changed since the save. The top-left + // corner stays put: it is what the user placed. + rect = Rescale(rect, savedDpi, target.Dpi); + + // Reachable: leave it exactly where the user put it — even hanging off an edge or + // spanning two monitors. Restoring is not the place to second-guess a layout. + if (home is not null && IsCaptionVisible(rect, target.Dpi, monitors)) + return (EnsureMinSize(rect, target, minWidthDip, minHeightDip), target); + + // Caption out of reach (monitor unplugged, rearranged, resolution dropped): fit it into + // the nearest monitor's work area, moving it the shortest distance — to the nearest + // edge rather than the centre, so it stays roughly where it was. + rect = EnsureMinSize(rect, target, minWidthDip, minHeightDip); + return (ClampInto(rect, target.Work), target); + } + + /// + /// Legacy DIP bounds → physical pixels. The scale they were saved at is unknown: + /// v0.10.0 (per-monitor v2, the usual upgrade source) saved pixels ÷ the DPI of the + /// monitor the window was on; older system-DPI builds saved pixels ÷ the primary's DPI at + /// the time. So each monitor's DPI is tried as the scale, and a candidate is consistent + /// when the scaled rect's own monitor () is the monitor whose DPI was + /// used — v0.10.0's rule, and also the system-DPI rule whenever the window was on the + /// primary. Several can be consistent (a small window near a shared edge); the primary + /// wins that tie, then the largest share of the rect on its monitor. If none is, the + /// primary's scale is used and the visibility rules in take over. + /// + public static (PixelRect Rect, int Dpi) FromLegacyDips( + WindowBounds legacy, IReadOnlyList monitors, MonitorArea primary) + { + PixelRect Scaled(int dpi) + { + double s = dpi / 96.0; + return new PixelRect(Round(legacy.Left * s), Round(legacy.Top * s), + Round(legacy.Width * s), Round(legacy.Height * s)); + } + + var best = monitors + .Select(m => (m, r: Scaled(m.Dpi))) + .Where(x => ReferenceEquals(Home(x.r, monitors), x.m)) + .OrderByDescending(x => x.m.IsPrimary) + .ThenByDescending(x => (double)x.r.Intersect(x.m.Bounds).Area / Math.Max(1, x.r.Area)) + .Select(x => ((PixelRect, int)?)(x.r, x.m.Dpi)) + .FirstOrDefault(); + return best ?? (Scaled(primary.Dpi), primary.Dpi); + } + + /// + /// The monitor a screen rect is (mostly) on — largest overlap, as Windows picks it — or + /// null when it overlaps none. + /// + public static MonitorArea? Home(PixelRect rect, IReadOnlyList monitors) => + monitors + .Select(m => (m, area: rect.Intersect(m.Bounds).Area)) + .Where(x => x.area > 0) + .OrderByDescending(x => x.area) + .Select(x => x.m) + .FirstOrDefault(); + + /// + /// What to save for a normal-state window: its screen rect (GetWindowRect) and the DPI of + /// the monitor that RECT is on — not of whatever monitor the window reports, which is the + /// question that goes wrong for a snapped or spanning window. + /// + public static WindowPlacementState? SaveFromScreen(PixelRect screen, IReadOnlyList monitors) + { + if (screen.Width <= 0 || screen.Height <= 0 || monitors.Count == 0) return null; + var m = Home(screen, monitors) ?? Nearest(screen, monitors)!; + return ToState(screen, m); + } + + /// + /// What to save for a maximized/minimized window: its normal rect from GetWindowPlacement + /// (workspace coordinates). Each monitor's work-area offset is tried, and the one under + /// which the rect lands most on that same monitor is taken — the rect's own monitor, which + /// need not be the one the (maximized) window is on now. That monitor supplies the DPI. + /// + public static WindowPlacementState? SaveFromWorkspace(PixelRect workspace, IReadOnlyList monitors) + { + if (workspace.Width <= 0 || workspace.Height <= 0 || monitors.Count == 0) return null; + var hit = monitors + .Select(m => (m, s: WorkspaceToScreen(workspace, m))) + .Select(x => (x.m, x.s, area: x.s.Intersect(x.m.Bounds).Area)) + .Where(x => x.area > 0) + .OrderByDescending(x => x.area) + .FirstOrDefault(); + if (hit.m is not null) return ToState(hit.s, hit.m); + var m = Nearest(workspace, monitors)!; + return ToState(WorkspaceToScreen(workspace, m), m); + } + + private static WindowPlacementState ToState(PixelRect r, MonitorArea m) => + new() { Left = r.Left, Top = r.Top, Width = r.Width, Height = r.Height, Dpi = m.Dpi }; + + /// + /// True when the top strip of the window (where the caption is) overlaps some monitor's + /// work area by enough to grab it with the mouse. + /// + public static bool IsCaptionVisible(PixelRect rect, int dpi, IReadOnlyList monitors) + { + int strip = Math.Min(rect.Height, Scale(CaptionDip, dpi)); + var caption = new PixelRect(rect.Left, rect.Top, rect.Width, strip); + int needW = Math.Min(rect.Width, Scale(MinVisibleWidthDip, dpi)); + int needH = Math.Max(1, strip / 2); + return monitors.Any(m => + { + var i = caption.Intersect(m.Work); + return i.Width >= needW && i.Height >= needH; + }); + } + + /// Scales the size (not the position) from one DPI to another. + public static PixelRect Rescale(PixelRect r, int fromDpi, int toDpi) + { + if (fromDpi <= 0 || toDpi <= 0 || fromDpi == toDpi) return r; + double f = (double)toDpi / fromDpi; + return r with { Width = Round(r.Width * f), Height = Round(r.Height * f) }; + } + + /// Moves the shortest distance that puts it inside . + public static PixelRect ClampInto(PixelRect r, PixelRect area) + { + int w = Math.Min(r.Width, area.Width), h = Math.Min(r.Height, area.Height); + int l = Math.Clamp(r.Left, area.Left, area.Right - w); + int t = Math.Clamp(r.Top, area.Top, area.Bottom - h); + return new PixelRect(l, t, w, h); + } + + /// + /// Physical (screen) ↔ workspace coordinates. WINDOWPLACEMENT.rcNormalPosition is + /// in workspace coordinates — relative to the work area, so a taskbar docked top or left + /// shifts them. Save and restore both convert through the window's monitor, so a round + /// trip is exact whichever way Windows defines the offset on a secondary monitor. + /// + public static PixelRect ScreenToWorkspace(PixelRect r, MonitorArea m) => + r with { Left = r.Left - (m.Work.Left - m.Bounds.Left), Top = r.Top - (m.Work.Top - m.Bounds.Top) }; + + /// + public static PixelRect WorkspaceToScreen(PixelRect r, MonitorArea m) => + r with { Left = r.Left + (m.Work.Left - m.Bounds.Left), Top = r.Top + (m.Work.Top - m.Bounds.Top) }; + + /// Grows a too-small rect to the window's minimum size (capped at the work area). + private static PixelRect EnsureMinSize(PixelRect r, MonitorArea m, double minWidthDip, double minHeightDip) + { + int minW = Math.Min(m.Work.Width, (int)Math.Ceiling(minWidthDip * m.Dpi / 96.0)); + int minH = Math.Min(m.Work.Height, (int)Math.Ceiling(minHeightDip * m.Dpi / 96.0)); + return r with { Width = Math.Max(r.Width, minW), Height = Math.Max(r.Height, minH) }; + } + + private static MonitorArea? Nearest(PixelRect r, IReadOnlyList monitors) + { + long cx = r.Left + r.Width / 2, cy = r.Top + r.Height / 2; + return monitors + .OrderBy(m => + { + long dx = cx < m.Bounds.Left ? m.Bounds.Left - cx : cx > m.Bounds.Right ? cx - m.Bounds.Right : 0; + long dy = cy < m.Bounds.Top ? m.Bounds.Top - cy : cy > m.Bounds.Bottom ? cy - m.Bounds.Bottom : 0; + return dx * dx + dy * dy; + }) + .FirstOrDefault(); + } + + private static int Scale(int dip, int dpi) => Round(dip * Math.Max(dpi, 96) / 96.0); + private static int Round(double v) => (int)Math.Round(v, MidpointRounding.AwayFromZero); + private static bool IsFinite(double v) => !double.IsNaN(v) && !double.IsInfinity(v); +} diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs index 9aae0a9..995d4b5 100644 --- a/src/CodeShellManager/ViewModels/MainViewModel.cs +++ b/src/CodeShellManager/ViewModels/MainViewModel.cs @@ -279,9 +279,16 @@ public void SaveStateDebounced() public AppState CurrentState => _appState; /// Saves window position/size. Only updates NormalBounds when not maximized. - public void UpdateWindowState(System.Windows.WindowState windowState, double left, double top, double width, double height) - { - _appState.WindowMaximized = windowState == System.Windows.WindowState.Maximized; + /// + /// The normal bounds in physical pixels (from GetWindowPlacement — valid whatever the + /// current state), or null when the native read failed. Only replaced when non-null. + /// + /// Maximized now, or minimized from a maximized window. + public void UpdateWindowState(System.Windows.WindowState windowState, double left, double top, double width, double height, + Models.WindowPlacementState? placement, bool maximized) + { + _appState.WindowMaximized = maximized; + if (placement != null) _appState.LastPlacement = placement; if (windowState == System.Windows.WindowState.Normal) { _appState.LastNormalBounds = new Models.WindowBounds @@ -294,10 +301,6 @@ public void UpdateWindowState(System.Windows.WindowState windowState, double lef } } - public Models.WindowBounds? GetSavedWindowBounds() => _appState.LastNormalBounds; - - public bool IsWindowMaximized() => _appState.WindowMaximized; - // Called by the View after it has set up the WebView2 + bridge for a session public void RegisterSession(SessionViewModel vm) { diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index f56322d..f16841f 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -609,6 +609,27 @@ public void NotifyConfigChanged() OnPropertyChanged(nameof(WorktreeSubtitle)); } + /// + /// Returns , resolving it now if the poll has not got there yet. + /// The git busy slot is keyed by it, and falling back to the working folder in that + /// window let two panes of one repo hold different keys and run git at once (#181). + /// The probe runs on the pool — see "Never spawn a process on the UI thread". + /// + public async Task EnsureRepoRootAsync() + { + if (!string.IsNullOrEmpty(RepoRoot) || Session.Kind == SessionKind.Ssh) return RepoRoot; + string folder = Session.WorkingFolder; + string? root = await Task.Run(() => GitService.GetRepoRootAsync(folder)); + // The folder may have been edited during the await (ReloadGitInfoAsync clears RepoRoot): + // then this answer describes the OLD folder, and storing it would stick — nothing + // re-probes a non-null RepoRoot. Probe again for the folder as it is now (a folder + // edit is a user action, so this cannot spin). + if (!string.Equals(Session.WorkingFolder, folder, StringComparison.Ordinal)) + return await EnsureRepoRootAsync(); + if (!string.IsNullOrEmpty(root) && string.IsNullOrEmpty(RepoRoot)) RepoRoot = root; + return string.IsNullOrEmpty(RepoRoot) ? root : RepoRoot; + } + /// /// Drops the cached git info and re-probes it. resolves /// only once (it's stable for the life of a session), so a working diff --git a/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs index 7373cd3..bd46ee2 100644 --- a/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs +++ b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs @@ -127,4 +127,31 @@ public void TranslateUncArgToLinux_CaseInsensitiveDistroMatch() Assert.Equal("/home/alice", GitService.TranslateUncArgToLinux(@"\\wsl$\ubuntu\home\alice", "Ubuntu")); } + + // ── repo root from `rev-parse --git-common-dir` (#181) ─────────────────── + + [Fact] + public void RepoRoot_WslMainAndLinkedWorktree_ShareOneBusyKey() + { + // Main checkout: git answers relative to the cwd. + string? main = GitService.RepoRootFromCommonDir(".git\n", @"\\wsl$\Ubuntu\home\u\repo"); + // Linked worktree, Linux-absolute (as git prints it inside the distro). + string? linkedRaw = GitService.RepoRootFromCommonDir("/home/u/repo/.git\n", @"\\wsl$\Ubuntu\home\u\repo-wt"); + // Linked worktree, already mapped to UNC by RunGitInWslAsync's stdout translation. + string? linkedUnc = GitService.RepoRootFromCommonDir(@"\\wsl$\Ubuntu\home\u\repo\.git", @"\\wsl.localhost\Ubuntu\home\u\repo-wt"); + + Assert.Equal("//wsl$/Ubuntu/home/u/repo", main); + Assert.Equal(main, linkedRaw); + Assert.Equal(main, linkedUnc); + Assert.Equal(GitWorktrees.BusyKey(main, null), GitWorktrees.BusyKey(linkedRaw, null)); + } + + [Theory] + [InlineData(".git", @"C:\src\app", "C:/src/app")] + [InlineData("C:/src/app/.git", @"C:\src\app-wt", "C:/src/app")] + [InlineData(@"C:\src\bare.git", @"C:\src\bare.git", "C:/src/bare")] + [InlineData("", @"C:\src\app", null)] + [InlineData(null, @"C:\src\app", null)] + public void RepoRoot_LocalShapes(string? commonDir, string folder, string? expected) => + Assert.Equal(expected, GitService.RepoRootFromCommonDir(commonDir, folder)); } diff --git a/tests/CodeShellManager.Tests/GitToolsTests.cs b/tests/CodeShellManager.Tests/GitToolsTests.cs index 48e18f9..8bfcb46 100644 --- a/tests/CodeShellManager.Tests/GitToolsTests.cs +++ b/tests/CodeShellManager.Tests/GitToolsTests.cs @@ -98,6 +98,67 @@ public void Unmerged_entry_is_listed_as_conflict() Assert.Equal("conflicted file.txt", c.Path); } + [Fact] + public void Unmerged_entries_keep_their_real_code() + { + // Shape as git 2.54 prints it for a merge with every kind of conflict (#181: all of + // these used to be shown as "UU"). + const string z = "0000000000000000000000000000000000000000"; + const string h = "d00491fd7e5bb6fa28c517a0bb32b8b506539d4d"; + var s = GitService.ParseStatusV2( + "# branch.head main\n" + + $"u AA N... 000000 100644 100644 100644 {z} {h} {h} added.txt\n" + + $"u DU N... 100644 000000 100644 100644 {h} {z} {h} deleted by us.txt\n" + + $"u UD N... 100644 100644 000000 100644 {h} {h} {z} delthem.txt\n" + + $"u AU N... 000000 100644 100644 100644 {z} {h} {h} au.txt\n" + + $"u UA N... 000000 100644 100644 100644 {z} {h} {h} ua.txt\n" + + $"u DD N... 100644 000000 000000 000000 {h} {z} {z} dd.txt\n" + + $"u UU N... 100644 100644 100644 100644 {h} {h} {h} mod.txt\n"); + Assert.Equal(new[] { "AA", "DU", "UD", "AU", "UA", "DD", "UU" }, s.Changes.Select(c => c.Code)); + Assert.Equal("deleted by us.txt", s.Changes[1].Path); + } + + [Fact] + public void Quoted_paths_are_unquoted() + { + // git C-quotes these even with core.quotePath=false: '"', '\', and control chars. + const string h = "587be6b4c3f93f93c489c0111bba5596147a26cb"; + var s = GitService.ParseStatusV2( + "# branch.head main\n" + + $"1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 {h} \"q\\\"uote.txt\"\n" + + $"1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 {h} \"tab\\there.txt\"\n" + + $"1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 {h} \"back\\\\slash.txt\"\n" + + $"1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 {h} \"new\\nline.txt\"\n" + + $"1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 {h} \"æø \\\"q\\\".txt\"\n" + + $"2 R. N... 100644 100644 100644 {h} {h} R100 \"to \\\"x\\\".txt\"\tfrom\\plain.txt\n" + + "? \"un\\ttracked\"\n"); + Assert.Equal(new[] + { + "q\"uote.txt", "tab\there.txt", "back\\slash.txt", "new\nline.txt", "æø \"q\".txt", + "from\\plain.txt → to \"x\".txt", "un\ttracked", + }, s.Changes.Select(c => c.Path)); + } + + [Theory] + [InlineData("plain.txt", "plain.txt")] + [InlineData("\"\"", "")] + [InlineData("\"a\\\"b\"", "a\"b")] + [InlineData("\"a\\\\b\"", "a\\b")] + [InlineData("\"\\a\\b\\t\\n\\v\\f\\r\"", "\a\b\t\n\v\f\r")] + // Octal escapes are UTF-8 bytes: \303\246\303\270 is "æø" (what quotePath=true prints). + [InlineData("\"\\303\\246\\303\\270.txt\"", "æø.txt")] + // Mixed: raw non-ASCII next to escaped bytes still decodes as one UTF-8 sequence. + [InlineData("\"æ\\303\\270\"", "æø")] + // Malformed / unknown escapes are kept, not dropped. + [InlineData("\"a\\qb\"", "a\\qb")] + [InlineData("\"a\\9b\"", "a\\9b")] + [InlineData("\"trailing\\\"", "trailing\\")] + // Only fully wrapped values are quoted. + [InlineData("\"half", "\"half")] + [InlineData("half\"", "half\"")] + public void UnquoteCPath(string quoted, string expected) => + Assert.Equal(expected, GitService.UnquoteCPath(quoted)); + [Fact] public void Unrecognised_lines_are_ignored_not_fatal() { @@ -392,6 +453,70 @@ public async Task Fetch_from_an_unreachable_https_remote_fails_fast_instead_of_p Assert.True(sw.Elapsed < TimeSpan.FromSeconds(30), $"took {sw.Elapsed}"); } + private static int GitExit(string cwd, params string[] args) + { + var psi = new ProcessStartInfo("git") + { + WorkingDirectory = cwd, RedirectStandardOutput = true, RedirectStandardError = true, + UseShellExecute = false, CreateNoWindow = true, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + using var p = Process.Start(psi)!; + p.StandardOutput.ReadToEnd(); + p.StandardError.ReadToEnd(); + p.WaitForExit(); + return p.ExitCode; + } + + [Fact] + public async Task Status_reports_each_kind_of_conflict_by_its_own_code() + { + void Write(string f, string c) => File.WriteAllText(Path.Combine(Work, f), c); + Write("mod.txt", "base\n"); Write("delus.txt", "base\n"); Write("delthem.txt", "base\n"); + Git(Work, "add", "."); + Git(Work, "commit", "-q", "-m", "base"); + Git(Work, "switch", "-q", "-c", "other"); + Write("mod.txt", "other\n"); Write("added.txt", "other\n"); Write("delus.txt", "other\n"); + Git(Work, "rm", "-q", "delthem.txt"); + Git(Work, "add", "."); + Git(Work, "commit", "-q", "-m", "other"); + Git(Work, "switch", "-q", "main"); + Write("mod.txt", "main\n"); Write("added.txt", "main\n"); Write("delthem.txt", "main\n"); + Git(Work, "rm", "-q", "delus.txt"); + Git(Work, "add", "."); + Git(Work, "commit", "-q", "-m", "main"); + Assert.NotEqual(0, GitExit(Work, "-c", "core.autocrlf=false", "merge", "other")); + + var s = await GitService.GetStatusAsync(Work); + Assert.NotNull(s); + var codes = s!.Changes.ToDictionary(c => c.Path, c => c.Code); + Assert.Equal("UU", codes["mod.txt"]); + Assert.Equal("AA", codes["added.txt"]); + Assert.Equal("DU", codes["delus.txt"]); + Assert.Equal("UD", codes["delthem.txt"]); + } + + [Fact] + public async Task Status_unquotes_names_git_still_quotes() + { + // Windows can't hold these names on disk, but the index can — staged with + // update-index, they come back from status as C-quoted paths. + string blob = Path.Combine(_root, "blob.txt"); + File.WriteAllText(blob, "x\n"); + string h = Git(Work, "hash-object", "-w", blob).Trim(); + string[] names = { "q\"uote.txt", "tab\there.txt", "back\\slash.txt", "new\nline.txt", "two \"q\" s.txt" }; + // Non-ASCII is covered by the parser tests: passed through argv it reaches git mangled + // on some Windows setups, which would test this harness rather than the parser. + foreach (var n in names) + // Git for Windows refuses such names by default (core.protectNTFS). + Git(Work, "-c", "core.protectNTFS=false", "update-index", "--add", "--cacheinfo", $"100644,{h},{n}"); + + var s = await GitService.GetStatusAsync(Work); + Assert.NotNull(s); + var paths = s!.Changes.Select(c => c.Path).ToHashSet(); + foreach (var n in names) Assert.Contains(n, paths); + } + [Fact] public async Task StatusText_is_git_status_prose() { diff --git a/tests/CodeShellManager.Tests/GitWorktreeTests.cs b/tests/CodeShellManager.Tests/GitWorktreeTests.cs index 492cb13..5beebfe 100644 --- a/tests/CodeShellManager.Tests/GitWorktreeTests.cs +++ b/tests/CodeShellManager.Tests/GitWorktreeTests.cs @@ -16,6 +16,32 @@ private static WorktreeInfo Wt(string path, string? branch = null, bool bare = f bool detached = false, bool locked = false, bool prunable = false) => new(path, branch, bare, detached, locked, prunable); + // ── git busy-slot key (#181) ────────────────────────────────────────────── + + [Fact] + public void BusyKey_prefers_the_repo_root_over_the_folder() + { + // Two worktrees of one repo: different folders, one RepoRoot (the main working tree). + Assert.Equal(GitWorktrees.BusyKey("C:/src/app", @"C:\src\app-wt-feature"), + GitWorktrees.BusyKey("C:/src/app", @"C:\src\app\sub")); + Assert.Equal(GitWorktrees.BusyKey(null, @"C:\src\app"), GitWorktrees.BusyKey("", "C:/src/app/")); + } + + [Fact] + public void BusyKey_folds_spelling_and_case_on_Windows() + { + Assert.Equal(GitWorktrees.BusyKey(@"C:\Src\App\", null), GitWorktrees.BusyKey("c:/src/app", null)); + } + + [Fact] + public void BusyKey_is_case_sensitive_inside_WSL_but_not_for_the_distro() + { + Assert.Equal(GitWorktrees.BusyKey(@"\\wsl.localhost\Ubuntu\home\u\repo", null), + GitWorktrees.BusyKey(@"\\wsl$\ubuntu\home\u\repo", null)); + Assert.NotEqual(GitWorktrees.BusyKey(@"\\wsl$\Ubuntu\home\u\Repo", null), + GitWorktrees.BusyKey(@"\\wsl$\Ubuntu\home\u\repo", null)); + } + [Theory] [InlineData("C:/repo", "C:/repo", true)] [InlineData("C:/repo/src/app", "C:/repo", true)] @@ -173,6 +199,34 @@ private static string Git(string cwd, params string[] args) return o; } + [Fact] + public async Task Two_panes_of_one_repo_claim_the_same_busy_slot_before_RepoRoot_lands() + { + // #181: the busy slot used to be keyed by the folder until the poll set RepoRoot, so a + // pane in the main checkout and one in a linked worktree held different keys. + AddWorktree("wt-feat", "feat"); + string wt = WtPath("wt-feat"); + Assert.NotEqual(GitWorktrees.BusyKey(null, Main), GitWorktrees.BusyKey(null, wt)); + + var a = new CodeShellManager.ViewModels.SessionViewModel( + new CodeShellManager.Models.ShellSession { Id = "a", WorkingFolder = Main, Command = "cmd" }); + var b = new CodeShellManager.ViewModels.SessionViewModel( + new CodeShellManager.Models.ShellSession { Id = "b", WorkingFolder = wt, Command = "cmd" }); + try + { + // What MainWindow.ResolveGitRepoKeyAsync does before claiming the slot. + string ka = GitWorktrees.BusyKey(await a.EnsureRepoRootAsync(), a.WorkingFolder); + string kb = GitWorktrees.BusyKey(await b.EnsureRepoRootAsync(), b.WorkingFolder); + Assert.Equal(ka, kb); + Assert.Equal(GitWorktrees.BusyKey(Main, null), ka); + } + finally + { + a.Dispose(); + b.Dispose(); + } + } + [Fact] public async Task Listing_maps_branches_to_worktrees_and_finds_the_current_one() { diff --git a/tests/CodeShellManager.Tests/StateServiceTests.cs b/tests/CodeShellManager.Tests/StateServiceTests.cs index 2f55388..a37524f 100644 --- a/tests/CodeShellManager.Tests/StateServiceTests.cs +++ b/tests/CodeShellManager.Tests/StateServiceTests.cs @@ -47,6 +47,49 @@ private static AppState WithSessions(params string[] ids) // ── atomic write + backup ──────────────────────────────────────────────── + // ── synchronous window-bounds read (#181) ─────────────────────────────── + + [Fact] + public void ReadWindowState_FirstRun_IsNull() + { + Assert.Null(StateService.TryReadWindowState()); + } + + [Fact] + public async Task ReadWindowState_RoundTripsPlacementAndLegacyBounds() + { + var s = WithSessions("a"); + s.WindowMaximized = true; + s.LastNormalBounds = new WindowBounds { Left = 10, Top = 20, Width = 800, Height = 600 }; + s.LastPlacement = new WindowPlacementState { Left = 2000, Top = 100, Width = 1500, Height = 900, Dpi = 144 }; + await _svc.SaveAsync(s); + + var w = StateService.TryReadWindowState(); + Assert.NotNull(w); + Assert.True(w!.WindowMaximized); + Assert.Equal(800, w.LastNormalBounds!.Width); + Assert.Equal((2000, 100, 1500, 900, 144), + (w.LastPlacement!.Left, w.LastPlacement.Top, w.LastPlacement.Width, w.LastPlacement.Height, w.LastPlacement.Dpi)); + } + + [Fact] + public void ReadWindowState_OldFileWithoutPlacement_StillReadsLegacyBounds() + { + File.WriteAllText(_path, + "{ \"Sessions\": [], \"LastNormalBounds\": { \"Left\": 1, \"Top\": 2, \"Width\": 1280, \"Height\": 800 }, \"WindowMaximized\": false }"); + var w = StateService.TryReadWindowState(); + Assert.Null(w!.LastPlacement); + Assert.Equal(1280, w.LastNormalBounds!.Width); + } + + [Fact] + public void ReadWindowState_FallsBackToBackup_WhenPrimaryIsTorn() + { + File.WriteAllText(_path, "{ \"Sessions\": [ { \"Id\": "); + File.WriteAllText(_path + ".bak", "{ \"WindowMaximized\": true }"); + Assert.True(StateService.TryReadWindowState()!.WindowMaximized); + } + [Fact] public async Task Save_FirstWrite_CreatesFileAndNoBackup() { diff --git a/tests/CodeShellManager.Tests/WindowPlacementMathTests.cs b/tests/CodeShellManager.Tests/WindowPlacementMathTests.cs new file mode 100644 index 0000000..ad78fda --- /dev/null +++ b/tests/CodeShellManager.Tests/WindowPlacementMathTests.cs @@ -0,0 +1,276 @@ +using CodeShellManager.Models; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// Restoring the main window's bounds under per-monitor DPI v2 (#181). All coordinates are +/// physical pixels. The standard rig: a 1920x1080 primary at 100% with a 48px taskbar at the +/// bottom, and a 3840x2160 secondary at 150% to its right. +/// +public class WindowPlacementMathTests +{ + private static readonly MonitorArea Primary = new( + new PixelRect(0, 0, 1920, 1080), new PixelRect(0, 0, 1920, 1032), 96, IsPrimary: true); + + private static readonly MonitorArea Secondary = new( + new PixelRect(1920, 0, 3840, 2160), new PixelRect(1920, 0, 3840, 2088), 144, IsPrimary: false); + + private static readonly MonitorArea[] Both = { Primary, Secondary }; + + private static WindowPlacementState P(int l, int t, int w, int h, int dpi) => + new() { Left = l, Top = t, Width = w, Height = h, Dpi = dpi }; + + [Fact] + public void Nothing_saved_resolves_to_null() + { + Assert.Null(WindowPlacementMath.Resolve(null, null, Both)); + Assert.Null(WindowPlacementMath.Resolve(P(0, 0, 0, 0, 96), null, Both)); + Assert.Null(WindowPlacementMath.Resolve(null, new WindowBounds { Width = double.NaN, Height = 10 }, Both)); + } + + [Fact] + public void No_monitors_resolves_to_null() + { + Assert.Null(WindowPlacementMath.Resolve(P(10, 10, 800, 600, 96), null, System.Array.Empty())); + } + + [Fact] + public void Placement_on_the_secondary_is_restored_exactly() + { + // The drift case: same pixels back, every run, on a monitor whose scale differs from + // the primary's. + var r = WindowPlacementMath.Resolve(P(2200, 300, 2400, 1500, 144), null, Both)!.Value; + Assert.Equal(new PixelRect(2200, 300, 2400, 1500), r.Rect); + Assert.Same(Secondary, r.Monitor); + } + + // A rig where the workspace offsets differ per monitor: primary with its taskbar docked + // at the TOP (workspace (0,0) = screen (0,48)), 150% secondary with a left taskbar. + private static readonly MonitorArea TopBarPrimary = new( + new PixelRect(0, 0, 1920, 1080), new PixelRect(0, 48, 1920, 1032), 96, IsPrimary: true); + + private static MonitorArea LeftBarSecondary(int dpi) => new( + new PixelRect(1920, 0, 3840, 2160), new PixelRect(1992, 0, 3768, 2160), dpi, IsPrimary: false); + + /// + /// One app run: restore what was saved, then save what is on screen the way + /// MainWindow.CaptureWindowState does — GetWindowRect for a normal window, the workspace + /// rcNormalPosition (as SetWindowPlacement was given it) for a maximized one. + /// + private static WindowPlacementState Run(WindowPlacementState saved, MonitorArea[] monitors, bool maximized) + { + var (rect, monitor) = WindowPlacementMath.Resolve(saved, null, monitors)!.Value; + return maximized + ? WindowPlacementMath.SaveFromWorkspace(WindowPlacementMath.ScreenToWorkspace(rect, monitor), monitors)! + : WindowPlacementMath.SaveFromScreen(rect, monitors)!; + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Round_trip_is_stable_across_runs_with_per_monitor_offsets(bool maximized) + { + var monitors = new[] { TopBarPrimary, LeftBarSecondary(144) }; + foreach (var start in new[] { P(2500, 400, 1800, 1200, 144), P(100, 200, 1000, 700, 96) }) + { + var p = start; + for (int run = 0; run < 10; run++) p = Run(p, monitors, maximized); + Assert.Equal((start.Left, start.Top, start.Width, start.Height, start.Dpi), + (p.Left, p.Top, p.Width, p.Height, p.Dpi)); + } + } + + [Fact] + public void Monitor_scale_change_rescales_once_then_stays_put() + { + var p = Run(P(2500, 400, 1800, 1200, 144), new[] { TopBarPrimary, LeftBarSecondary(144) }, false); + // The secondary goes 150% -> 200%: same apparent size, applied once. + var after = new[] { TopBarPrimary, LeftBarSecondary(192) }; + p = Run(p, after, false); + Assert.Equal((2500, 400, 2400, 1600, 192), (p.Left, p.Top, p.Width, p.Height, p.Dpi)); + for (int run = 0; run < 5; run++) p = Run(p, after, false); + Assert.Equal((2500, 400, 2400, 1600, 192), (p.Left, p.Top, p.Width, p.Height, p.Dpi)); + } + + [Fact] + public void Saved_normal_rect_takes_offset_and_DPI_from_the_monitor_it_is_on() + { + // A maximized window on the PRIMARY whose normal (pre-maximize / pre-snap) rect is on + // the secondary: taking offset and DPI from the window's current monitor would give + // top 348 at 96 dpi — the wrong place, and a Rescale that shrinks it next run. + var monitors = new[] { TopBarPrimary, LeftBarSecondary(144) }; + var s = WindowPlacementMath.SaveFromWorkspace(new PixelRect(2128, 300, 1500, 900), monitors)!; + Assert.Equal((2200, 300, 1500, 900, 144), (s.Left, s.Top, s.Width, s.Height, s.Dpi)); + + // A rect on the primary picks up the primary's top-taskbar offset instead. + s = WindowPlacementMath.SaveFromWorkspace(new PixelRect(100, 100, 800, 600), monitors)!; + Assert.Equal((100, 148, 96), (s.Left, s.Top, s.Dpi)); + } + + [Fact] + public void Saved_screen_rect_takes_DPI_from_the_monitor_holding_most_of_it() + { + var s = WindowPlacementMath.SaveFromScreen(new PixelRect(1500, 100, 1200, 800), Both)!; + Assert.Equal(144, s.Dpi); + s = WindowPlacementMath.SaveFromScreen(new PixelRect(1000, 100, 1200, 800), Both)!; + Assert.Equal(96, s.Dpi); + // Off every monitor: the nearest one's. + s = WindowPlacementMath.SaveFromScreen(new PixelRect(9000, 100, 800, 600), Both)!; + Assert.Equal(144, s.Dpi); + } + + [Fact] + public void A_window_spanning_both_monitors_is_left_alone() + { + // Mostly on the secondary, so that is the DPI it was saved at. + var r = WindowPlacementMath.Resolve(P(1500, 100, 1200, 800, 144), null, Both)!.Value; + Assert.Equal(new PixelRect(1500, 100, 1200, 800), r.Rect); + Assert.Same(Secondary, r.Monitor); + } + + [Fact] + public void Monitor_scale_change_keeps_the_apparent_size() + { + // Saved at 100% on the secondary, which is now at 150%: 1.5x the pixels, same corner. + var r = WindowPlacementMath.Resolve(P(2000, 100, 1000, 800, 96), null, Both)!.Value; + Assert.Equal(new PixelRect(2000, 100, 1500, 1200), r.Rect); + } + + [Fact] + public void Unplugged_monitor_falls_back_to_a_visible_position() + { + // Saved on the secondary; now only the primary exists. + var r = WindowPlacementMath.Resolve(P(2200, 300, 1500, 1000, 144), null, new[] { Primary })!.Value; + Assert.Same(Primary, r.Monitor); + // 1500x1000 @144 → 1000x667 @96, pushed to the nearest (right) edge of the work area. + Assert.Equal(new PixelRect(920, 300, 1000, 667), r.Rect); + Assert.True(WindowPlacementMath.IsCaptionVisible(r.Rect, 96, new[] { Primary })); + } + + [Fact] + public void Monitor_left_of_primary_gone_lands_on_the_left_edge() + { + var r = WindowPlacementMath.Resolve(P(-1700, 200, 1200, 800, 96), null, new[] { Primary })!.Value; + Assert.Equal(new PixelRect(0, 200, 1200, 800), r.Rect); + } + + [Fact] + public void Caption_above_every_work_area_is_pulled_down() + { + // Body on screen, title bar not: unreachable with the mouse. + var r = WindowPlacementMath.Resolve(P(100, -200, 1000, 700, 96), null, new[] { Primary })!.Value; + Assert.Equal(new PixelRect(100, 0, 1000, 700), r.Rect); + } + + [Fact] + public void Caption_under_the_taskbar_is_pulled_up() + { + // Top edge inside the taskbar band (1032..1080): on the monitor, not on its work area. + var r = WindowPlacementMath.Resolve(P(100, 1040, 800, 600, 96), null, new[] { Primary })!.Value; + Assert.Equal(new PixelRect(100, 432, 800, 600), r.Rect); + } + + [Fact] + public void Slightly_off_edge_window_is_kept_where_it_is() + { + var r = WindowPlacementMath.Resolve(P(-8, -4, 1000, 700, 96), null, new[] { Primary })!.Value; + Assert.Equal(new PixelRect(-8, -4, 1000, 700), r.Rect); + } + + [Fact] + public void Off_screen_window_larger_than_the_work_area_is_shrunk_to_fit() + { + var r = WindowPlacementMath.Resolve(P(5000, 5000, 3000, 2000, 96), null, new[] { Primary })!.Value; + Assert.Equal(Primary.Work, r.Rect); + } + + [Fact] + public void Minimum_size_is_enforced_in_physical_pixels() + { + var r = WindowPlacementMath.Resolve(P(2000, 100, 300, 200, 144), null, Both, minWidthDip: 800, minHeightDip: 500)!.Value; + Assert.Equal(new PixelRect(2000, 100, 1200, 750), r.Rect); + } + + [Fact] + public void Legacy_DIP_bounds_are_scaled_by_the_primary_DPI() + { + // Saved by a system-DPI build at 150% primary: DIPs × 1.5 = pixels. + var primary150 = Primary with { Dpi = 144 }; + var r = WindowPlacementMath.Resolve(null, + new WindowBounds { Left = 100, Top = 50, Width = 1000, Height = 600 }, new[] { primary150 })!.Value; + Assert.Equal(new PixelRect(150, 75, 1500, 900), r.Rect); + } + + [Fact] + public void Legacy_v0_10_bounds_on_a_150_percent_secondary_land_back_on_it() + { + // v0.10.0 (per-monitor v2) saved DIPs = pixels × 96 / THAT monitor's DPI. A window at + // pixels (2200, 300, 2400x1500) on the 150% secondary was saved as (1466.67, 200, + // 1600x1000). Read at the primary's 100% it would land mostly on the primary at + // two-thirds the size. + var r = WindowPlacementMath.Resolve(null, + new WindowBounds { Left = 2200 / 1.5, Top = 200, Width = 1600, Height = 1000 }, Both)!.Value; + Assert.Equal(new PixelRect(2200, 300, 2400, 1500), r.Rect); + Assert.Same(Secondary, r.Monitor); + } + + [Fact] + public void Legacy_bounds_on_the_primary_stay_on_the_primary() + { + var r = WindowPlacementMath.Resolve(null, + new WindowBounds { Left = 100, Top = 100, Width = 1000, Height = 700 }, Both)!.Value; + Assert.Equal(new PixelRect(100, 100, 1000, 700), r.Rect); + Assert.Same(Primary, r.Monitor); + } + + [Fact] + public void Legacy_bounds_ambiguous_between_monitors_prefer_the_primary() + { + // DIP (1300, 100, 400x300) is consistent both as 100% pixels on the primary and as + // 150% pixels (1950, 150, ...) on the secondary. Nothing in the file says which. + var r = WindowPlacementMath.Resolve(null, + new WindowBounds { Left = 1300, Top = 100, Width = 400, Height = 300 }, Both)!.Value; + Assert.Same(Primary, r.Monitor); + Assert.Equal(new PixelRect(1300, 100, 400, 300), r.Rect); + } + + [Fact] + public void Legacy_bounds_far_off_screen_still_produce_a_visible_window() + { + var r = WindowPlacementMath.Resolve(null, + new WindowBounds { Left = 9000, Top = 9000, Width = 1280, Height = 800 }, new[] { Primary })!.Value; + Assert.True(WindowPlacementMath.IsCaptionVisible(r.Rect, 96, new[] { Primary })); + Assert.Equal(new PixelRect(640, 232, 1280, 800), r.Rect); + } + + [Fact] + public void Placement_wins_over_legacy_bounds() + { + var r = WindowPlacementMath.Resolve(P(2200, 300, 2400, 1500, 144), + new WindowBounds { Left = 0, Top = 0, Width = 500, Height = 500 }, Both)!.Value; + Assert.Equal(new PixelRect(2200, 300, 2400, 1500), r.Rect); + } + + [Fact] + public void Workspace_conversion_round_trips_and_follows_a_top_taskbar() + { + // Taskbar docked at the top: workspace (0,0) is screen (0,48). + var topBar = new MonitorArea(new PixelRect(0, 0, 1920, 1080), new PixelRect(0, 48, 1920, 1032), 96, true); + var screen = new PixelRect(100, 148, 800, 600); + var ws = WindowPlacementMath.ScreenToWorkspace(screen, topBar); + Assert.Equal(new PixelRect(100, 100, 800, 600), ws); + Assert.Equal(screen, WindowPlacementMath.WorkspaceToScreen(ws, topBar)); + } + + [Theory] + [InlineData(96, 144, 1000, 1500)] + [InlineData(144, 96, 1500, 1000)] + [InlineData(120, 120, 1000, 1000)] + public void Rescale_scales_size_only(int from, int to, int w, int expectedW) + { + var r = WindowPlacementMath.Rescale(new PixelRect(7, 9, w, w), from, to); + Assert.Equal(new PixelRect(7, 9, expectedW, expectedW), r); + } +}