diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 3e9127a2..864b9fa9 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -36,6 +36,87 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate } throw "RED: multi-project expected actual $prefix rejection" } + $selectionAst = [Management.Automation.Language.Parser]::ParseInput($gateSource, [ref]$null, [ref]$null) + $observer = $selectionAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Get-MultiProjectObservation" + }, $true) + $selectionCheck = @($observer.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Extent.Text.Contains('$projectSelected -eq') -and $node.Extent.Text.Contains('$loopSelected -eq') + }, $true)) + Assert-MultiCase ($selectionCheck.Count -eq 1) "owner selection predicate is not uniquely source-derived" + $ownerSelectionPredicate = [scriptblock]::Create($selectionCheck[0].Extent.Text) + foreach ($case in @( + @{ surface = "project"; selected = $true; project = $true; loop = $false; accepted = $true }, + @{ surface = "workspace"; selected = $true; project = $false; loop = $true; accepted = $true }, + @{ surface = "project"; selected = $true; project = $true; loop = $true; accepted = $false }, + @{ surface = "workspace"; selected = $true; project = $true; loop = $true; accepted = $false }, + @{ surface = "project"; selected = $true; project = $false; loop = $true; accepted = $false }, + @{ surface = "workspace"; selected = $true; project = $true; loop = $false; accepted = $false }, + @{ surface = "project"; selected = $false; project = $true; loop = $false; accepted = $false }, + @{ surface = "workspace"; selected = $false; project = $false; loop = $true; accepted = $false }, + @{ surface = "project"; selected = $false; project = $false; loop = $false; accepted = $true } + )) { + Invoke-MultiCase ("destination-scoped sidebar owner " + ($case | ConvertTo-Json -Compress)) -Negative:(-not $case.accepted) { + $surface = $case.surface; $selected = $case.selected + $projectSelected = $case.project; $loopSelected = $case.loop; $matches = $true + . $ownerSelectionPredicate + Assert-MultiCase ($matches -eq $case.accepted) "sidebar owner selection contradicts its destination" + return "Source-derived owner predicate only; exactly-one roster and native UIA remain runtime evidence." + } + } + $selectionCountCheck = @($observer.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Extent.Text.Contains('$selectedSidebarIds.Count -eq 1') + }, $true)) + Assert-MultiCase ($selectionCountCheck.Count -eq 1) "sidebar cardinality predicate is not uniquely source-derived" + $sidebarSelectionPredicate = [scriptblock]::Create($selectionCountCheck[0].Extent.Text) + foreach ($case in @( + @{ ids = @("expected"); accepted = $true }, + @{ ids = @(); accepted = $false }, + @{ ids = @("wrong"); accepted = $false }, + @{ ids = @("expected", "wrong"); accepted = $false }, + @{ ids = @("expected", "expected"); accepted = $false } + )) { + Invoke-MultiCase ("exact sidebar selection " + ($case | ConvertTo-Json -Compress)) -Negative:(-not $case.accepted) { + $selectedSidebarIds = $case.ids; $expectedSidebarId = "expected"; $matches = $true + . $sidebarSelectionPredicate + Assert-MultiCase ($matches -eq $case.accepted) "sidebar accepts an absent, wrong or duplicate selection" + return "Source-derived count/identity predicate; no live UIA claim." + } + } + $projectSelection = [ordered]@{ automationId = "open-project-1"; name = "Selected project" } + $loopSelection = [ordered]@{ automationId = "loop-row-2"; name = "Wrong loop" } + $attentionSelection = [ordered]@{ automationId = "needs-you-row-3"; name = "Selected attention alias" } + foreach ($case in @( + @{ rows = @($projectSelection); accepted = $true; extras = 0 }, + @{ rows = @($projectSelection, $attentionSelection); accepted = $true; extras = 1 }, + @{ rows = @(); accepted = $false; extras = 0 }, + @{ rows = @($loopSelection); accepted = $false; extras = 0 }, + @{ rows = @($projectSelection, $loopSelection); accepted = $false; extras = 0 }, + @{ rows = @($attentionSelection); accepted = $false; extras = 1 } + )) { + Invoke-MultiCase ("navigation selection excludes logged extras " + ($case | ConvertTo-Json -Compress)) -Negative:(-not $case.accepted) { + $output = @(Get-MultiProjectSidebarSelection $case.rows 6>&1) + $selection = @($output | Where-Object { $_ -is [Collections.IDictionary] }) + $logs = @($output | Where-Object { $_ -is [Management.Automation.InformationRecord] }) + Assert-MultiCase ($selection.Count -eq 1) "navigation classification omitted its result" + $selectedSidebarIds = @($selection[0].navigation | ForEach-Object { $_.automationId }) + $expectedSidebarId = $projectSelection.automationId; $matches = $true + . $sidebarSelectionPredicate + Assert-MultiCase ($matches -eq $case.accepted -and $selection[0].extras.Count -eq $case.extras) ` + "navigation cardinality counted a retained alias or accepted wrong/missing navigation" + Assert-MultiCase ($logs.Count -eq $case.extras) "retained alias was not logged exactly once" + if ($case.extras -gt 0) { + Assert-MultiCase ($logs[0].ToString().Contains($attentionSelection.automationId) -and + $logs[0].ToString().Contains($attentionSelection.name)) "alias log omitted exact ID or name" + } + return "Source-derived navigation predicate; retained extras logged, native static selection deferred to #801." + } + } $eventStart = [DateTime]::Parse("2026-10-01T18:29:43.1462496Z").ToUniversalTime() $eventEnd = $eventStart.AddSeconds(1) $eventProcess = [pscustomobject]@{ Id = 5832; StartTime = $eventStart } @@ -1170,6 +1251,42 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate $captureReader = { param($element, [bool] $includeContent) $element.Capture($includeContent) } $capturePrior = @{ ownership = "owned"; finalPID = @{ value = 4242 } automationID = @{ value = "canvas-card-1983941480823304696" } } + foreach ($clipped in @("empty", "zero")) { + Invoke-MultiCase ("selected alias name tolerates " + $clipped + " bounds") { + $states = @((New-MultiCaptureState),(New-MultiCaptureState),(New-MultiCaptureState)) + $states[1].rect = if ($clipped -ceq "empty") { [System.Windows.Rect]::Empty } else { [System.Windows.Rect]::new(0,0,0,0) } + $mock = New-MultiCaptureMock $states + $before = Read-MultiProjectOwnershipSnapshot $mock $captureReader + $name = Read-MultiProjectSelectedName $mock $before 4242 $captureReader + Assert-MultiCase ($name -ceq "Alpha loop" -and $mock.calls -eq 3 -and + -not ($mock.order -contains "BOUNDS")) "clipped alias rejected or bounds read for name-only logging" + return "Owned cached ID/runtime verified before name and after; no geometry or live UIA claim." + } + } + foreach ($mutation in @("foreign", "zero", "identity", "runtime", "name-type", "name-unsupported", "after-foreign", "after-runtime")) { + Invoke-MultiCase ("selected alias name rejects " + $mutation) -Negative { + $states = @((New-MultiCaptureState),(New-MultiCaptureState),(New-MultiCaptureState)) + switch ($mutation) { + "foreign" { $states[1].pid = 7777 } + "zero" { $states[1].pid = 0 } + "identity" { $states[1].id = "different-row" } + "runtime" { $states[1].runtime = @(3,42,99) } + "name-type" { $states[1].name = 7 } + "name-unsupported" { $states[1].name = [System.Windows.Automation.AutomationElement]::NotSupported } + "after-foreign" { $states[2].pid = 7777 } + "after-runtime" { $states[2].runtime = @(3,42,99) } + } + $mock = New-MultiCaptureMock $states + $before = Read-MultiProjectOwnershipSnapshot $mock $captureReader + $prefix = if ($mutation -cin @("foreign", "after-foreign")) { "MULTIPROJECT_PRIVACY:" } else { "MULTIPROJECT_METADATA:" } + $rejection = Reject-MultiCase { Read-MultiProjectSelectedName $mock $before 4242 $captureReader } $prefix + Assert-MultiCase (-not ($mock.order -contains "BOUNDS")) "name-only logging read bounds" + if ($mutation -cin @("foreign", "zero", "identity", "runtime")) { + Assert-MultiCase (-not ($mock.order -contains "NAME")) "selected alias name read before cached ownership guard" + } + return $rejection + } + } Invoke-MultiCase "fresh cached capture verifies owned metadata before and after content" { $mock = New-MultiCaptureMock @((New-MultiCaptureState),(New-MultiCaptureState),(New-MultiCaptureState)) $evidence = Get-MultiProjectElementEvidence $mock 4242 $capturePrior "functional/owned" $captureReader diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 80e1e4b6..de19fd3b 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -3224,6 +3224,23 @@ function Test-MultiProjectFragmentRoster($actual, $expected, [int] $processId) { return $true } +function Get-MultiProjectSidebarSelection($selectedRows) { + $navigation = @() + $extras = @() + foreach ($row in $selectedRows) { + if ($row.automationId -cin @("overview-destination", "quick-chats-destination") -or + $row.automationId -cmatch '^(open-project|loop-row|quick-chat-row|quick-chats-header)-[0-9]+$') { + $navigation += $row + } else { + $extras += $row + } + } + if ($extras.Count -gt 0) { + Write-Host ("UIA_SIDEBAR_EXTRA_SELECTIONS=" + ($extras | ConvertTo-Json -Compress)) + } + return [ordered]@{ navigation = $navigation; extras = $extras } +} + function Get-MultiProjectExpectedCanvasRoster([string] $surface, $owners, $selectedOwner) { if ($surface -ceq "overview") { return ,@($owners | ForEach-Object { @@ -4496,6 +4513,18 @@ function Invoke-MultiProjectRenamePhase { $element.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() } function Get-MultiProjectObservation([string] $surface, $selectedOwner = $null) { + function Read-MultiProjectSelectedName( + $element, $before, [int] $processId, [scriptblock] $cacheReader = $null + ) { + $selectionCache = if ($cacheReader) { & $cacheReader $element $true } else { Read-MultiProjectElementCache $element $true } + $cachedOwner = Read-MultiProjectOwnershipSnapshot $element { param($ignored, $includeContent) $selectionCache } + Confirm-MultiProjectOwnedSnapshot $before $cachedOwner $processId "sidebar-selected/cached" + $name = $selectionCache.GetCachedPropertyValue([System.Windows.Automation.AutomationElement]::NameProperty, $true) + if ($name -isnot [string]) { throw "MULTIPROJECT_METADATA: selected sidebar name unavailable" } + $after = Read-MultiProjectOwnershipSnapshot $element $cacheReader + Confirm-MultiProjectOwnedSnapshot $before $after $processId "sidebar-selected/after" + return $name + } $capturedSnapshots = [Collections.Generic.List[object]]::new() $multiProcess.Refresh() Require (-not $multiProcess.HasExited) "multi-project shell exited during observation" @@ -4578,7 +4607,11 @@ function Invoke-MultiProjectRenamePhase { $matches = $matches -and $projectEvidence.bounds[2] -gt $projectEvidence.bounds[0] -and $projectEvidence.bounds[3] -gt $projectEvidence.bounds[1] -and $loopEvidence.bounds[2] -gt $loopEvidence.bounds[0] -and $loopEvidence.bounds[3] -gt $loopEvidence.bounds[1] - if ($null -ne $selectedOwner) { $matches = $matches -and $projectSelected -eq $selected -and $loopSelected -eq $selected } + if ($null -ne $selectedOwner) { + $matches = $matches -and + $projectSelected -eq ($selected -and $surface -cne "workspace") -and + $loopSelected -eq ($selected -and $surface -ceq "workspace") + } } $cardEvidence = $null if ($cardRequired) { @@ -4602,6 +4635,50 @@ function Invoke-MultiProjectRenamePhase { sidebarBounds = if ($loopEvidence) { $loopEvidence.bounds } else { @() } projectSelected = $projectSelected; loopSelected = $loopSelected; card = $cardEvidence }) } + $loopContainer = Find-FragmentByIdWithRetry $multiRoot "loops" $rawWalker + if ($null -eq $loopContainer) { throw "MULTIPROJECT_METADATA: loops container unavailable for sidebar selection" } + $loopOwner = Read-MultiProjectOwnershipSnapshot $loopContainer + if (($loopOwner.processId -is [int] -or $loopOwner.processId -is [long]) -and + $loopOwner.processId -gt 0 -and $loopOwner.processId -ne $multiProcess.Id) { + throw "MULTIPROJECT_PRIVACY: loops container has positive foreign PID" + } + if ($loopOwner.state -cne "available" -or $loopOwner.processId -ne $multiProcess.Id -or $loopOwner.automationId -cne "loops") { + throw "MULTIPROJECT_METADATA: loops container ownership unavailable before child capture" + } + $capturedSnapshots.Add([ordered]@{ element = $loopContainer; snapshot = $loopOwner; phase = "$surface/loops-container" }) + $selectedSidebarRows = @( + $sidebarElements = @($projectElements) + @(Get-DirectChildren $loopContainer $rawWalker) + foreach ($element in $sidebarElements) { + $sidebarOwner = Read-MultiProjectOwnershipSnapshot $element + if (($sidebarOwner.processId -is [int] -or $sidebarOwner.processId -is [long]) -and + $sidebarOwner.processId -gt 0 -and $sidebarOwner.processId -ne $multiProcess.Id) { + throw "MULTIPROJECT_PRIVACY: sidebar selection has positive foreign PID" + } + if ($sidebarOwner.state -cne "available" -or $sidebarOwner.processId -ne $multiProcess.Id) { + throw "MULTIPROJECT_METADATA: sidebar selection ownership unavailable" + } + $capturedSnapshots.Add([ordered]@{ element = $element; snapshot = $sidebarOwner; phase = "$surface/sidebar-selection" }) + $selectionPattern = $null + if ($element.TryGetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern, [ref]$selectionPattern) -and + $selectionPattern.Current.IsSelected) { + $selectedName = Read-MultiProjectSelectedName $element $sidebarOwner $multiProcess.Id + [ordered]@{ automationId = $sidebarOwner.automationId; name = $selectedName } + } + } + ) + $sidebarSelection = Get-MultiProjectSidebarSelection $selectedSidebarRows + $selectedSidebarIds = @($sidebarSelection.navigation | ForEach-Object { $_.automationId }) + $expectedSidebarId = if ($surface -ceq "overview") { $null } + elseif ($null -ne $selectedOwner -and $surface -ceq "workspace") { + Get-MultiProjectAutomationId "loop" $selectedOwner.path $selectedOwner.node + } elseif ($null -ne $selectedOwner -and $surface -ceq "project") { + Get-MultiProjectAutomationId "open-project" $selectedOwner.path + } else { $null } + # Native static-destination SelectionItem is deferred to #801. + if ($surface -cne "overview") { + $matches = $matches -and $selectedSidebarIds.Count -eq 1 -and + $selectedSidebarIds[0] -ceq $expectedSidebarId + } $canvasEvidence = if ($graph) { Get-MultiProjectElementEvidence $graph $multiProcess.Id $null "$surface/graph-container-bounds" $null $capturedSnapshots } else { $null } $canvas = if ($canvasEvidence) { $canvasEvidence.bounds } else { @() } $fragmentProjection = [ordered]@{ @@ -4650,6 +4727,8 @@ function Invoke-MultiProjectRenamePhase { ownership = $_.ownership; family = $_.family; observationReady = $_.observationReady; proofLimit = $_.proofLimit } }) matched = $matches; observedOwnerCount = $allProjectRows.Count + selectedSidebarIds = $selectedSidebarIds; expectedSidebarId = $expectedSidebarId + extraSidebarSelections = $sidebarSelection.extras expectedMatchedOwners = @($observed | Where-Object { $null -ne $_.projectName }).Count totalObservedProjectRowCount = $allProjectRows.Count; totalObservedCardCount = $allCards.Count observedCardCount = $allCards.Count; expectedMatchedCardCount = $cards.Count; owners = $observed.ToArray() @@ -5366,6 +5445,15 @@ try { "global sidebar destination did not expose the pinned Graph identity" Require ($projectRows.Count -eq 1) "Projects did not expose grouped recent rows" Require ($openProjectRows.Count -eq 1) "Projects did not expose the separate open-project row" + $workspaceFooters = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^workspace-footer-' + }) + Require ($workspaceFooters.Count -eq 1) "sidebar did not expose exactly one workspace footer" + Require (($workspaceFooters[0].Current.ControlType -eq [System.Windows.Automation.ControlType]::Button) -and + ($workspaceFooters[0].Current.Name.Length -gt 0) -and + ($workspaceFooters[0].Current.BoundingRectangle.Width -gt 0) -and + ($workspaceFooters[0].Current.BoundingRectangle.Height -gt 0)) ` + "workspace footer omitted its Button role name or visible bounds" Require ((@($projectRows | ForEach-Object { $_.Current.Name }) -join "|") -eq "Fixture remote") "recent project row names were not synchronized" Require ($openProjectRows[0].Current.Name -eq "UIA project") "open project row name was not synchronized" foreach ($projectRow in $projectRows) { @@ -5446,11 +5534,12 @@ try { $loopRows = @(Get-DirectChildren $loops $rawWalker | Where-Object { $_.Current.AutomationId -match '^loop-row-' }) - Require (($loopRows.Count -eq 1) -and ($loopRows[0].Current.Name -eq "UIA loop A")) ` - "nested loop tree did not start collapsed at its root" + Require (($loopRows.Count -eq 2) -and + ((@($loopRows | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) ` + "nested loop tree did not start expanded with both named loops" $loopDisclosure = @(Get-DirectChildren $loops $rawWalker | Where-Object { $_.Current.AutomationId -match '^loop-disclosure-' -and - $_.Current.Name -eq "Expand loop children" + $_.Current.Name -eq "Collapse loop children" }) | Select-Object -First 1 Require ($null -ne $loopDisclosure) "nested root omitted its disclosure action" $rootLoopId = $loopRows[0].Current.AutomationId @@ -5459,10 +5548,23 @@ try { $loopRows = @(Get-DirectChildren $loops $rawWalker | Where-Object { $_.Current.AutomationId -match '^loop-row-' }) + Require (($loopRows.Count -eq 1) -and ($loopRows[0].Current.Name -eq "UIA loop A") -and + ($loopRows[0].Current.AutomationId -eq $rootLoopId)) ` + "nested disclosure did not hide its child while preserving root identity" + $loopDisclosure = @(Get-DirectChildren $loops $rawWalker | Where-Object { + $_.Current.AutomationId -match '^loop-disclosure-' -and + $_.Current.Name -eq "Expand loop children" + }) | Select-Object -First 1 + Require ($null -ne $loopDisclosure) "collapsed root omitted its expand action" + $loopDisclosure.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $loopRows = @(Get-DirectChildren $loops $rawWalker | Where-Object { + $_.Current.AutomationId -match '^loop-row-' + }) Require (($loopRows.Count -eq 2) -and ((@($loopRows | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B") -and ($loopRows[0].Current.AutomationId -eq $rootLoopId)) ` - "nested disclosure did not reveal its child while preserving root identity" + "nested disclosure did not restore both named loops with the same root identity" $projectDisclosure = @(Get-DirectChildren $projects $rawWalker | Where-Object { $_.Current.AutomationId -match '^project-disclosure-' -and $_.Current.Name -eq "Collapse project" @@ -7217,18 +7319,52 @@ try { } Require ($null -eq $remainingJump) "Return did not activate and close the jump palette" $loopsAfterJump = Find-FragmentById $root "loops" $rawWalker - $loopBAfterJump = @(Get-DirectChildren $loopsAfterJump $rawWalker | - Where-Object { $_.Current.Name -eq "UIA loop B" }) | Select-Object -First 1 - Require ($null -ne $loopBAfterJump) "jump navigation did not retain the destination loop row" + $projectsAfterJump = Find-FragmentById $root "projects" $rawWalker + $sidebarAfterJump = @(Get-DirectChildren $projectsAfterJump $rawWalker) + + @(Get-DirectChildren $loopsAfterJump $rawWalker) + $loopBRowsAfterJump = @($sidebarAfterJump | + Where-Object { $_.Current.AutomationId -match '^loop-row-' -and $_.Current.Name -eq "UIA loop B" }) + Require ($loopBRowsAfterJump.Count -eq 1) "jump navigation did not retain exactly one destination loop row" + $loopBAfterJump = $loopBRowsAfterJump[0] $selectedAfterJump = $loopBAfterJump.GetCurrentPattern( [System.Windows.Automation.SelectionItemPattern]::Pattern ).Current.IsSelected - Require $selectedAfterJump "jump palette activation did not navigate to UIA loop B" + # TODO(#799): restore loop-row selection when jump opens the workspace. + Require (-not $selectedAfterJump) "project-canvas jump incorrectly selected the sidebar loop" + $selectedSidebarElementsAfterJump = @($sidebarAfterJump | Where-Object { + $selectionPattern = $null + $_.TryGetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern, [ref]$selectionPattern) -and + $selectionPattern.Current.IsSelected + }) + $sidebarSelectionAfterJump = Get-MultiProjectSidebarSelection @($selectedSidebarElementsAfterJump | ForEach-Object { + Require ($_.Current.ProcessId -eq $process.Id) "jump sidebar selection belongs to another process" + [ordered]@{ automationId = [string]$_.Current.AutomationId; name = [string]$_.Current.Name } + }) + $selectedSidebarAfterJump = $sidebarSelectionAfterJump.navigation + Require (($selectedSidebarAfterJump.Count -eq 1) -and + ($selectedSidebarAfterJump[0].automationId -match '^open-project-') -and + ($selectedSidebarAfterJump[0].name -eq "UIA project")) ` + "project-canvas jump did not select exactly its owning sidebar project" + $graphAfterJump = Find-FragmentById $root "graph" $rawWalker + $cardsAfterJump = @(Get-DirectChildren $graphAfterJump $rawWalker | + Where-Object { $_.Current.AutomationId -match '^canvas-card-' }) + Require (($cardsAfterJump.Count -eq 2) -and + ((@($cardsAfterJump | ForEach-Object { $_.Current.Name }) -join '|') -eq 'UIA loop A|UIA loop B')) ` + "jump did not retain the destination project's exact canvas cards" + $selectedCardsAfterJump = @($cardsAfterJump | Where-Object { + $_.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected + }) + Require (($selectedCardsAfterJump.Count -eq 1) -and + ($selectedCardsAfterJump[0].Current.Name -eq "UIA loop B")) ` + "jump palette activation did not select exactly canvas card UIA loop B" $jumpPaletteEvidence = [ordered]@{ resultCount = $jumpNames.Count results = @($jumpNames | ForEach-Object { [string]$_ }) filteredResults = @($filteredJumpNames | ForEach-Object { [string]$_ }) destination = [string]$loopBAfterJump.Current.Name + sidebarSelection = [string]$selectedSidebarAfterJump[0].name + extraSidebarSelections = $sidebarSelectionAfterJump.extras + canvasSelection = [string]$selectedCardsAfterJump[0].Current.Name } Write-Host ("UIA_JUMP_PALETTE_EVIDENCE=" + ($jumpPaletteEvidence | ConvertTo-Json -Compress)) diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index b5bbae9c..e017f5a7 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -1255,6 +1255,7 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("workspace-split-right:", 0) == 0 ? L"workspace-split-right-" : row.identity.rfind("workspace-split-down:", 0) == 0 ? L"workspace-split-down-" : row.identity.rfind("workspace-switch:", 0) == 0 ? L"workspace-switch-" : + row.identity.rfind("workspace-footer:", 0) == 0 ? L"workspace-footer-" : row.identity.rfind("sidebar-error-footer:", 0) == 0 ? L"sidebar-error-footer-" : row.identity.rfind("sidebar-update-banner:", 0) == 0 ? L"sidebar-update-banner-" : row.identity.rfind("worktree-loading:", 0) == 0 ? L"worktree-loading-" : @@ -1299,6 +1300,7 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("project-disclosure:", 0) == 0 || row.identity.rfind("quick-chats-header:", 0) == 0 || row.identity.rfind("sidebar-update-banner:", 0) == 0 || + row.identity.rfind("workspace-footer:", 0) == 0 || row.identity.rfind("quick-chat-new:", 0) == 0 || row.identity.rfind("quick-chats-disclosure:", 0) == 0 || row.identity.rfind("loop-disclosure:", 0) == 0; diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 61866659..79233bfb 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1075,10 +1075,9 @@ const AccessibilityBounds = union(enum) { } }; -/// Smallest client height at which the sidebar keeps `Sidebar.min_content_region` of rows under -/// the fixed header with the Workspace panel, Activity strip, and status line stacked below it. -const min_client_height: i32 = Sidebar.content_top + Sidebar.min_content_region + - Sidebar.footerReserve(false, false) + Tokens.workspace_height + Tokens.activity_strip_height; +/// Preserve the existing height floor while matching macOS's observed minimum content width. +const min_client_height: i32 = 517; +const min_client_width: i32 = 452; extern "user32" fn AdjustWindowRectExForDpi( rect: *c.RECT, @@ -1098,6 +1097,15 @@ fn minimumWindowHeight(hwnd: c.HWND, dpi: u32) i32 { return frame.bottom - frame.top; } +fn minimumWindowWidth(hwnd: c.HWND, dpi: u32) i32 { + var frame = c.RECT{ .left = 0, .top = 0, .right = physicalCoordinate(min_client_width, dpi), .bottom = 0 }; + const style: c.DWORD = @truncate(@as(usize, @bitCast(c.GetWindowLongPtrW(hwnd, c.GWL_STYLE)))); + const ex_style: c.DWORD = @truncate(@as(usize, @bitCast(c.GetWindowLongPtrW(hwnd, c.GWL_EXSTYLE)))); + const has_menu: c.BOOL = if (c.GetMenu(hwnd) != null) 1 else 0; + if (AdjustWindowRectExForDpi(&frame, style, has_menu, ex_style, Dpi.normalize(dpi)) == 0) return 0; + return frame.right - frame.left; +} + fn clipSidebarAccessibilityBounds(bounds: c.RECT, viewport_bottom: i32) c.RECT { const top = @max(bounds.top, Sidebar.content_top); const bottom = @min(bounds.bottom, viewport_bottom); @@ -4226,9 +4234,15 @@ pub const App = struct { self.workspace_controls.panel_visible = false; self.layoutWorkspace(); self.layoutEmptyStateControls(); - self.sidebar_scroll = Sidebar.clampScroll( - Sidebar.loopRowTopForModel(&self.model, node_index) - 24, - Sidebar.maxScroll(&self.model, if (self.worktree_inspection) |*value| value else null, 700, &self.sidebar_state), + const client = logicalClientRect(self.window.hwnd, self.dpi); + const inspection = self.currentWorktreeInspection(); + const target_top = Sidebar.loopRowTopForModel(&self.model, node_index, inspection, &self.sidebar_state) catch { + self.setStatus("Unable to locate the matching sidebar row"); + return; + }; + if (target_top) |top| self.sidebar_scroll = Sidebar.clampScroll( + top - Sidebar.content_top, + Sidebar.maxScroll(&self.model, inspection, self.sidebarContentBottom(client.bottom), &self.sidebar_state), ); self.syncAccessibility(); _ = c.InvalidateRect(self.window.hwnd, null, 0); @@ -6528,8 +6542,18 @@ pub const App = struct { fn ensureWorktreeVisible(self: *App, index: usize) void { const client = logicalClientRect(self.window.hwnd, self.dpi); - const loop_count = if (self.model.graph) |graph| graph.nodes.items.len else 0; - const top = Sidebar.worktreeRowTopForModel(&self.model, loop_count, index) - self.sidebar_scroll; + const inspection = self.currentWorktreeInspection() orelse { + self.setStatus("Selected worktree is no longer available"); + return; + }; + const row_top = Sidebar.worktreeRowTopForModel(&self.model, inspection, index, &self.sidebar_state) catch { + self.setStatus("Unable to locate the selected worktree row"); + return; + } orelse { + self.setStatus("Selected worktree is no longer available"); + return; + }; + const top = row_top - self.sidebar_scroll; const bottom = top + 34; const viewport_top = Sidebar.content_top; const viewport_bottom = self.sidebarContentBottom(client.bottom); @@ -7523,7 +7547,8 @@ pub const App = struct { const sidebar_bottom = self.sidebarContentBottom(client.bottom); const footer_viewport_bottom = GraphCanvas.sidebarBottom(client.bottom, self.workspace_controls, self.surface); provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); - provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); const current_inspection = self.currentWorktreeInspection(); + provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); + const current_inspection = self.currentWorktreeInspection(); // The scroll sink is optional so test sinks that only record elements need not declare it. if (comptime providerHasDecl(@TypeOf(provider), "syncSidebarScroll")) { provider.syncSidebarScroll( @@ -7540,6 +7565,7 @@ pub const App = struct { &self.sidebar_state, ) catch return; defer sidebar_rows.deinit(self.allocator); + const sidebar_selection = Sidebar.selectionForSurface(&self.model, self.surface, self.workspace_is_quick_chat, self.selected_quick_chat, if (self.worktree_dialog != null and current_inspection != null) current_inspection.?.project_path else null); const header = self.headerPresentation(); const header_layout = header.layout(client.right); for (GraphCanvas.header_actions) |action| { @@ -7590,7 +7616,10 @@ pub const App = struct { for (sidebar_rows.items) |row| { const bounds = clipSidebarAccessibilityBounds( - .{ .left = 12, .top = row.top - 3, .right = 232, .bottom = row.top + 23 }, + if (row.kind == .worktree) + c.RECT{ .left = 12, .top = row.top - 3, .right = 232, .bottom = row.top + 23 } + else + Sidebar.rowBounds(row.top), sidebar_bottom, ); switch (row.kind) { @@ -7601,15 +7630,15 @@ pub const App = struct { self.appendAccessibilityElement(&elements, &owned_identities, "project", project.path, project.name, 1, .{ .logical = bounds }, false, false) catch return; }, .open_project => if (row.project_path) |path| if (self.model.graphFor(path)) |graph| { - self.appendAccessibilityElement(&elements, &owned_identities, "open-project", path, graph.project.name, 1, .{ .logical = bounds }, self.model.selected_project_path != null and std.mem.eql(u8, self.model.selected_project_path.?, path), false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "open-project", path, graph.project.name, 1, .{ .logical = bounds }, Sidebar.rowSelected(row, &self.model, sidebar_selection), false) catch return; const new_bounds = clipSidebarAccessibilityBounds( - .{ .left = 174, .top = row.top, .right = 198, .bottom = row.top + 24 }, + .{ .left = 174, .top = row.top, .right = 198, .bottom = row.top + Sidebar.row_height }, sidebar_bottom, ); self.appendAccessibilityElement(&elements, &owned_identities, "project-new-loop", path, "New Loop", 1, .{ .logical = new_bounds }, false, false) catch return; if (row.has_children) { const disclosure_bounds = clipSidebarAccessibilityBounds( - .{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + 24 }, + .{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + Sidebar.row_height }, sidebar_bottom, ); self.appendAccessibilityElement( @@ -7630,10 +7659,10 @@ pub const App = struct { const node = graph.nodes.items[row.index]; const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ path, node.id }) catch return; defer self.allocator.free(key); - self.appendAccessibilityElement(&elements, &owned_identities, "loop", key, node.title, 2, .{ .logical = bounds }, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, node.id), false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "loop", key, node.title, 2, .{ .logical = bounds }, Sidebar.rowSelected(row, &self.model, sidebar_selection), false) catch return; if (row.has_children) { const disclosure_bounds = clipSidebarAccessibilityBounds( - .{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + 24 }, + .{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + Sidebar.row_height }, sidebar_bottom, ); self.appendAccessibilityElement( @@ -7665,18 +7694,18 @@ pub const App = struct { }; } }, - .overview => self.appendAccessibilityElement(&elements, &owned_identities, "overview-destination", "graph", "Graph", 1, .{ .logical = bounds }, self.surface == .overview, false) catch return, + .overview => self.appendAccessibilityElement(&elements, &owned_identities, "overview-destination", "graph", "Graph", 1, .{ .logical = bounds }, Sidebar.rowSelected(row, &self.model, sidebar_selection), false) catch return, .quick_chat_overview => { - self.appendAccessibilityElement(&elements, &owned_identities, "quick-chats-destination", "quick-chats", "Quick Chats", 1, .{ .logical = bounds }, self.surface == .quick_chats, false) catch return; - self.appendAccessibilityElement(&elements, &owned_identities, "quick-chats-header", "quick-chats", "Quick Chats", 1, .{ .logical = bounds }, self.surface == .quick_chats, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chats-destination", "quick-chats", "Quick Chats", 1, .{ .logical = bounds }, Sidebar.rowSelected(row, &self.model, sidebar_selection), false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chats-header", "quick-chats", "Quick Chats", 1, .{ .logical = bounds }, Sidebar.rowSelected(row, &self.model, sidebar_selection), false) catch return; const new_bounds = clipSidebarAccessibilityBounds( - .{ .left = 174, .top = row.top, .right = 198, .bottom = row.top + 24 }, + .{ .left = 174, .top = row.top, .right = 198, .bottom = row.top + Sidebar.row_height }, sidebar_bottom, ); self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-new", "quick-chats", "New Chat", 1, .{ .logical = new_bounds }, false, false) catch return; if (self.model.quick_chats.items.len != 0) { const disclosure_bounds = clipSidebarAccessibilityBounds( - .{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + 24 }, + .{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + Sidebar.row_height }, sidebar_bottom, ); self.appendAccessibilityElement( @@ -7694,11 +7723,13 @@ pub const App = struct { }, .quick_chat => if (row.index < self.model.quick_chats.items.len) { const chat = self.model.quick_chats.items[row.index]; - self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-row", chat.id, chat.title, 1, .{ .logical = bounds }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-row", chat.id, chat.title, 1, .{ .logical = bounds }, Sidebar.rowSelected(row, &self.model, sidebar_selection), false) catch return; }, } } const footer = Sidebar.effectiveFooter(footer_viewport_bottom, self.updateBannerShown(), self.ingress_error.len != 0); + if (self.workspace_controls.rail_visible and Sidebar.workspaceWorthShowing(self.workspace_path, if (self.workspace_list) |list| list.items.len else 0)) + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-footer", "switcher", Sidebar.workspaceName(self.workspace_path), 1, .{ .logical = Sidebar.workspaceFooterRect(footer_viewport_bottom) }, false, self.workspace_identity_valid) catch return; if (footer.update) { const banner = Sidebar.updateBannerRect(footer_viewport_bottom, footer.ingress); self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-update-banner", "update", "Update available", 1, .{ .logical = banner }, false, false) catch return; @@ -8877,11 +8908,11 @@ fn onWindowMessage( } switch (message) { c.WM_GETMINMAXINFO => { - // Below this the sidebar cannot keep two rows visible beneath the fixed header with the - // Workspace panel, Activity strip, and status line stacked under it. Only the height is - // constrained; width keeps the system minimum. const info = Win32.messagePointer(*c.MINMAXINFO, lparam); const minimum = minimumWindowHeight(hwnd, app.dpi); + const minimum_width = minimumWindowWidth(hwnd, app.dpi); + if (minimum_width == 0) app.setStatus("Unable to calculate the window minimum width"); + if (minimum_width > info.ptMinTrackSize.x) info.ptMinTrackSize.x = minimum_width; if (minimum > info.ptMinTrackSize.y) info.ptMinTrackSize.y = minimum; result.* = 0; return true; @@ -8903,6 +8934,18 @@ fn onWindowMessage( }, c.WM_COMMAND => { if ((wparam & Accessibility.uia_dynamic_invoke_mask) == Accessibility.uia_dynamic_invoke_tag) { + if ((wparam & Accessibility.uia_row_payload_mask) == Accessibility.worktreeIdentityPayload("workspace-footer:switcher")) { + if (!app.workspace_controls.rail_visible) { + app.setStatus("Workspace switcher unavailable while the sidebar is hidden"); + result.* = 0; + return true; + } + const client = logicalClientRect(hwnd, app.dpi); + const footer = Sidebar.workspaceFooterRect(GraphCanvas.sidebarBottom(client.bottom, app.workspace_controls, app.surface)); + const x: u16 = @intCast(physicalCoordinate(18, app.dpi)); + const y: u16 = @intCast(physicalCoordinate(footer.top + 12, app.dpi)); + return onWindowMessage(context, hwnd, c.WM_LBUTTONDOWN, 0, @intCast(@as(u32, x) | (@as(u32, y) << 16)), result); + } _ = app.applyUiaDynamicInvoke(wparam & Accessibility.uia_row_payload_mask); result.* = 0; return true; @@ -9120,10 +9163,17 @@ fn onWindowMessage( } const header = app.headerPresentation(); const inspection = app.currentWorktreeInspection(); + app.sidebar_state.selection = Sidebar.selectionForSurface(&app.model, app.surface, app.workspace_is_quick_chat, app.selected_quick_chat, if (app.worktree_dialog != null and inspection != null) inspection.?.project_path else null); + app.sidebar_state.workspace_name = if (Sidebar.workspaceWorthShowing(app.workspace_path, if (app.workspace_list) |list| list.items.len else 0)) + Sidebar.workspaceName(app.workspace_path) + else + ""; app.update_lock.lock(); if (app.model.currentGraph()) |graph| app.canvas.syncNodeOffsets(graph.nodes.items); const offered_version = if (app.update_state.state == .available) app.update_version else ""; GraphCanvas.paint(hdc, logical_right, logical_bottom, &app.model, inspection, app.selected_worktree_path, app.sidebar_scroll, app.status(), offered_version, app.ingress_error, app.connectionFailureVisible(), app.worktree_state, app.declared_entry_ids.items, app.kept_worktree_paths.items, app.allocator, &app.canvas, &app.sidebar_state, app.sidebar_hover_y, app.workspace_controls, app.surface); + app.sidebar_state.selection = .none; + app.sidebar_state.workspace_name = ""; app.update_lock.unlock(); if (app.workspace_controls.panel_visible or app.surface == .workspace) { if (app.surface == .workspace) { @@ -9646,6 +9696,25 @@ fn onWindowMessage( result.* = 0; return true; } + if (Sidebar.workspaceFooterAt(x, y, sidebar_bottom) and Sidebar.workspaceWorthShowing(app.workspace_path, if (app.workspace_list) |list| list.items.len else 0)) { + app.clearSidebarRootDrag(); + if (app.refreshWorkspaceList()) { + app.updateNativeChrome(.popup_open); + if (c.GetSubMenu(c.GetMenu(hwnd), 3)) |menu| { + const footer = Sidebar.workspaceFooterRect(sidebar_bottom); + var anchor = c.POINT{ .x = physicalCoordinate(footer.left, app.dpi), .y = physicalCoordinate(footer.top, app.dpi) }; + if (c.ClientToScreen(hwnd, &anchor) != 0) { + if (builtin.is_test and workspace_menu_observer != null) { + workspace_menu_observer.?(menu, anchor); + } else { + _ = c.TrackPopupMenuEx(menu, c.TPM_LEFTALIGN | c.TPM_BOTTOMALIGN | c.TPM_LEFTBUTTON, anchor.x, anchor.y, hwnd, null); + } + } else app.setStatus("Unable to position the workspace switcher"); + } else app.setStatus("Workspace menu unavailable"); + } + result.* = 0; + return true; + } app.update_lock.lock(); const update_available = app.update_state.state == .available; app.update_lock.unlock(); @@ -11433,10 +11502,10 @@ test "sidebar UIA bounds clip hidden rows instead of sharing visible hit rectang 400, ), ); - // A row scrolled under the fixed GRAPH/Projects header is not published at all. + // A row entirely above the scrolling content is not published. try std.testing.expectEqualDeep( c.RECT{ .left = 0, .top = 0, .right = 0, .bottom = 0 }, - clipSidebarAccessibilityBounds(.{ .left = 12, .top = Tokens.header_height + 10, .right = 232, .bottom = Tokens.header_height + 34 }, 400), + clipSidebarAccessibilityBounds(.{ .left = 12, .top = Sidebar.content_top - 20, .right = 232, .bottom = Sidebar.content_top }, 400), ); try std.testing.expectEqualDeep( c.RECT{ .left = 0, .top = 0, .right = 0, .bottom = 0 }, @@ -11466,6 +11535,7 @@ const sidebar_uia_prefixes = [_][]const u8{ "activity-control:", "sidebar-error-footer:", "sidebar-update-banner:", + "workspace-footer:", "overview-destination:", "quick-chats-destination:", }; @@ -11476,6 +11546,7 @@ const SidebarUiaSink = struct { names: [192][160]u8 = undefined, name_lens: [192]usize = undefined, invokable: [192]bool = undefined, + selected: [192]bool = undefined, scrolls: [192]bool = undefined, labels: [192][40]u8 = undefined, label_lens: [192]usize = undefined, @@ -11502,6 +11573,7 @@ const SidebarUiaSink = struct { @memcpy(self.labels[self.count][0..label_len], element.name[0..label_len]); self.label_lens[self.count] = label_len; self.invokable[self.count] = element.invokable; + self.selected[self.count] = element.selected; self.scrolls[self.count] = element.sidebar_scroll; self.count += 1; } @@ -11766,7 +11838,7 @@ fn expectReachable(content_top: i32, content_bottom: i32, viewport_bottom: i32, try std.testing.expect(lowest <= highest); } -test "sidebar status text, CHATS label, and Needs you stay separate and reachable at 960px and 1280x820, 96 and 144 DPI" { +test "sidebar reserved footer Quick Chats row and Needs you stay separate and reachable at 960px and 1280x820, 96 and 144 DPI" { const footers = [_]struct { update: bool, ingress: bool }{ .{ .update = false, .ingress = false }, .{ .update = true, .ingress = false }, @@ -11783,12 +11855,12 @@ test "sidebar status text, CHATS label, and Needs you stay separate and reachabl // Footer parts that do not leave two rows of room collapse; only the rest are laid out. const effective = Sidebar.effectiveFooter(sidebar_bottom, footer.update, footer.ingress); - const status = Sidebar.statusTextRect(sidebar_bottom, effective.update, effective.ingress); - try std.testing.expect(status.top >= content_bottom); - try std.testing.expect(status.bottom <= sidebar_bottom); - try std.testing.expect(status.left >= 0 and status.right <= Tokens.sidebar_width); - if (effective.update) try std.testing.expect(!rectsIntersect(status, Sidebar.updateBannerRect(sidebar_bottom, effective.ingress))); - if (effective.ingress) try std.testing.expect(!rectsIntersect(status, Sidebar.errorFooterRect(sidebar_bottom))); + const workspace_footer = Sidebar.workspaceFooterRect(sidebar_bottom); + try std.testing.expect(workspace_footer.top >= content_bottom); + try std.testing.expect(workspace_footer.bottom <= sidebar_bottom); + try std.testing.expect(workspace_footer.left >= 0 and workspace_footer.right <= Tokens.sidebar_width); + if (effective.update) try std.testing.expect(!rectsIntersect(workspace_footer, Sidebar.updateBannerRect(sidebar_bottom, effective.ingress))); + if (effective.ingress) try std.testing.expect(!rectsIntersect(workspace_footer, Sidebar.errorFooterRect(sidebar_bottom))); // The production clamp must agree with the content viewport, so the last row is reachable. const max_scroll = Sidebar.maxScroll(&app.model, inspection, content_bottom, &app.sidebar_state); @@ -11805,8 +11877,8 @@ test "sidebar status text, CHATS label, and Needs you stay separate and reachabl var rows = try Sidebar.appendRows(app.allocator, &app.model, inspection, 0, &app.sidebar_state); defer rows.deinit(app.allocator); const chats = for (rows.items) |row| { - if (row.kind == .quick_chat_overview) break Sidebar.chatsLabelRect(row.top); - } else return error.TestExpectedChatsLabel; + if (row.kind == .quick_chat_overview) break c.RECT{ .left = 10, .top = row.top, .right = 212, .bottom = row.top + 32 }; + } else return error.TestExpectedChatsRow; try expectReachable(chats.top, chats.bottom, content_bottom, max_scroll); const needs_you = Sidebar.needsYouLabelRect(section, 0); @@ -11882,10 +11954,10 @@ fn loadManySidebarLoops(app: *App, count: usize) !void { /// The vertical extent a sidebar row is drawn and published over, clipped to the scroll /// region, or an empty rect when the row is scrolled out of it. fn expectedSidebarRowRect(row_top: i32, region_top: i32, region_bottom: i32) c.RECT { - const top = @max(row_top - 3, region_top); - const bottom = @min(row_top + 23, region_bottom); + const top = @max(row_top, region_top); + const bottom = @min(row_top + 32, region_bottom); if (top >= bottom) return .{ .left = 0, .top = 0, .right = 0, .bottom = 0 }; - return .{ .left = 12, .top = top, .right = 232, .bottom = bottom }; + return .{ .left = 10, .top = top, .right = 212, .bottom = bottom }; } test "sidebar UIA bounds are the drawn rect clipped to the scroll region at short heights, 96 and 144 DPI" { @@ -12273,6 +12345,161 @@ test "sidebar footer collapses the error footer then the banner to keep a 52 px try std.testing.expect(reachable >= 48); } +test "parity sidebar UIA selection matches the destination rather than a stale canvas loop" { + var app = try overviewTestApp(96); + defer deinitOverviewTestApp(&app); + try loadActivationTestFixture(&app); + try std.testing.expect(app.selectNodeIndex(1)); + const client = logicalClientRect(app.window.hwnd, app.dpi); + for ([_]GraphCanvas.Surface{ .overview, .project, .quick_chats, .workspace }) |surface| { + app.surface = surface; + var sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&sink, client); + const graph = sink.indexOfName("overview-destination:graph") orelse return error.TestExpectedGraph; + const project = sink.indexOfName("open-project:C:\\activation-fixture") orelse return error.TestExpectedProject; + const loop = sink.indexOfName("loop:C:\\activation-fixture:target-loop") orelse return error.TestExpectedLoop; + const chats = sink.indexOfName("quick-chats-destination:quick-chats") orelse return error.TestExpectedChats; + try std.testing.expectEqual(surface == .overview, sink.selected[graph]); + try std.testing.expectEqual(surface == .project, sink.selected[project]); + try std.testing.expectEqual(surface == .workspace, sink.selected[loop]); + try std.testing.expectEqual(surface == .quick_chats, sink.selected[chats]); + } +} + +test "parity keyboard worktree selection scrolls to the generated row across project folds" { + for ([_]bool{ false, true }) |folded| { + var app = try footerLayoutApp(.{ .dpi = 96, .width = 960, .height = 400 }, footer_layout_controls[0], false, false); + defer deinitFooterLayoutApp(&app); + try loadManySidebarLoops(&app, 30); + if (folded) try app.sidebar_state.toggleProject("C:\\activation-fixture"); + const action = InputRouter.keyAction(c.VK_DOWN, false, false); + try std.testing.expectEqual(InputRouter.Action.worktree_next, action); + app.handleAction(action); + const inspection = app.currentWorktreeInspection() orelse return error.TestExpectedInspection; + const index = for (inspection.entries.items, 0..) |entry, i| { + if (std.mem.eql(u8, entry.path, app.selected_worktree_path)) break i; + } else return error.TestExpectedSelectedWorktree; + const client = logicalClientRect(app.window.hwnd, app.dpi); + var rows = try Sidebar.appendRows(app.allocator, &app.model, inspection, app.sidebar_scroll, &app.sidebar_state); + defer rows.deinit(app.allocator); + const row = for (rows.items) |candidate| { + if (candidate.kind == .worktree and candidate.index == index) break candidate; + } else return error.TestExpectedWorktreeRow; + try std.testing.expect(row.top >= Sidebar.content_top); + try std.testing.expect(row.top + 34 <= app.sidebarContentBottom(client.bottom)); + try std.testing.expect(Sidebar.rowAt(24, row.top + 4, &app.model, inspection, app.sidebar_scroll, app.sidebarContentBottom(client.bottom), &app.sidebar_state).?.index == index); + } +} + +var workspace_menu_observer: ?*const fn (c.HMENU, c.POINT) void = null; + +const WorkspaceFooterMenuProbe = struct { + var count: usize = 0; + var menu: c.HMENU = null; + var anchor: c.POINT = .{ .x = 0, .y = 0 }; + + fn observe(value: c.HMENU, point: c.POINT) void { + count += 1; + menu = value; + anchor = point; + } +}; + +test "parity workspace footer pointer and UIA invoke request the same existing Workspace menu" { + const allocator = std.testing.allocator; + const support_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_SUPPORT_DIR"); + var environment = try std.process.getEnvMap(allocator); + defer environment.deinit(); + defer setWorkspaceTestEnvironment(support_key, environment.get("GRAPHCODE_SUPPORT_DIR")) catch @panic("support restore failed"); + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + try temporary.dir.makePath(".graphcode-footer-fixture"); + const support = try temporary.dir.realpathAlloc(allocator, ".graphcode-footer-fixture"); + defer allocator.free(support); + try setWorkspaceTestEnvironment(support_key, support); + workspace_menu_observer = WorkspaceFooterMenuProbe.observe; + defer workspace_menu_observer = null; + for ([_]u32{ 96, 144, 192 }) |dpi| { + var app = try overviewTestApp(dpi); + defer deinitOverviewTestApp(&app); + try MainWindow.installMenu(app.window.hwnd); + try app.acquireSingleInstance(); + defer app.workspace_reservation.deinit(); + defer allocator.free(app.workspace_path); + defer allocator.free(app.workspace_identity); + defer if (app.workspace_list) |*list| list.deinit(allocator); + const client = logicalClientRect(app.window.hwnd, dpi); + const footer = Sidebar.workspaceFooterRect(GraphCanvas.sidebarBottom(client.bottom, app.workspace_controls, app.surface)); + const x: u16 = @intCast(physicalCoordinate(18, dpi)); + const y: u16 = @intCast(physicalCoordinate(footer.top + 12, dpi)); + const click: c.LPARAM = @intCast(@as(u32, x) | (@as(u32, y) << 16)); + var result: c.LRESULT = 0; + WorkspaceFooterMenuProbe.count = 0; + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_LBUTTONDOWN, 0, click, &result)); + try std.testing.expectEqual(@as(usize, 1), WorkspaceFooterMenuProbe.count); + const pointer_menu = WorkspaceFooterMenuProbe.menu; + const pointer_anchor = WorkspaceFooterMenuProbe.anchor; + try std.testing.expect(pointer_menu == c.GetSubMenu(c.GetMenu(app.window.hwnd), 3)); + try std.testing.expect(c.GetMenuItemCount(pointer_menu) > 0); + const command = Accessibility.uia_dynamic_invoke_tag | Accessibility.worktreeIdentityPayload("workspace-footer:switcher"); + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_COMMAND, command, 0, &result)); + try std.testing.expectEqual(@as(usize, 2), WorkspaceFooterMenuProbe.count); + try std.testing.expect(pointer_menu == WorkspaceFooterMenuProbe.menu); + try std.testing.expectEqualDeep(pointer_anchor, WorkspaceFooterMenuProbe.anchor); + var sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&sink, client); + const index = sink.indexOfName("workspace-footer:switcher") orelse return error.TestExpectedFooter; + try std.testing.expectEqualStrings("footer-fixture", sink.label(index)); + try std.testing.expectEqualDeep((AccessibilityBounds{ .logical = footer }).physicalRect(dpi), sink.rects[index]); + try std.testing.expect(sink.rects[index].right <= physicalCoordinate(Tokens.sidebar_width, dpi)); + } +} + +test "parity minimum window width is 452 logical points at 96 144 and 192 DPI" { + for ([_]u32{ 96, 144, 192 }) |dpi| { + var app = try overviewTestApp(dpi); + defer deinitOverviewTestApp(&app); + var info = std.mem.zeroes(c.MINMAXINFO); + var result: c.LRESULT = 0; + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_GETMINMAXINFO, 0, @bitCast(@intFromPtr(&info)), &result)); + try std.testing.expectEqual(physicalCoordinate(452, dpi), info.ptMinTrackSize.x); + info.ptMinTrackSize.x += 50; + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_GETMINMAXINFO, 0, @bitCast(@intFromPtr(&info)), &result)); + try std.testing.expectEqual(physicalCoordinate(452, dpi) + 50, info.ptMinTrackSize.x); + } +} + +test "parity minimum width preserves 452 client points in a framed native window with a menu" { + var app = try overviewTestApp(96); + defer deinitOverviewTestApp(&app); + const framed = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("STATIC"), + std.unicode.utf8ToUtf16LeStringLiteral("Never shown minimum-width frame"), + c.WS_OVERLAPPEDWINDOW, + 0, + 0, + 960, + 820, + null, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.TestWindowCreationFailed; + _ = c.DestroyWindow(app.window.hwnd); + app.window.hwnd = framed; + app.dpi = Dpi.normalize(Win32.dpiForWindow(framed)); + try MainWindow.installMenu(framed); + var info = std.mem.zeroes(c.MINMAXINFO); + var result: c.LRESULT = 0; + try std.testing.expect(onWindowMessage(&app, framed, c.WM_GETMINMAXINFO, 0, @bitCast(@intFromPtr(&info)), &result)); + try std.testing.expect(info.ptMinTrackSize.x > physicalCoordinate(452, app.dpi)); + try std.testing.expect(c.MoveWindow(framed, 0, 0, info.ptMinTrackSize.x, info.ptMinTrackSize.y, 0) != 0); + var client: c.RECT = undefined; + try std.testing.expect(c.GetClientRect(framed, &client) != 0); + try std.testing.expectEqual(physicalCoordinate(452, app.dpi), client.right); +} + test "the minimum window height keeps a 52 px sidebar scroll region and at least one published row with the panel, strip, banner and error footer" { for ([_]u32{ 96, 144 }) |dpi| { var app = try overviewTestApp(dpi); @@ -12382,8 +12609,24 @@ test "banner click and the UIA dynamic invoke command open the same update offer try std.testing.expectEqual(@as(usize, 2), OfferProbe.count); offers += 1; }; - try std.testing.expectEqual(@as(usize, 14), offers); - try std.testing.expectEqual(@as(usize, 2), collapsed); + // Removing the fixed headings lets all 16 original viewport cases fit the banner. + try std.testing.expectEqual(@as(usize, 16), offers); + const short_case = FooterLayoutCase{ + .dpi = 96, + .width = 960, + .height = Sidebar.content_top + Sidebar.min_content_region + Sidebar.footerReserve(false, false) + Tokens.activity_strip_height, + }; + var short_app = try footerLayoutApp(short_case, footer_layout_controls[0], true, false); + defer deinitFooterLayoutApp(&short_app); + const short_client = logicalClientRect(short_app.window.hwnd, short_app.dpi); + const short_bottom = GraphCanvas.sidebarBottom(short_client.bottom, short_app.workspace_controls, short_app.surface); + try std.testing.expect(!Sidebar.effectiveFooter(short_bottom, true, false).update); + const hidden_banner = Sidebar.updateBannerRect(short_bottom, false); + OfferProbe.reset(); + try nativeClick(&short_app, hidden_banner.left + 4, hidden_banner.top + 4); + try std.testing.expectEqual(@as(usize, 0), OfferProbe.count); + collapsed += 1; + try std.testing.expectEqual(@as(usize, 1), collapsed); } test "every scrolling sidebar element advertises scroll-into-view and the fixed footer and header do not" { @@ -18481,6 +18724,9 @@ const GraphPublicationTest = struct { var loop_bars: usize = 0; var toolbars: usize = 0; var selected_beta_projects: usize = 0; + var selected_sidebar_rows: usize = 0; + var selected_beta_loops: usize = 0; + var selected_graph_destinations: usize = 0; var selected_beta_cards: usize = 0; fn init(mode: Wire.ProtocolMode) !App { @@ -18542,6 +18788,9 @@ const GraphPublicationTest = struct { loop_bars = 0; toolbars = 0; selected_beta_projects = 0; + selected_sidebar_rows = 0; + selected_beta_loops = 0; + selected_graph_destinations = 0; selected_beta_cards = 0; } @@ -18572,8 +18821,14 @@ const GraphPublicationTest = struct { loop_bars = 0; toolbars = 0; selected_beta_projects = 0; + selected_sidebar_rows = 0; + selected_beta_loops = 0; + selected_graph_destinations = 0; selected_beta_cards = 0; for (elements) |element| { + if (element.selected and (std.mem.startsWith(u8, element.identity, "open-project:") or std.mem.startsWith(u8, element.identity, "loop:") or std.mem.startsWith(u8, element.identity, "overview-destination:"))) selected_sidebar_rows += 1; + if (std.mem.eql(u8, element.identity, "loop:B:loop") and element.selected) selected_beta_loops += 1; + if (std.mem.eql(u8, element.identity, "overview-destination:graph") and element.selected) selected_graph_destinations += 1; if (std.mem.eql(u8, element.identity, "workspace-loop-bar:loop")) loop_bars += 1; if (std.mem.eql(u8, element.identity, "workspace-toolbar:B")) toolbars += 1; if (std.mem.eql(u8, element.identity, "open-project:B") and element.selected) selected_beta_projects += 1; @@ -18680,7 +18935,10 @@ fn expectSettledGraphPublication(surface: GraphCanvas.Surface, owner: []const u8 try std.testing.expectEqualDeep(original_canvas, F.sink.canvas.?); try std.testing.expectEqual(@as(usize, if (surface == .workspace) 1 else 0), F.loop_bars); try std.testing.expectEqual(@as(usize, if (surface == .workspace) 1 else 0), F.toolbars); - try std.testing.expectEqual(@as(usize, 1), F.selected_beta_projects); + try std.testing.expectEqual(@as(usize, 0), F.selected_beta_projects); + try std.testing.expectEqual(@as(usize, 1), F.selected_sidebar_rows); + try std.testing.expectEqual(@as(usize, if (surface == .workspace) 1 else 0), F.selected_beta_loops); + try std.testing.expectEqual(@as(usize, if (surface == .overview) 1 else 0), F.selected_graph_destinations); try std.testing.expectEqual(@as(usize, if (surface == .overview) 0 else 1), F.selected_beta_cards); } @@ -19659,7 +19917,7 @@ test "DPI UIA logical cards headers sidebar and direct inserts scale once" { .{ .identity = "header-attention:needs-you", .bounds = .{ .{ 288, 5, 400, 29 }, .{ 432, 8, 600, 44 }, .{ 576, 10, 800, 58 } } }, .{ .identity = "header-worktree:worktrees", .bounds = .{ .{ 408, 5, 588, 29 }, .{ 612, 8, 882, 44 }, .{ 816, 10, 1176, 58 } } }, .{ .identity = "header-jump:jump", .bounds = .{ .{ 596, 5, 760, 29 }, .{ 894, 8, 1140, 44 }, .{ 1192, 10, 1520, 58 } } }, - .{ .identity = "sidebar-section:local", .bounds = .{ .{ 12, 109, 232, 135 }, .{ 18, 164, 348, 203 }, .{ 24, 218, 464, 270 } } }, + .{ .identity = "sidebar-section:local", .bounds = .{ .{ 10, 154, 212, 186 }, .{ 15, 231, 318, 279 }, .{ 20, 308, 424, 372 } } }, .{ .identity = "workspace-switch:B", .bounds = .{ .{ 250, 34, 500, 62 }, .{ 375, 51, 750, 93 }, .{ 500, 68, 1000, 124 } } }, }; // The footer is anchored to the sidebar's visible bottom, which the workspace panel @@ -19758,11 +20016,14 @@ const OverviewAccessibilitySink = struct { fn check(self: *@This(), elements: []const Accessibility.DynamicElement) !void { var cards: usize = 0; + var selected_sidebar: usize = 0; for (elements) |element| { if (std.mem.startsWith(u8, element.identity, "overview-card:") or std.mem.startsWith(u8, element.identity, "project-card:")) cards += 1; + if (element.selected and (std.mem.startsWith(u8, element.identity, "open-project:") or std.mem.startsWith(u8, element.identity, "loop:") or std.mem.startsWith(u8, element.identity, "overview-destination:"))) selected_sidebar += 1; } try std.testing.expectEqual(self.card_count, cards); + try std.testing.expectEqual(@as(usize, 1), selected_sidebar); for (self.expected) |expected| { var matches: usize = 0; for (elements) |element| { @@ -19986,7 +20247,8 @@ test "cross-project overview Open actions select exact projects and emit DPI sco else => .{ 524, 884, 964, 1056 }, }; var overview = OverviewAccessibilitySink{ .card_count = 6, .expected = &.{ - .{ .identity = "open-project:A", .selected = true }, + .{ .identity = "overview-destination:graph", .selected = true }, + .{ .identity = "open-project:A" }, .{ .identity = "open-project:B" }, .{ .identity = "overview-card:A:a1" }, .{ .identity = "overview-card:A:a2" }, @@ -20066,8 +20328,9 @@ test "cross-project overview refresh emits both projects without changing the se ); try expectOverviewSelection(&app, .overview, "B", "b2"); var sink = OverviewAccessibilitySink{ .card_count = 6, .expected = &.{ + .{ .identity = "overview-destination:graph", .selected = true }, .{ .identity = "open-project:A" }, - .{ .identity = "open-project:B", .selected = true }, + .{ .identity = "open-project:B" }, .{ .identity = "overview-card:A:a4", .name = "Alpha refreshed", .bounds = .{ 262, 118, 482, 204 } }, .{ .identity = "overview-card:A:a1", .name = "Alpha 1", .bounds = .{ 508, 118, 728, 204 } }, .{ .identity = "overview-card:A:a2" }, diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index ba60cae5..4165aa7e 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -13,8 +13,10 @@ pub const State = struct { activity_attention_only: bool = false, activity_scroll: usize = 0, collapsed_projects: std.StringHashMapUnmanaged(void) = .empty, - expanded_nodes: std.StringHashMapUnmanaged(void) = .empty, + collapsed_nodes: std.StringHashMapUnmanaged(void) = .empty, root_order: std.ArrayListUnmanaged([]u8) = .empty, + selection: Selection = .none, + workspace_name: []const u8 = "", pub fn init(allocator: std.mem.Allocator) State { return .{ .allocator = allocator }; @@ -22,7 +24,7 @@ pub const State = struct { pub fn deinit(self: *State) void { freeSet(self.allocator, &self.collapsed_projects); - freeSet(self.allocator, &self.expanded_nodes); + freeSet(self.allocator, &self.collapsed_nodes); for (self.root_order.items) |id| self.allocator.free(id); self.root_order.deinit(self.allocator); self.* = undefined; @@ -33,7 +35,7 @@ pub const State = struct { } pub fn toggleNode(self: *State, id: []const u8) !void { - try toggleSet(self.allocator, &self.expanded_nodes, id); + try toggleSet(self.allocator, &self.collapsed_nodes, id); } pub fn isProjectCollapsed(self: *const State, path: []const u8) bool { @@ -41,12 +43,12 @@ pub const State = struct { } pub fn isNodeExpanded(self: *const State, id: []const u8) bool { - return self.expanded_nodes.contains(id); + return !self.collapsed_nodes.contains(id); } pub fn clearExpandedNodes(self: *State) void { - freeSet(self.allocator, &self.expanded_nodes); - self.expanded_nodes = .empty; + freeSet(self.allocator, &self.collapsed_nodes); + self.collapsed_nodes = .empty; } pub fn reorderRoots(self: *State, roots: []const []const u8) !void { @@ -70,8 +72,8 @@ pub const State = struct { pub fn encode(self: *const State, allocator: std.mem.Allocator) ![]u8 { var result: std.ArrayList(u8) = .empty; errdefer result.deinit(allocator); - var iterator = self.expanded_nodes.keyIterator(); - while (iterator.next()) |id| try result.writer(allocator).print("expanded\t{s}\n", .{id.*}); + var iterator = self.collapsed_nodes.keyIterator(); + while (iterator.next()) |id| try result.writer(allocator).print("collapsed\t{s}\n", .{id.*}); for (self.root_order.items) |id| try result.writer(allocator).print("root\t{s}\n", .{id}); return result.toOwnedSlice(allocator); } @@ -79,10 +81,11 @@ pub const State = struct { pub fn decode(self: *State, data: []const u8) !void { var lines = std.mem.splitScalar(u8, data, '\n'); while (lines.next()) |line| { - if (!std.mem.startsWith(u8, line, "expanded\t")) continue; - const id = line["expanded\t".len..]; - if (id.len != 0 and !self.expanded_nodes.contains(id)) - try self.expanded_nodes.put(self.allocator, try self.allocator.dupe(u8, id), {}); + // Legacy files only recorded expanded nodes; unrecorded nodes now start expanded. + if (!std.mem.startsWith(u8, line, "collapsed\t")) continue; + const id = line["collapsed\t".len..]; + if (id.len != 0 and !self.collapsed_nodes.contains(id)) + try self.collapsed_nodes.put(self.allocator, try self.allocator.dupe(u8, id), {}); } var roots = std.mem.splitScalar(u8, data, '\n'); while (roots.next()) |line| { @@ -178,10 +181,9 @@ pub fn draw( hover_y: i32, allocator: std.mem.Allocator, ) void { - const sidebar = rect(0, Tokens.header_height, Tokens.sidebar_width, 1200); + _ = status; + const sidebar = rect(0, Tokens.header_height, Tokens.sidebar_width, viewport_bottom); fill(hdc, sidebar, Tokens.workspace_rail); - drawText(hdc, allocator, "GRAPH", 18, Tokens.header_height + 20, 16, 0x00FFFFFF); - drawText(hdc, allocator, "Projects", 18, Tokens.header_height + 54, 14, 0x00B8B8B8); const footer = effectiveFooter(viewport_bottom, update_version.len != 0, ingress_error.len != 0); const has_update = footer.update; const has_error = footer.ingress; @@ -199,55 +201,56 @@ pub fn draw( }; defer rows.deinit(allocator); for (rows.items) |row| { + const ink: u32 = 0x00E6E6E6; + const selected = rowSelected(row, model, state.selection); + if (selected) roundedFill(hdc, rowBounds(row.top), selection_blue, 8); switch (row.kind) { .local_heading => { - drawText(hdc, allocator, if (state.local_collapsed) "> LOCAL" else "v LOCAL", 18, row.top, 10, 0x007A7A7A); + drawTextRect(hdc, allocator, "Local Projects", rect(18, row.top, Tokens.sidebar_width - 14, row.top + row_height), 11, 0x00909090, loop_text_format | c.DT_VCENTER); + if (hover_y >= row.top and hover_y < row.top + row_height) + drawChevron(hdc, 207, row.top + 16, !state.local_collapsed, 0x00909090); }, .remote_heading => { - drawText(hdc, allocator, if (state.remote_collapsed) "> REMOTE" else "v REMOTE", 18, row.top, 10, 0x007A7A7A); + drawTextRect(hdc, allocator, "Remote Repositories", rect(18, row.top, Tokens.sidebar_width - 14, row.top + row_height), 11, 0x00909090, loop_text_format | c.DT_VCENTER); + if (hover_y >= row.top and hover_y < row.top + row_height) + drawChevron(hdc, 207, row.top + 16, !state.remote_collapsed, 0x00909090); }, .project => { const project = model.recent_projects.items[row.index]; - drawText(hdc, allocator, if (project.isRemote()) "R" else "L", 18, row.top + 1, 9, 0x007A7A7A); - drawText(hdc, allocator, project.name, 34, row.top, 13, 0x00E6E6E6); + drawIcon(hdc, if (project.isRemote()) .network else .folder, 18, row.top + 9, ink); + drawTextRect(hdc, allocator, project.name, projectTitleRect(row.top), 13, ink, loop_text_format | c.DT_VCENTER); }, .overview => { - drawText(hdc, allocator, "G", 18, row.top, 11, 0x007AB8FF); - drawText(hdc, allocator, "Graph", 34, row.top, 13, 0x00E6E6E6); + drawIcon(hdc, .graph, 18, row.top + 9, ink); + drawTextRect(hdc, allocator, "Graph", projectTitleRect(row.top), 13, ink, loop_text_format | c.DT_VCENTER); }, - .open_project => if (row.project_path) |path| if (model.graphFor(path)) |summary| { - const selected = if (model.selected_project_path) |selected_path| - std.mem.eql(u8, selected_path, path) - else - false; - drawText(hdc, allocator, if (state.isProjectCollapsed(path)) ">" else "v", 18, row.top, 9, 0x007A7A7A); - drawText(hdc, allocator, if (summary.project.isRemote()) "R" else "L", 31, row.top + 1, 9, 0x007A7A7A); - drawText(hdc, allocator, summary.project.name, 44, row.top, 13, if (selected) 0x00FFFFFF else 0x00D0D0D0); - if (hover_y >= row.top and hover_y < row.top + 24) { - drawText(hdc, allocator, "+", 181, row.top, 13, 0x00B8B8B8); - if (row.has_children) drawText(hdc, allocator, if (state.isProjectCollapsed(path)) ">" else "v", 204, row.top, 9, 0x00B8B8B8); + .open_project => if (row.project_path) |path| { + const project = projectForRow(model, path) orelse continue; + drawIcon(hdc, if (project.isRemote()) .network else .folder, 18, row.top + 9, ink); + drawTextRect(hdc, allocator, project.name, projectTitleRect(row.top), 13, ink, loop_text_format | c.DT_VCENTER); + if (hover_y >= row.top and hover_y < row.top + row_height) { + drawPlus(hdc, 184, row.top + 16, ink); + if (row.has_children) drawChevron(hdc, 207, row.top + 16, !state.isProjectCollapsed(path), ink); } }, .loop => if (row.project_path) |path| if (model.graphFor(path)) |summary| { if (row.index < summary.nodes.items.len) { const node = summary.nodes.items[row.index]; - const indent = @as(i32, @intCast(row.depth * 12)); - if (row.depth != 0) drawText(hdc, allocator, ">", 28 + indent, row.top, 9, 0x006A6A6A); - fill(hdc, rect(30 + indent, row.top - 2, 33 + indent, row.top + 17), loopAccent(node.loop_type)); + const indent = @as(i32, @intCast(@min(row.depth, max_visual_depth))) * 16; + roundedFill(hdc, rect(34 + indent, row.top + 9, 37 + indent, row.top + 23), loopAccent(node.loop_type), 3); const text = loopRowTextRects(row.top, row.depth); - drawTextRect(hdc, allocator, node.title, text.title, 11, 0x00E6E6E6, loop_text_format); - drawTextRect(hdc, allocator, compactState(node.state), text.state, 9, stateColor(node.state), loop_text_format); + drawTextRect(hdc, allocator, node.title, text.title, 13, ink, loop_text_format | c.DT_VCENTER); + drawStateDot(hdc, text.state, displayState(node)); const elapsed = elapsedText(allocator, @intCast(node.created_at orelse 0), std.time.timestamp()) catch null; defer if (elapsed) |value| allocator.free(value); - if (elapsed) |value| drawTextRect(hdc, allocator, value, text.age, 9, 0x008E8E93, loop_text_format | c.DT_RIGHT); - if (row.has_children and hover_y >= row.top and hover_y < row.top + 24) - drawText(hdc, allocator, if (state.isNodeExpanded(node.id)) "v" else ">", 204, row.top, 9, 0x00B8B8B8); + if (elapsed) |value| drawTextRect(hdc, allocator, value, text.age, 10, 0x008E8E93, loop_text_format | c.DT_RIGHT | c.DT_VCENTER); + if (row.has_children) drawChevron(hdc, 207, row.top + 16, state.isNodeExpanded(node.id), 0x007A7A7A); } }, .worktree => if (inspection) |value| { const entry = value.entries.items[row.index]; - const selected = std.mem.eql(u8, entry.path, selected_worktree_path); - if (selected and WorktreeStatus.decision(entry) == .reclaimable) + const worktree_selected = std.mem.eql(u8, entry.path, selected_worktree_path); + if (worktree_selected and WorktreeStatus.decision(entry) == .reclaimable) fill(hdc, rect(12, row.top - 3, Tokens.sidebar_width - 12, row.top + 25), 0x003A3A44); drawText(hdc, allocator, entry.path, 24, row.top, 11, 0x00E6E6E6); const size = WorktreeStatus.sizeCoverageText(allocator, entry.sizeCoverage()) catch null; @@ -260,23 +263,18 @@ pub fn draw( drawText(hdc, allocator, detail orelse reason(entry), 24, row.top + 14, 10, if (WorktreeStatus.decision(entry) == .reclaimable) 0x0078D7A8 else 0x00FFCD7A); }, .quick_chat_overview => { - drawText(hdc, allocator, if (state.chats_collapsed) ">" else "v", 18, row.top, 9, 0x007A7A7A); - drawText(hdc, allocator, "Quick Chats", 32, row.top, 13, 0x00E6E6E6); - if (hover_y >= row.top and hover_y < row.top + 24) { - drawText(hdc, allocator, "+", 181, row.top, 13, 0x00B8B8B8); + drawIcon(hdc, .chats, 18, row.top + 9, ink); + drawTextRect(hdc, allocator, "Quick Chats", projectTitleRect(row.top), 13, ink, loop_text_format | c.DT_VCENTER); + if (hover_y >= row.top and hover_y < row.top + row_height) { + drawPlus(hdc, 184, row.top + 16, ink); if (model.quick_chats.items.len != 0) - drawText(hdc, allocator, if (state.chats_collapsed) ">" else "v", 204, row.top, 9, 0x00B8B8B8); + drawChevron(hdc, 207, row.top + 16, !state.chats_collapsed, ink); } }, .quick_chat => if (row.index < model.quick_chats.items.len) - drawText(hdc, allocator, model.quick_chats.items[row.index].title, 24, row.top, 11, 0x00E6E6E6), + drawTextRect(hdc, allocator, model.quick_chats.items[row.index].title, rect(34, row.top, Tokens.sidebar_width - 14, row.top + row_height), 13, ink, loop_text_format | c.DT_VCENTER), } } - for (rows.items) |row| if (row.kind == .quick_chat_overview) { - const label = chatsLabelRect(row.top); - drawText(hdc, allocator, "CHATS", label.left, label.top, 10, 0x007A7A7A); - break; - }; const section_y = sidebarSectionBottom(model, inspection, state) - scroll_offset; if (model.attentionCount() != 0) { @@ -344,42 +342,52 @@ pub fn draw( defer if (detail) |value| allocator.free(value); drawText(hdc, allocator, detail orelse update_version, bounds.left + 30, bounds.top + 24, 10, 0x00909090); } - drawTextRect( - hdc, - allocator, - status, - statusTextRect(viewport_bottom, has_update, has_error), - 11, - 0x00909090, - c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS | c.DT_NOPREFIX, - ); + if (state.workspace_name.len != 0) { + const workspace = workspaceFooterRect(viewport_bottom); + fill(hdc, workspace, 0x00282624); + fill(hdc, rect(workspace.left, workspace.top, workspace.right, workspace.top + 1), 0x00454341); + drawIcon(hdc, .workspace, 18, workspace.top + 9, 0x00909090); + drawTextRect(hdc, allocator, state.workspace_name, rect(40, workspace.top, Tokens.sidebar_width - 28, workspace.bottom), 13, 0x00D0D0D0, loop_text_format | c.DT_VCENTER); + const x = Tokens.sidebar_width - 16; + stroke(hdc, &.{ .{ .x = x - 3, .y = workspace.top + 14 }, .{ .x = x, .y = workspace.top + 11 }, .{ .x = x + 3, .y = workspace.top + 14 } }, 0x00909090); + drawChevron(hdc, x, workspace.top + 20, true, 0x00909090); + } } const loop_text_format: c.UINT = c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS | c.DT_NOPREFIX; -const loop_age_right: i32 = 198; -const loop_age_width: i32 = 28; -const loop_state_width: i32 = 44; +pub const row_height: i32 = 32; +const max_visual_depth: usize = 3; +pub const selection_blue: u32 = 0x00D15900; +pub const Selection = union(enum) { none, overview, quick_chats, project: []const u8, loop: []const u8, chat: []const u8 }; + +pub fn selectionForSurface(model: *const GraphModel.Model, surface: anytype, quick_chat: bool, chat_index: ?usize, worktree_project: ?[]const u8) Selection { + return switch (surface) { + .overview => if (worktree_project) |path| .{ .project = path } else .overview, + .quick_chats => .quick_chats, + .project => if (model.selected_project_path) |path| .{ .project = path } else .none, + .workspace => if (quick_chat) blk: { + const index = chat_index orelse break :blk .none; + break :blk if (index < model.quick_chats.items.len) .{ .chat = model.quick_chats.items[index].id } else .none; + } else if (model.selected_node_id) |id| .{ .loop = id } else .none, + }; +} + +const loop_age_right: i32 = 188; +const loop_age_width: i32 = 24; const loop_text_gap: i32 = 4; pub const LoopRowText = struct { title: c.RECT, state: c.RECT, age: c.RECT }; -/// Disjoint columns for a loop row's title, status label, and age. The age is right-aligned -/// against the hover disclosure's hit zone (x >= 198), the status sits left of it, and the -/// title takes the rest, so each label elides inside its own column instead of overlapping -/// its neighbour at the default sidebar width. +/// Reserve age, state dot, and disclosure before giving the rest to the indented title. pub fn loopRowTextRects(row_top: i32, depth: usize) LoopRowText { - const indent = @as(i32, @intCast(depth * 12)); - const age = rect(loop_age_right - loop_age_width, row_top, loop_age_right, row_top + 17); - const state = rect(age.left - loop_text_gap - loop_state_width, row_top, age.left - loop_text_gap, row_top + 17); - const title_left = 39 + indent; - const title_right = @max(state.left - loop_text_gap, title_left); - return .{ .title = rect(title_left, row_top, title_right, row_top + 19), .state = state, .age = age }; + const indent = @as(i32, @intCast(@min(depth, max_visual_depth))) * 16; + const age = rect(loop_age_right - loop_age_width, row_top, loop_age_right, row_top + row_height); + const state = rect(190, row_top + 12, 197, row_top + 19); + const title_left = 44 + indent; + return .{ .title = rect(title_left, row_top, age.left - loop_text_gap, row_top + row_height), .state = state, .age = age }; } -/// Top edge of the scrollable sidebar content, 3 px above the first row's top so a row's -/// selection fill is never cut at rest. The GRAPH / Projects header above it is fixed, so rows, -/// labels, hit targets, and UIA bounds all start here and never overlap it. -pub const content_top: i32 = Tokens.header_height + 78 - 3; +pub const content_top: i32 = Tokens.header_height + 12; const footer_status_extent: i32 = 58; const footer_error_extent: i32 = 50; @@ -432,8 +440,33 @@ pub fn statusTextRect(viewport_bottom: i32, has_update: bool, has_error: bool) c return rect(18, top, Tokens.sidebar_width - 10, top + status_line_height); } -pub fn chatsLabelRect(row_top: i32) c.RECT { - return rect(18, row_top - 32, Tokens.sidebar_width - 10, row_top - 32 + 18); +pub fn rowBounds(top: i32) c.RECT { + return rect(10, top, Tokens.sidebar_width - 8, top + row_height); +} + +pub fn workspaceFooterRect(viewport_bottom: i32) c.RECT { + return rect(0, @max(Tokens.header_height, viewport_bottom - 34), Tokens.sidebar_width, viewport_bottom); +} + +pub fn workspaceName(path: []const u8) []const u8 { + const base = std.fs.path.basenameWindows(path); + if (std.mem.startsWith(u8, base, ".graphcode-")) return base[".graphcode-".len..]; + if (base.len == 0 or std.mem.eql(u8, base, ".graphcode")) return "Default"; + return base; +} + +pub fn workspaceWorthShowing(path: []const u8, known_count: usize) bool { + const base = std.fs.path.basenameWindows(path); + return (base.len != 0 and !std.mem.eql(u8, base, ".graphcode")) or known_count > 1; +} + +pub fn workspaceFooterAt(x: i32, y: i32, viewport_bottom: i32) bool { + const bounds = workspaceFooterRect(viewport_bottom); + return x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom; +} + +fn projectTitleRect(top: i32) c.RECT { + return rect(42, top, 174, top + row_height); } pub fn needsYouLabelRect(section_bottom: i32, scroll_offset: i32) c.RECT { @@ -483,10 +516,11 @@ pub const ActivityViewport = struct { }; fn loopAccent(loop_type: []const u8) u32 { - if (std.mem.eql(u8, loop_type, "goalBased")) return 0x0048C78E; - if (std.mem.eql(u8, loop_type, "timeBased")) return 0x00D6A649; - if (std.mem.eql(u8, loop_type, "composite")) return 0x00C77DFF; - return 0x007AB8FF; + if (std.mem.eql(u8, loop_type, "goalBased")) return 0x00709E19; + if (std.mem.eql(u8, loop_type, "timeBased")) return 0x000085C9; + if (std.mem.eql(u8, loop_type, "turnBased")) return 0x008151D5; + if (std.mem.eql(u8, loop_type, "composite") or std.mem.eql(u8, loop_type, "proactive")) return 0x00E98590; + return 0x00E3DAD3; } fn compactState(state: []const u8) []const u8 { @@ -538,11 +572,24 @@ pub fn loopRowTop(project_count: usize, index: usize) i32 { return layout.loopTop(index); } -pub fn loopRowTopForModel(model: *const GraphModel.Model, index: usize) i32 { - const layout = layoutFor(model, null); - const graph = model.currentGraph() orelse return layout.loopTop(index); - const position = hierarchyPosition(std.heap.page_allocator, graph.nodes.items, graph.edges.items, index) catch index; - return layout.loopTop(position); +pub fn loopRowTopForModel(model: *const GraphModel.Model, index: usize, inspection: ?*const WorktreeStatus.Inspection, state: *const State) !?i32 { + const graph = model.currentGraph() orelse return null; + var rows = try appendRows(model.allocator, model, inspection, 0, state); + defer rows.deinit(model.allocator); + var parent_top: ?i32 = null; + for (rows.items) |row| { + if (row.kind == .loop and row.index == index and row.project_path != null and std.mem.eql(u8, row.project_path.?, graph.project.path)) return row.top; + if (row.kind == .open_project and row.project_path != null and std.mem.eql(u8, row.project_path.?, graph.project.path)) { + parent_top = row.top; + } else if (parent_top == null and + (row.kind == .overview and graph.project.isGlobal() or + row.kind == .local_heading and !graph.project.isRemote() or + row.kind == .remote_heading and graph.project.isRemote())) + { + parent_top = row.top; + } + } + return parent_top; } pub fn worktreeRowTop(project_count: usize, loop_count: usize, index: usize) i32 { @@ -550,15 +597,11 @@ pub fn worktreeRowTop(project_count: usize, loop_count: usize, index: usize) i32 return layout.worktreeTop(index); } -pub fn worktreeRowTopForModel(model: *const GraphModel.Model, loop_count: usize, index: usize) i32 { - const layout = Layout{ - .base = Tokens.header_height + 78, - .project_count = visibleProjectCount(model), - .project_heading_count = projectHeadingCount(model), - .loop_count = loop_count, - .worktree_count = 0, - }; - return layout.worktreeTop(index); +pub fn worktreeRowTopForModel(model: *const GraphModel.Model, inspection: *const WorktreeStatus.Inspection, index: usize, state: *const State) !?i32 { + var rows = try appendRows(model.allocator, model, inspection, 0, state); + defer rows.deinit(model.allocator); + for (rows.items) |row| if (row.kind == .worktree and row.index == index) return row.top; + return null; } pub const RowKind = enum { local_heading, remote_heading, project, open_project, overview, loop, worktree, quick_chat_overview, quick_chat }; @@ -586,11 +629,10 @@ pub const Layout = struct { return self.base + @as(i32, @intCast(index * 24)); } pub fn overviewTop(self: Layout) i32 { - return self.base + self.projectSectionHeight() + 24; + return self.base; } pub fn loopTop(self: Layout, index: usize) i32 { - return self.base + self.projectSectionHeight() + 86 + - @as(i32, @intCast(index * 24)); + return self.base + 12 + @as(i32, @intCast(3 + self.quick_chat_count + self.project_count + self.project_heading_count + index)) * row_height; } pub fn worktreeTop(self: Layout, index: usize) i32 { return self.base + self.projectSectionHeight() + 140 + @@ -598,16 +640,10 @@ pub const Layout = struct { @as(i32, @intCast(index * 34)); } pub fn quickChatHeadingTop(self: Layout) i32 { - return self.quickChatRowTop(0) - 32; + return self.quickChatRowTop(0); } pub fn quickChatRowTop(self: Layout, index: usize) i32 { - const project_bottom = self.base + self.projectSectionHeight(); - const worktree_height: i32 = if (self.inspection_present) - 24 + @as(i32, @intCast(self.worktree_count * 34)) - else - 0; - const section_bottom = project_bottom + self.graph_section_height + worktree_height; - return section_bottom + 42 + @as(i32, @intCast(index * 24)); + return self.base + @as(i32, @intCast(1 + index)) * row_height; } fn projectSectionHeight(self: Layout) i32 { return @as(i32, @intCast((self.project_count + self.project_heading_count) * 24)); @@ -622,68 +658,25 @@ pub fn appendRows( state: ?*const State, ) !std.ArrayList(Row) { var rows: std.ArrayList(Row) = .empty; - var top: i32 = Tokens.header_height + 78 - scroll_offset; + errdefer rows.deinit(allocator); + var top: i32 = content_top - scroll_offset; const local_collapsed = if (state) |value| value.local_collapsed else false; const remote_collapsed = if (state) |value| value.remote_collapsed else false; const chats_collapsed = if (state) |value| value.chats_collapsed else false; - if (hasLocalProjects(model)) { - try rows.append(allocator, .{ .kind = .local_heading, .index = 0, .top = top }); - top += 24; - } - if (!local_collapsed) { - for (model.recent_projects.items, 0..) |project, index| { - if (!projectIsVisibleInSection(model, project, .local)) continue; - try rows.append(allocator, .{ .kind = .project, .index = index, .top = top, .project_path = project.path }); - top += 24; - } - } - if (hasRemoteProjects(model)) { - try rows.append(allocator, .{ .kind = .remote_heading, .index = 0, .top = top }); - top += 24; - } - if (!remote_collapsed) { - for (model.recent_projects.items, 0..) |project, index| { - if (!projectIsVisibleInSection(model, project, .remote)) continue; - try rows.append(allocator, .{ .kind = .project, .index = index, .top = top, .project_path = project.path }); - top += 24; - } - } - try rows.append(allocator, .{ .kind = .overview, .index = 0, .top = top + 24 }); - top += 62; - for (model.open_projects.items, 0..) |project, index| { - if (hasGraphForProject(model, project.path)) continue; - try rows.append(allocator, .{ .kind = .open_project, .index = index, .top = top, .project_path = project.path }); - top += 62; - } - if (model.graphs.items.len != 0 or model.graph != null) { - if (model.graphs.items.len != 0) { - for (model.graphs.items, 0..) |summary, graph_index| { - try rows.append(allocator, .{ .kind = .open_project, .index = graph_index, .top = top, .project_path = summary.project.path, .has_children = summary.nodes.items.len != 0 }); - top += 24; - if (state == null or !state.?.isProjectCollapsed(summary.project.path)) { - var hierarchy = try hierarchyItems(allocator, summary.nodes.items, summary.edges.items, state); - defer hierarchy.deinit(allocator); - for (hierarchy.items) |item| { - try rows.append(allocator, .{ .kind = .loop, .index = item.index, .top = top, .project_path = summary.project.path, .depth = item.depth, .has_children = item.has_children }); - top += 24; - } - } - top += 38; - } - } else if (model.graph) |graph| { - try rows.append(allocator, .{ .kind = .open_project, .index = 0, .top = top, .project_path = graph.project.path, .has_children = graph.nodes.items.len != 0 }); - top += 24; - if (state == null or !state.?.isProjectCollapsed(graph.project.path)) { - var hierarchy = try hierarchyItems(allocator, graph.nodes.items, graph.edges.items, state); - defer hierarchy.deinit(allocator); - for (hierarchy.items) |item| { - try rows.append(allocator, .{ .kind = .loop, .index = item.index, .top = top, .project_path = graph.project.path, .depth = item.depth, .has_children = item.has_children }); - top += 24; - } - } - top += 38; + try rows.append(allocator, .{ .kind = .overview, .index = 0, .top = top }); + top += row_height; + if (model.graphFor("graphcode://global")) |graph| + try appendLoopRows(allocator, &rows, graph, &top, state); + try rows.append(allocator, .{ .kind = .quick_chat_overview, .index = 0, .top = top }); + top += row_height; + if (!chats_collapsed) { + for (model.quick_chats.items, 0..) |_, index| { + try rows.append(allocator, .{ .kind = .quick_chat, .index = index, .top = top }); + top += row_height; } } + if (hasLocalProjects(model)) try appendProjectSection(allocator, &rows, model, &top, state, .local, local_collapsed); + if (hasRemoteProjects(model)) try appendProjectSection(allocator, &rows, model, &top, state, .remote, remote_collapsed); if (inspection) |value| { top += 24; for (value.entries.items, 0..) |_, index| { @@ -691,16 +684,47 @@ pub fn appendRows( top += 34; } } - top += 42; - try rows.append(allocator, .{ .kind = .quick_chat_overview, .index = 0, .top = top }); - top += 24; - if (!chats_collapsed) { - for (model.quick_chats.items, 0..) |_, index| { - try rows.append(allocator, .{ .kind = .quick_chat, .index = index, .top = top }); - top += 24; + return rows; +} + +fn appendProjectSection(allocator: std.mem.Allocator, rows: *std.ArrayList(Row), model: *const GraphModel.Model, top: *i32, state: ?*const State, section: ProjectSection, collapsed: bool) !void { + top.* += 12; + try rows.append(allocator, .{ .kind = if (section == .local) .local_heading else .remote_heading, .index = 0, .top = top.* }); + top.* += row_height; + if (collapsed) return; + for (model.recent_projects.items, 0..) |project, index| { + if (!projectIsVisibleInSection(model, project, section) or project.isGlobal()) continue; + try rows.append(allocator, .{ .kind = .project, .index = index, .top = top.*, .project_path = project.path }); + top.* += row_height; + } + for (model.open_projects.items, 0..) |project, index| { + if (hasGraphForProject(model, project.path) or project.isGlobal() or project.isRemote() != (section == .remote)) continue; + try rows.append(allocator, .{ .kind = .open_project, .index = index, .top = top.*, .project_path = project.path }); + top.* += row_height; + } + if (model.graphs.items.len != 0) { + for (model.graphs.items, 0..) |graph, index| { + if (graph.project.isGlobal() or graph.project.isRemote() != (section == .remote)) continue; + try rows.append(allocator, .{ .kind = .open_project, .index = index, .top = top.*, .project_path = graph.project.path, .has_children = graph.nodes.items.len != 0 }); + top.* += row_height; + try appendLoopRows(allocator, rows, graph, top, state); } + } else if (model.graph) |graph| { + if (graph.project.isGlobal() or graph.project.isRemote() != (section == .remote)) return; + try rows.append(allocator, .{ .kind = .open_project, .index = 0, .top = top.*, .project_path = graph.project.path, .has_children = graph.nodes.items.len != 0 }); + top.* += row_height; + try appendLoopRows(allocator, rows, graph, top, state); + } +} + +fn appendLoopRows(allocator: std.mem.Allocator, rows: *std.ArrayList(Row), graph: anytype, top: *i32, state: ?*const State) !void { + if (state) |value| if (value.isProjectCollapsed(graph.project.path)) return; + var hierarchy = try hierarchyItems(allocator, graph.nodes.items, graph.edges.items, state); + defer hierarchy.deinit(allocator); + for (hierarchy.items) |item| { + try rows.append(allocator, .{ .kind = .loop, .index = item.index, .top = top.*, .project_path = graph.project.path, .depth = item.depth, .has_children = item.has_children }); + top.* += row_height; } - return rows; } pub fn layoutFor(model: *const GraphModel.Model, inspection: ?*const WorktreeStatus.Inspection) Layout { @@ -717,7 +741,7 @@ pub fn layoutFor(model: *const GraphModel.Model, inspection: ?*const WorktreeSta } return .{ - .base = Tokens.header_height + 78, + .base = content_top, .project_count = visibleProjectCount(model), .project_heading_count = projectHeadingCount(model), .loop_count = loop_count, @@ -730,20 +754,23 @@ pub fn layoutFor(model: *const GraphModel.Model, inspection: ?*const WorktreeSta } pub fn sharedGraphTop(model: *const GraphModel.Model, graph_index: usize) i32 { - var top = Tokens.header_height + 78 + - @as(i32, @intCast((visibleProjectCount(model) + projectHeadingCount(model)) * 24)) + - @as(i32, @intCast(openProjectPlaceholderCount(model) * 62)) + 36; - for (model.graphs.items[0..@min(graph_index, model.graphs.items.len)]) |summary| { - top += 62 + @as(i32, @intCast(summary.nodes.items.len * 24)); - } - return top; + if (graph_index >= model.graphs.items.len) return content_top; + return generatedRowTop(model, .open_project, model.graphs.items[graph_index].project.path, 0); } pub fn sharedLoopTop(model: *const GraphModel.Model, graph_index: usize, node_index: usize) i32 { - if (graph_index >= model.graphs.items.len) return sharedGraphTop(model, graph_index) + 48; + if (graph_index >= model.graphs.items.len) return content_top; const graph = model.graphs.items[graph_index]; - const position = hierarchyPosition(std.heap.page_allocator, graph.nodes.items, graph.edges.items, node_index) catch node_index; - return sharedGraphTop(model, graph_index) + 48 + @as(i32, @intCast(position * 24)); + return generatedRowTop(model, .loop, graph.project.path, node_index); +} + +fn generatedRowTop(model: *const GraphModel.Model, kind: RowKind, path: []const u8, index: usize) i32 { + var rows = appendRows(std.heap.page_allocator, model, null, 0, null) catch return content_top; + defer rows.deinit(std.heap.page_allocator); + for (rows.items) |row| { + if (row.kind == kind and (kind != .loop or row.index == index) and row.project_path != null and std.mem.eql(u8, row.project_path.?, path)) return row.top; + } + return content_top; } pub fn sharedWorktreeTop(model: *const GraphModel.Model, index: usize) i32 { @@ -770,7 +797,7 @@ pub fn rowAt( defer rows.deinit(std.heap.page_allocator); for (rows.items) |row| { const height: i32 = switch (row.kind) { - .local_heading, .remote_heading, .project, .open_project, .overview, .loop, .quick_chat_overview, .quick_chat => 24, + .local_heading, .remote_heading, .project, .open_project, .overview, .loop, .quick_chat_overview, .quick_chat => row_height, .worktree => 34, }; if (y >= row.top and y < row.top + height) return row; @@ -779,16 +806,18 @@ pub fn rowAt( } fn hasLocalProjects(model: *const GraphModel.Model) bool { - for (model.recent_projects.items) |project| { - if (projectIsVisibleInSection(model, project, .local)) return true; - } - return false; + return hasProjectsInSection(model, .local); } fn hasRemoteProjects(model: *const GraphModel.Model) bool { - for (model.recent_projects.items) |project| { - if (projectIsVisibleInSection(model, project, .remote)) return true; - } + return hasProjectsInSection(model, .remote); +} + +fn hasProjectsInSection(model: *const GraphModel.Model, section: ProjectSection) bool { + for (model.recent_projects.items) |project| if (!project.isGlobal() and project.isRemote() == (section == .remote)) return true; + for (model.open_projects.items) |project| if (!project.isGlobal() and project.isRemote() == (section == .remote)) return true; + for (model.graphs.items) |graph| if (!graph.project.isGlobal() and graph.project.isRemote() == (section == .remote)) return true; + if (model.graph) |graph| if (!graph.project.isGlobal() and graph.project.isRemote() == (section == .remote)) return true; return false; } @@ -854,12 +883,23 @@ fn hierarchyItems( const visited = try allocator.alloc(bool, nodes.len); defer allocator.free(visited); @memset(visited, false); + const reachable = try allocator.alloc(bool, nodes.len); + defer allocator.free(reachable); + @memset(reachable, false); var roots = try collectRootIndices(allocator, nodes, edges, state, false, visited); defer roots.deinit(allocator); - for (roots.items) |index| try appendHierarchy(allocator, &result, visited, nodes, edges, index, 0, state); - var unresolved = try collectRootIndices(allocator, nodes, edges, state, true, visited); + for (roots.items) |index| { + reachable[index] = true; + markDescendantsVisited(reachable, nodes, edges, index); + try appendHierarchy(allocator, &result, visited, nodes, edges, index, 0, state); + } + var unresolved = try collectRootIndices(allocator, nodes, edges, state, true, reachable); defer unresolved.deinit(allocator); - for (unresolved.items) |index| if (!visited[index]) try appendHierarchy(allocator, &result, visited, nodes, edges, index, 0, state); + for (unresolved.items) |index| if (!reachable[index]) { + reachable[index] = true; + markDescendantsVisited(reachable, nodes, edges, index); + try appendHierarchy(allocator, &result, visited, nodes, edges, index, 0, state); + }; return result; } @@ -880,7 +920,7 @@ fn collectRootIndices( } var incoming = false; for (edges) |edge| { - if (std.mem.eql(u8, edge.kind, "handoff") and std.mem.eql(u8, edge.to, node.id)) { + if (std.mem.eql(u8, edge.to, node.id) and GraphModel.findNodeIndexByID(nodes, edge.from) != null) { incoming = true; break; } @@ -926,8 +966,7 @@ fn appendHierarchy( visited[index] = true; var has_children = false; for (edges) |edge| { - if (std.mem.eql(u8, edge.kind, "handoff") and - std.mem.eql(u8, edge.from, nodes[index].id) and + if (std.mem.eql(u8, edge.from, nodes[index].id) and GraphModel.findNodeIndexByID(nodes, edge.to) != null) { has_children = true; @@ -936,14 +975,10 @@ fn appendHierarchy( } try result.append(allocator, .{ .index = index, .depth = depth, .has_children = has_children }); if (depth != 0 or has_children) { - if (state) |value| if (!value.isNodeExpanded(nodes[index].id)) { - markDescendantsVisited(visited, nodes, edges, index); - return; - }; + if (state) |value| if (!value.isNodeExpanded(nodes[index].id)) return; } for (edges) |edge| { - if (!std.mem.eql(u8, edge.kind, "handoff") or - !std.mem.eql(u8, edge.from, nodes[index].id)) continue; + if (!std.mem.eql(u8, edge.from, nodes[index].id)) continue; const child = GraphModel.findNodeIndexByID(nodes, edge.to) orelse continue; try appendHierarchy(allocator, result, visited, nodes, edges, child, depth + 1, state); } @@ -957,8 +992,7 @@ fn markDescendantsVisited( ) void { if (index >= nodes.len) return; for (edges) |edge| { - if (!std.mem.eql(u8, edge.kind, "handoff") or - !std.mem.eql(u8, edge.from, nodes[index].id)) continue; + if (!std.mem.eql(u8, edge.from, nodes[index].id)) continue; const child = GraphModel.findNodeIndexByID(nodes, edge.to) orelse continue; if (visited[child]) continue; visited[child] = true; @@ -1013,7 +1047,7 @@ pub fn sidebarSectionBottom(model: *const GraphModel.Model, inspection: ?*const defer rows.deinit(std.heap.page_allocator); if (rows.items.len == 0) return Tokens.header_height; const last = rows.items[rows.items.len - 1]; - return last.top + (if (last.kind == .worktree) @as(i32, 34) else 24); + return last.top + (if (last.kind == .worktree) @as(i32, 34) else row_height); } pub fn needsYouStopBounds( @@ -1283,6 +1317,154 @@ fn reason(entry: WorktreeStatus.Entry) []const u8 { return if (WorktreeStatus.decision(entry) == .reclaimable) "safe to reclaim" else "unsafe to reclaim"; } +test "parity sidebar pins Graph and Quick Chats above captioned open folders" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\fixture","name":"Fixture"},"nodes":[{"id":"root","title":"Root","state":"idle"}],"edges":[]}}} + ); + var inspection = WorktreeStatus.Inspection{ + .entries = std.array_list.Managed(WorktreeStatus.Entry).init(allocator), + .default_branch = @constCast("main"), + .project_path = @constCast("C:\\fixture"), + }; + defer inspection.entries.deinit(); + try inspection.entries.append(.{ .path = @constCast("C:\\fixture\\wt"), .branch = @constCast("topic") }); + var rows = try appendRows(allocator, &model, &inspection, 0, null); + defer rows.deinit(allocator); + try std.testing.expectEqual(RowKind.overview, rows.items[0].kind); + try std.testing.expectEqual(RowKind.quick_chat_overview, rows.items[1].kind); + try std.testing.expectEqual(RowKind.local_heading, rows.items[2].kind); + try std.testing.expectEqual(RowKind.open_project, rows.items[3].kind); + try std.testing.expectEqual(@as(usize, 1), countRows(rows.items, .worktree)); +} + +test "parity sidebar expands the whole mixed-edge tree by default without duplicating a cycle" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\fixture","name":"Fixture"},"nodes":[{"id":"root","title":"Main","state":"idle"},{"id":"goal","title":"Goal","state":"awaitingInput"},{"id":"turn","title":"Turn","state":"idle"},{"id":"timed","title":"Timed","state":"stopped"}],"edges":[{"id":"a","from":"root","to":"goal","kind":"spawn"},{"id":"b","from":"goal","to":"turn","kind":"handoff"},{"id":"c","from":"turn","to":"timed","kind":"message"},{"id":"d","from":"turn","to":"goal","kind":"handoff"}]}}} + ); + var state = State.init(allocator); + defer state.deinit(); + var rows = try appendRows(allocator, &model, null, 0, &state); + defer rows.deinit(allocator); + try std.testing.expectEqual(@as(usize, 4), countRows(rows.items, .loop)); + var depth: usize = 0; + for (rows.items) |row| if (row.kind == .loop) { + try std.testing.expectEqual(depth, row.depth); + depth += 1; + }; +} + +test "parity sidebar type stripes use macOS RGB converted to COLORREF" { + try std.testing.expectEqual(@as(u32, 0x00E3DAD3), loopAccent("sketch")); + try std.testing.expectEqual(@as(u32, 0x00709E19), loopAccent("goalBased")); + try std.testing.expectEqual(@as(u32, 0x008151D5), loopAccent("turnBased")); + try std.testing.expectEqual(@as(u32, 0x000085C9), loopAccent("timeBased")); + try std.testing.expectEqual(@as(u32, 0x00E98590), loopAccent("composite")); + try std.testing.expectEqual(@as(u32, 0x00E98590), loopAccent("proactive")); +} + +test "parity sidebar diamond child remains visible under its expanded parent" { + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("a"), .title = @constCast("A"), .loop_type = @constCast("sketch"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, + .{ .id = @constCast("b"), .title = @constCast("B"), .loop_type = @constCast("sketch"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, + .{ .id = @constCast("c"), .title = @constCast("Shared"), .loop_type = @constCast("sketch"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, + .{ .id = @constCast("d"), .title = @constCast("Hidden"), .loop_type = @constCast("sketch"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, + }; + const edges = [_]GraphModel.Edge{ + .{ .from = @constCast("a"), .to = @constCast("c"), .kind = @constCast("spawn") }, + .{ .from = @constCast("b"), .to = @constCast("c"), .kind = @constCast("message") }, + .{ .from = @constCast("a"), .to = @constCast("d"), .kind = @constCast("handoff") }, + }; + var state = State.init(std.testing.allocator); + defer state.deinit(); + try state.toggleNode("a"); + var hierarchy = try hierarchyItems(std.testing.allocator, &nodes, &edges, &state); + defer hierarchy.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 3), hierarchy.items.len); + try std.testing.expectEqual(@as(usize, 2), hierarchy.items[2].index); + try std.testing.expectEqual(@as(usize, 1), hierarchy.items[2].depth); + for (hierarchy.items) |row| try std.testing.expect(row.index != 3); +} + +test "parity jump scroll target follows expanded rows and falls back to the folded project" { + const allocator = std.testing.allocator; + var model = try manyLoopModel(allocator, 30); + defer model.deinit(); + var state = State.init(allocator); + defer state.deinit(); + for ([_]bool{ false, true }) |folded| { + if (folded) try state.toggleProject("C:\\many-loops"); + var rows = try appendRows(allocator, &model, null, 0, &state); + defer rows.deinit(allocator); + const expected = for (rows.items) |row| { + if ((!folded and row.kind == .loop and row.index == 29) or (folded and row.kind == .open_project)) break row.top; + } else return error.TestExpectedJumpTarget; + const target = try loopRowTopForModel(&model, 29, null, &state) orelse return error.TestExpectedJumpTarget; + try std.testing.expectEqual(expected, target); + const bottom = contentViewportBottom(400, false, false); + const scroll = clampScroll(target - content_top, maxScroll(&model, null, bottom, &state)); + try std.testing.expect(target - scroll >= content_top); + try std.testing.expect(target - scroll + row_height <= bottom); + } +} + +test "parity sidebar deeply indented titles keep useful space alongside age and state dot" { + const text = loopRowTextRects(100, 3); + try std.testing.expect(text.title.right - text.title.left >= 64); + try std.testing.expect(text.title.right <= text.age.left); + try std.testing.expect(text.age.right <= 198); +} + +test "parity sidebar selection is a rounded blue pill and the workspace footer never overlaps content" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + var state = State.init(allocator); + defer state.deinit(); + state.selection = .quick_chats; + state.workspace_name = workspaceName("C:\\fixture\\.graphcode-parityref"); + try std.testing.expectEqualStrings("parityref", state.workspace_name); + const screen = c.GetDC(null) orelse return error.SkipZigTest; + defer _ = c.ReleaseDC(null, screen); + const hdc = c.CreateCompatibleDC(screen) orelse return error.TestUnexpectedResult; + defer _ = c.DeleteDC(hdc); + const bitmap = c.CreateCompatibleBitmap(screen, Tokens.sidebar_width, 700) orelse return error.TestUnexpectedResult; + defer _ = c.DeleteObject(bitmap); + const previous = c.SelectObject(hdc, bitmap); + defer _ = c.SelectObject(hdc, previous); + draw(hdc, &model, null, "", 0, "Status must not cover the footer", 700, "", "", &state, -1, allocator); + const top = content_top + row_height; + try std.testing.expectEqual(selection_blue, c.GetPixel(hdc, 60, top + 2)); + try std.testing.expectEqual(@as(u32, Tokens.workspace_rail), c.GetPixel(hdc, rowBounds(top).left, top)); + try std.testing.expectEqual(@as(u32, Tokens.workspace_rail), c.GetPixel(hdc, 60, content_top + 2)); + const footer = workspaceFooterRect(700); + try std.testing.expect(contentViewportBottom(700, false, false) <= footer.top); + try std.testing.expect(workspaceFooterAt(18, footer.top + 12, 700)); + try std.testing.expect(!workspaceFooterAt(18, footer.top - 1, 700)); + try std.testing.expect(rowAt(18, footer.top + 12, &model, null, 0, contentViewportBottom(700, false, false), &state) == null); +} + +test "parity sidebar state dots distinguish presence and hollow stopped blocked waiting states" { + const node = GraphModel.Node{ .id = @constCast("a"), .title = @constCast("A"), .loop_type = @constCast("goalBased"), .state = @constCast("running"), .activity = @constCast(""), .presence = @constCast("awaitingInput") }; + try std.testing.expectEqualStrings("awaitingInput", displayState(node)); + try std.testing.expectEqual(@as(u32, 0x000A9FFF), dotColor(displayState(node))); + try std.testing.expectEqual(@as(u32, 0x0058D130), dotColor("succeeded")); + for ([_][]const u8{ "blocked", "stopped", "waiting" }) |value| try std.testing.expect(dotHollow(value)); + for ([_][]const u8{ "idle", "running", "awaitingInput", "succeeded", "failed", "stalled" }) |value| try std.testing.expect(!dotHollow(value)); +} + +test "parity sidebar workspace switcher only shows when it disambiguates the workspace" { + try std.testing.expect(!workspaceWorthShowing("C:\\fixture\\.graphcode", 1)); + try std.testing.expect(workspaceWorthShowing("C:\\fixture\\.graphcode", 2)); + try std.testing.expect(workspaceWorthShowing("C:\\fixture\\.graphcode-parityref", 1)); + try std.testing.expectEqualStrings("Default", workspaceName("C:\\fixture\\.graphcode")); +} + test "worktree row hit testing selects only visible rows" { const top = worktreeRowTop(2, 0, 0); try std.testing.expectEqual(@as(?usize, 0), hitTestWorktree(24, top + 4, 2, 2, 0, 700)); @@ -1431,10 +1613,7 @@ test "multi-project rows share render and hit-test offsets with project identity const remote_row = rowAt(24, remote_top + 4, &model, null, 0, 700, null) orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings("C:\\work\\local", local_row.project_path.?); try std.testing.expectEqualStrings("ssh://build/remote", remote_row.project_path.?); - try std.testing.expectEqual( - layoutFor(&model, null).quickChatRowTop(model.quick_chats.items.len + 1), - sidebarSectionBottom(&model, null, null), - ); + try std.testing.expectEqual(remote_top + row_height, sidebarSectionBottom(&model, null, null)); } test "scroll-adjusted generated rows hit titles loops and worktrees" { @@ -1570,8 +1749,8 @@ test "sidebar without graph counts only static rendered content" { .name = try std.testing.allocator.dupe(u8, "Project"), }); const no_graph_max = maxScroll(&model, null, 100, null); - try std.testing.expectEqual(@as(i32, Tokens.header_height + 78 + 24 + 24 + 62 + 42 + 24 - 100), no_graph_max); - try std.testing.expectEqual(@as(i32, 180), clampScroll(180, no_graph_max)); + try std.testing.expectEqual(@as(i32, content_top + 4 * row_height + 12 - 100), no_graph_max); + try std.testing.expectEqual(no_graph_max, clampScroll(180, no_graph_max)); } test "global Graph row remains pinned without an open project" { @@ -1598,11 +1777,13 @@ test "recent projects are grouped into local and remote sections" { }); var rows = try appendRows(std.testing.allocator, &model, null, 0, null); defer rows.deinit(std.testing.allocator); - try std.testing.expectEqual(RowKind.local_heading, rows.items[0].kind); - try std.testing.expectEqual(RowKind.project, rows.items[1].kind); - try std.testing.expectEqual(@as(usize, 1), rows.items[1].index); - try std.testing.expectEqual(RowKind.remote_heading, rows.items[2].kind); - try std.testing.expectEqual(@as(usize, 0), rows.items[3].index); + try std.testing.expectEqual(RowKind.overview, rows.items[0].kind); + try std.testing.expectEqual(RowKind.quick_chat_overview, rows.items[1].kind); + try std.testing.expectEqual(RowKind.local_heading, rows.items[2].kind); + try std.testing.expectEqual(RowKind.project, rows.items[3].kind); + try std.testing.expectEqual(@as(usize, 1), rows.items[3].index); + try std.testing.expectEqual(RowKind.remote_heading, rows.items[4].kind); + try std.testing.expectEqual(@as(usize, 0), rows.items[5].index); } test "recent project rows exclude folders already open in the projects list" { @@ -1622,12 +1803,13 @@ test "recent project rows exclude folders already open in the projects list" { }); var rows = try appendRows(std.testing.allocator, &model, null, 0, null); defer rows.deinit(std.testing.allocator); - try std.testing.expectEqual(RowKind.local_heading, rows.items[0].kind); - try std.testing.expectEqual(RowKind.project, rows.items[1].kind); - try std.testing.expectEqualStrings("C:\\recent", rows.items[1].project_path.?); - try std.testing.expectEqual(RowKind.overview, rows.items[2].kind); - try std.testing.expectEqual(RowKind.open_project, rows.items[3].kind); - try std.testing.expectEqualStrings("C:\\open", rows.items[3].project_path.?); + try std.testing.expectEqual(RowKind.overview, rows.items[0].kind); + try std.testing.expectEqual(RowKind.quick_chat_overview, rows.items[1].kind); + try std.testing.expectEqual(RowKind.local_heading, rows.items[2].kind); + try std.testing.expectEqual(RowKind.project, rows.items[3].kind); + try std.testing.expectEqualStrings("C:\\recent", rows.items[3].project_path.?); + try std.testing.expectEqual(RowKind.open_project, rows.items[4].kind); + try std.testing.expectEqualStrings("C:\\open", rows.items[4].project_path.?); } test "handoff edges derive stable nested loop order and depth" { @@ -1648,7 +1830,7 @@ test "handoff edges derive stable nested loop order and depth" { try std.testing.expectEqual(@as(usize, 1), hierarchy.items[1].depth); } -test "message and spawn edges do not define sidebar hierarchy" { +test "message and spawn edges define sidebar hierarchy as on macOS" { const nodes = [_]GraphModel.Node{ .{ .id = @constCast("first"), .title = @constCast("First"), .loop_type = @constCast("turnBased"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, .{ .id = @constCast("second"), .title = @constCast("Second"), .loop_type = @constCast("turnBased"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, @@ -1661,9 +1843,9 @@ test "message and spawn edges do not define sidebar hierarchy" { defer hierarchy.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 2), hierarchy.items.len); try std.testing.expectEqual(@as(usize, 0), hierarchy.items[0].depth); - try std.testing.expect(!hierarchy.items[0].has_children); - try std.testing.expectEqual(@as(usize, 0), hierarchy.items[1].depth); - try std.testing.expect(!hierarchy.items[1].has_children); + try std.testing.expect(hierarchy.items[0].has_children); + try std.testing.expectEqual(@as(usize, 1), hierarchy.items[1].depth); + try std.testing.expect(hierarchy.items[1].has_children); } test "sidebar groups and nested disclosures collapse independently and persist expansion" { @@ -1682,10 +1864,10 @@ test "sidebar groups and nested disclosures collapse independently and persist e state.local_collapsed = true; var rows = try appendRows(std.testing.allocator, &model, null, 0, &state); defer rows.deinit(std.testing.allocator); - try std.testing.expectEqual(RowKind.local_heading, rows.items[0].kind); - try std.testing.expectEqual(RowKind.remote_heading, rows.items[1].kind); - try std.testing.expectEqual(RowKind.project, rows.items[2].kind); - try std.testing.expectEqual(@as(usize, 1), rows.items[2].index); + try std.testing.expectEqual(RowKind.local_heading, rows.items[2].kind); + try std.testing.expectEqual(RowKind.remote_heading, rows.items[3].kind); + try std.testing.expectEqual(RowKind.project, rows.items[4].kind); + try std.testing.expectEqual(@as(usize, 1), rows.items[4].index); try state.toggleNode("root"); const encoded = try state.encode(std.testing.allocator); @@ -1693,12 +1875,12 @@ test "sidebar groups and nested disclosures collapse independently and persist e var restored = State.init(std.testing.allocator); defer restored.deinit(); try restored.decode(encoded); - try std.testing.expect(restored.isNodeExpanded("root")); - try restored.toggleNode("root"); try std.testing.expect(!restored.isNodeExpanded("root")); + try restored.toggleNode("root"); + try std.testing.expect(restored.isNodeExpanded("root")); } -test "daemon hierarchy starts with only roots visible and reveals children by stable id" { +test "daemon hierarchy starts expanded and hides children by stable id on request" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); const frame = @@ -1709,11 +1891,11 @@ test "daemon hierarchy starts with only roots visible and reveals children by st defer state.deinit(); var collapsed = try appendRows(std.testing.allocator, &model, null, 0, &state); defer collapsed.deinit(std.testing.allocator); - try std.testing.expectEqual(@as(usize, 1), countRows(collapsed.items, .loop)); + try std.testing.expectEqual(@as(usize, 2), countRows(collapsed.items, .loop)); try state.toggleNode("root"); var expanded = try appendRows(std.testing.allocator, &model, null, 0, &state); defer expanded.deinit(std.testing.allocator); - try std.testing.expectEqual(@as(usize, 2), countRows(expanded.items, .loop)); + try std.testing.expectEqual(@as(usize, 1), countRows(expanded.items, .loop)); } fn countRows(rows: []const Row, kind: RowKind) usize { @@ -1733,7 +1915,7 @@ test "quick chats remain selectable without an open graph or inspection" { .backend = try std.testing.allocator.dupe(u8, "claudeCode"), }); const layout = layoutFor(&model, null); - try std.testing.expect(layout.quickChatHeadingTop() + 11 < layout.quickChatRowTop(0)); + try std.testing.expectEqual(layout.quickChatHeadingTop(), layout.quickChatRowTop(0)); const overview = rowAt(24, layout.quickChatRowTop(0) + 4, &model, null, 0, 700, null) orelse return error.TestUnexpectedResult; try std.testing.expectEqual(RowKind.quick_chat_overview, overview.kind); @@ -1742,10 +1924,10 @@ test "quick chats remain selectable without an open graph or inspection" { try std.testing.expectEqual(RowKind.quick_chat, row.kind); try std.testing.expectEqual(@as(usize, 0), row.index); try std.testing.expectEqual( - @as(i32, Tokens.header_height + 78 + 62 + 42), + @as(i32, content_top + row_height), layout.quickChatRowTop(0), ); - try std.testing.expect(rowAt(24, layout.quickChatHeadingTop() + 4, &model, null, 0, 700, null) == null); + try std.testing.expect(rowAt(24, layout.quickChatHeadingTop() + 4, &model, null, 0, 700, null).?.kind == .quick_chat_overview); try std.testing.expectEqual(layout.quickChatRowTop(2), sidebarSectionBottom(&model, null, null)); } @@ -1756,13 +1938,13 @@ test "quick chat heading and rows stay distinct across graph layouts" { .{ .base = 100, .project_count = 2, .loop_count = 3, .worktree_count = 4, .inspection_present = true }, }; for (layouts) |layout| { - try std.testing.expect(layout.quickChatHeadingTop() + 11 < layout.quickChatRowTop(0)); - try std.testing.expectEqual(@as(i32, 24), layout.quickChatRowTop(1) - layout.quickChatRowTop(0)); + try std.testing.expectEqual(layout.quickChatHeadingTop(), layout.quickChatRowTop(0)); + try std.testing.expectEqual(row_height, layout.quickChatRowTop(1) - layout.quickChatRowTop(0)); } } test "sidebar loop presentation preserves type and terminal states" { - try std.testing.expectEqual(@as(u32, 0x0048C78E), loopAccent("goalBased")); + try std.testing.expectEqual(@as(u32, 0x00709E19), loopAccent("goalBased")); try std.testing.expectEqualStrings("done", compactState("succeeded")); try std.testing.expectEqualStrings("running", compactState("running")); try std.testing.expectEqual(@as(u32, 0x005F5FFF), stateColor("failed")); @@ -1837,21 +2019,22 @@ test "elapsedText renders a compact age and its unit boundaries honestly" { try std.testing.expectEqualStrings("10d", ten_days); } -test "sidebar footer reserve keeps content, status text, banner, and error footer disjoint" { +test "sidebar footer reserve keeps content workspace bar banner and error footer disjoint" { for ([_]bool{ false, true }) |has_update| for ([_]bool{ false, true }) |has_error| for ([_]i32{ 420, 522, 772 }) |viewport_bottom| { const content_bottom = contentViewportBottom(viewport_bottom, has_update, has_error); - const status = statusTextRect(viewport_bottom, has_update, has_error); - try std.testing.expect(content_bottom <= status.top); - try std.testing.expect(status.right <= Tokens.sidebar_width and status.bottom <= viewport_bottom); + const workspace = workspaceFooterRect(viewport_bottom); + try std.testing.expect(content_bottom <= workspace.top); + try std.testing.expectEqual(rect(0, viewport_bottom - 34, Tokens.sidebar_width, viewport_bottom), workspace); if (has_update) { const banner = updateBannerRect(viewport_bottom, has_error); try std.testing.expect(content_bottom <= banner.top); - try std.testing.expect(status.bottom <= banner.top); + try std.testing.expect(banner.bottom <= workspace.top); } if (has_error) { const footer = errorFooterRect(viewport_bottom); try std.testing.expect(content_bottom <= footer.top); - try std.testing.expect(status.bottom <= footer.top); + try std.testing.expect(footer.bottom <= workspace.top); + if (has_update) try std.testing.expect(updateBannerRect(viewport_bottom, true).bottom <= footer.top); } }; // A viewport too short for the footer still leaves a well-formed, header-anchored content rect. @@ -1993,8 +2176,8 @@ test "sidebar draw clips rows to the content viewport and keeps status text insi const background = pixel == Tokens.workspace_rail; if (y < content_bottom) { if (!background) painted_in_content += 1; - } else if (y >= status.bottom or x >= status.right) { - // Below the status line, and right of it, only the rail background may show. + } else if (y < workspaceFooterRect(viewport_bottom).top and (y >= status.bottom or x >= status.right)) { + // No scrolling content is painted in the gap above the workspace footer. try std.testing.expectEqual(@as(u32, Tokens.workspace_rail), pixel); } } @@ -2060,7 +2243,7 @@ test "scrolled rows beneath the fixed GRAPH/Projects header are not hit" { try std.testing.expect(rowAt(24, y, &model, null, scroll, content_bottom, &state) == null); } } - try std.testing.expect(under_header >= 2); + try std.testing.expect(under_header >= 1); } test "scrolled rows are not drawn over the fixed GRAPH/Projects header" { @@ -2086,7 +2269,7 @@ test "scrolled rows are not drawn over the fixed GRAPH/Projects header" { draw(hdc, &model, null, "", scroll, "", viewport_bottom, "", "", &state, -1, draw_allocator.allocator()); // The goalBased accent bar is only ever painted by loop rows. - const accent: u32 = 0x0048C78E; + const accent: u32 = 0x00709E19; var accent_in_header: usize = 0; var accent_in_content: usize = 0; var y: i32 = Tokens.header_height; @@ -2101,12 +2284,13 @@ test "scrolled rows are not drawn over the fixed GRAPH/Projects header" { try std.testing.expect(accent_in_content > 30); } -test "loop row title, status, and age occupy disjoint columns that each label fits" { +test "loop row title age state dot and disclosure occupy disjoint columns" { for (0..5) |depth| { const text = loopRowTextRects(100, depth); try std.testing.expect(text.title.left < text.title.right); - try std.testing.expect(text.title.right <= text.state.left); - try std.testing.expect(text.state.right <= text.age.left); + try std.testing.expect(text.title.right <= text.age.left); + try std.testing.expect(text.age.right <= text.state.left); + try std.testing.expect(text.state.right <= 198); // Clear of the hover disclosure's hit zone and the sidebar rail edge. try std.testing.expect(text.age.right <= 198 and text.age.right <= Tokens.sidebar_width); } @@ -2114,15 +2298,9 @@ test "loop row title, status, and age occupy disjoint columns that each label fi defer _ = c.ReleaseDC(null, screen); const hdc = c.CreateCompatibleDC(screen) orelse return error.TestUnexpectedResult; defer _ = c.DeleteDC(hdc); - const previous = AppFont.select(hdc, 9, false); + const previous = AppFont.select(hdc, 10, false); defer _ = c.SelectObject(hdc, previous); const text = loopRowTextRects(100, 0); - var widest_state: i32 = 0; - for ([_][]const u8{ "idle", "running", "stopped", "blocked", "failed", "stalled", "waiting", "done", "needs" }) |label| { - const width = try measuredWidth(hdc, label); - widest_state = @max(widest_state, width); - try std.testing.expect(width <= text.state.right - text.state.left); - } var widest_age: i32 = 0; for ([_][]const u8{ "-", "59s", "59m", "23h", "99d", "365d", "999d" }) |label| { const width = try measuredWidth(hdc, label); @@ -2130,7 +2308,7 @@ test "loop row title, status, and age occupy disjoint columns that each label fi try std.testing.expect(width <= text.age.right - text.age.left); } // Guard against vacuous measurements. - try std.testing.expect(widest_state > 20 and widest_age > 10); + try std.testing.expect(widest_age > 10); } fn measuredWidth(hdc: c.HDC, label: []const u8) !i32 { @@ -2145,6 +2323,125 @@ fn rect(left: i32, top: i32, right: i32, bottom: i32) c.RECT { return .{ .left = left, .top = top, .right = right, .bottom = bottom }; } +fn projectForRow(model: *const GraphModel.Model, path: []const u8) ?GraphModel.Project { + if (model.graphFor(path)) |graph| return graph.project; + for (model.open_projects.items) |project| if (std.mem.eql(u8, project.path, path)) return project; + return null; +} + +pub fn rowSelected(row: Row, model: *const GraphModel.Model, selection: Selection) bool { + return switch (selection) { + .none => false, + .overview => row.kind == .overview, + .quick_chats => row.kind == .quick_chat_overview, + .project => |path| row.kind == .open_project and row.project_path != null and std.mem.eql(u8, row.project_path.?, path), + .loop => |id| blk: { + if (row.kind != .loop) break :blk false; + const selected_project = model.selected_project_path orelse break :blk false; + if (row.project_path == null or !std.mem.eql(u8, row.project_path.?, selected_project)) break :blk false; + const graph = model.graphFor(row.project_path orelse break :blk false) orelse break :blk false; + break :blk row.index < graph.nodes.items.len and std.mem.eql(u8, graph.nodes.items[row.index].id, id); + }, + .chat => |id| row.kind == .quick_chat and row.index < model.quick_chats.items.len and std.mem.eql(u8, model.quick_chats.items[row.index].id, id), + }; +} + +fn displayState(node: GraphModel.Node) []const u8 { + if (std.mem.eql(u8, node.presence, "busy")) return "running"; + if (std.mem.eql(u8, node.presence, "awaitingInput")) return "awaitingInput"; + if (std.mem.eql(u8, node.state, "running") and std.mem.eql(u8, node.presence, "idle")) return "idle"; + return node.state; +} + +fn dotColor(state: []const u8) u32 { + if (std.mem.eql(u8, state, "running") or std.mem.eql(u8, state, "waiting")) return 0x00FF840A; + if (std.mem.eql(u8, state, "awaitingInput") or std.mem.eql(u8, state, "blocked")) return 0x000A9FFF; + if (std.mem.eql(u8, state, "succeeded")) return 0x0058D130; + if (std.mem.eql(u8, state, "failed")) return 0x003A45FF; + if (std.mem.eql(u8, state, "stalled")) return 0x00F25ABF; + return 0x00636363; +} + +fn dotHollow(state: []const u8) bool { + return std.mem.eql(u8, state, "blocked") or std.mem.eql(u8, state, "stopped") or std.mem.eql(u8, state, "waiting"); +} + +fn drawStateDot(hdc: c.HDC, bounds: c.RECT, state: []const u8) void { + const pen = c.CreatePen(c.PS_SOLID, 1, dotColor(state)); + defer _ = c.DeleteObject(pen); + const brush = c.CreateSolidBrush(dotColor(state)); + defer _ = c.DeleteObject(brush); + const old_pen = c.SelectObject(hdc, pen); + defer _ = c.SelectObject(hdc, old_pen); + const old_brush = c.SelectObject(hdc, if (dotHollow(state)) c.GetStockObject(c.HOLLOW_BRUSH) else brush); + defer _ = c.SelectObject(hdc, old_brush); + _ = c.Ellipse(hdc, bounds.left, bounds.top, bounds.right, bounds.bottom); +} + +fn roundedFill(hdc: c.HDC, bounds: c.RECT, color: u32, radius: i32) void { + const brush = c.CreateSolidBrush(color); + defer _ = c.DeleteObject(brush); + const old_brush = c.SelectObject(hdc, brush); + defer _ = c.SelectObject(hdc, old_brush); + const old_pen = c.SelectObject(hdc, c.GetStockObject(c.NULL_PEN)); + defer _ = c.SelectObject(hdc, old_pen); + _ = c.RoundRect(hdc, bounds.left, bounds.top, bounds.right, bounds.bottom, radius * 2, radius * 2); +} + +fn stroke(hdc: c.HDC, points: []const c.POINT, color: u32) void { + const pen = c.CreatePen(c.PS_SOLID, 1, color); + defer _ = c.DeleteObject(pen); + const previous = c.SelectObject(hdc, pen); + defer _ = c.SelectObject(hdc, previous); + _ = c.Polyline(hdc, points.ptr, @intCast(points.len)); +} + +fn drawChevron(hdc: c.HDC, x: i32, y: i32, expanded: bool, color: u32) void { + const points = if (expanded) + [_]c.POINT{ .{ .x = x - 3, .y = y - 2 }, .{ .x = x, .y = y + 1 }, .{ .x = x + 3, .y = y - 2 } } + else + [_]c.POINT{ .{ .x = x - 2, .y = y - 3 }, .{ .x = x + 1, .y = y }, .{ .x = x - 2, .y = y + 3 } }; + stroke(hdc, &points, color); +} + +fn drawPlus(hdc: c.HDC, x: i32, y: i32, color: u32) void { + stroke(hdc, &.{ .{ .x = x - 4, .y = y }, .{ .x = x + 5, .y = y } }, color); + stroke(hdc, &.{ .{ .x = x, .y = y - 4 }, .{ .x = x, .y = y + 5 } }, color); +} + +const Icon = enum { folder, graph, chats, network, workspace }; + +fn drawIcon(hdc: c.HDC, icon: Icon, x: i32, y: i32, color: u32) void { + switch (icon) { + .folder => { + stroke(hdc, &.{ + .{ .x = x, .y = y + 2 }, .{ .x = x + 6, .y = y + 2 }, .{ .x = x + 8, .y = y + 4 }, + .{ .x = x + 15, .y = y + 4 }, .{ .x = x + 15, .y = y + 14 }, .{ .x = x, .y = y + 14 }, + .{ .x = x, .y = y + 2 }, + }, color); + stroke(hdc, &.{ .{ .x = x, .y = y + 6 }, .{ .x = x + 15, .y = y + 6 } }, color); + }, + .graph, .network => { + stroke(hdc, &.{ .{ .x = x + 3, .y = y + 3 }, .{ .x = x + 13, .y = y + 3 }, .{ .x = x + 8, .y = y + 13 }, .{ .x = x + 3, .y = y + 3 } }, color); + roundedFill(hdc, rect(x, y, x + 5, y + 5), color, 2); + roundedFill(hdc, rect(x + 11, y, x + 16, y + 5), color, 2); + roundedFill(hdc, rect(x + 6, y + 11, x + 11, y + 16), color, 2); + }, + .chats => { + stroke(hdc, &.{ + .{ .x = x, .y = y }, .{ .x = x + 11, .y = y }, .{ .x = x + 11, .y = y + 9 }, + .{ .x = x + 5, .y = y + 9 }, .{ .x = x + 2, .y = y + 12 }, .{ .x = x + 2, .y = y + 9 }, + .{ .x = x, .y = y + 9 }, .{ .x = x, .y = y }, + }, color); + stroke(hdc, &.{ .{ .x = x + 12, .y = y + 4 }, .{ .x = x + 18, .y = y + 4 }, .{ .x = x + 18, .y = y + 13 }, .{ .x = x + 16, .y = y + 13 }, .{ .x = x + 16, .y = y + 16 }, .{ .x = x + 13, .y = y + 13 }, .{ .x = x + 7, .y = y + 13 }, .{ .x = x + 7, .y = y + 10 } }, color); + }, + .workspace => { + stroke(hdc, &.{ .{ .x = x, .y = y + 4 }, .{ .x = x + 6, .y = y }, .{ .x = x + 12, .y = y + 4 }, .{ .x = x + 6, .y = y + 8 }, .{ .x = x, .y = y + 4 } }, color); + stroke(hdc, &.{ .{ .x = x, .y = y + 7 }, .{ .x = x + 6, .y = y + 11 }, .{ .x = x + 12, .y = y + 7 } }, color); + }, + } +} + fn fill(hdc: c.HDC, bounds: c.RECT, color: u32) void { const brush = c.CreateSolidBrush(color); if (brush != null) {