From 115d421b817cc94f01bdbe01140fa8aeb789b842 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 09:32:37 -0700 Subject: [PATCH 1/6] Align Windows sidebar navigation Match sidebar destinations, open-owner sections, row geometry, UIA projection and scrolling; add bounded fixture coverage. Refs #767 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0db84998-f1cc-4cc7-a927-6c8912b42cdb Signed-off-by: Colin Neilens --- .../windows/Tests/ValidationRunner.Tests.ps1 | 113 ++ Tools/windows/uia-live-gate.ps1 | 154 ++- graphcode-windows/README.md | 23 +- graphcode-windows/src/App.zig | 621 ++++++++-- graphcode-windows/src/Sidebar.zig | 1078 +++++++++++------ 5 files changed, 1499 insertions(+), 490 deletions(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 70349c2f..ad42f6a0 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -3217,6 +3217,119 @@ try { if ($uiaParseErrors.Count -ne 0) { throw "RED: UIA live gate no longer parses after startup diagnostic changes" } + $sidebarOracleHelper = $uiaAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "Get-SidebarNavigationSnapshot" + }, $true) + if ($null -eq $sidebarOracleHelper) { + throw "RED: UIA live gate is missing its functional sidebar navigation snapshot oracle" + } + . ([scriptblock]::Create($sidebarOracleHelper.Extent.Text)) + & { + function New-SidebarNavigationElement( + [string] $automationId, + [string] $name, + [double] $left, + [double] $top, + [double] $right, + [double] $bottom + ) { + return [pscustomobject]@{ + Current = [pscustomobject]@{ + AutomationId = $automationId + Name = $name + BoundingRectangle = [pscustomobject]@{ + Left = $left; Top = $top; Right = $right; Bottom = $bottom + Width = $right - $left; Height = $bottom - $top + } + } + } + } + $sidebarFixture = @( + (New-SidebarNavigationElement "overview-destination:graph" "Graph" 12 20 220 52), + (New-SidebarNavigationElement "quick-chats-destination:quick-chats" "Quick Chats" 12 56 220 88), + (New-SidebarNavigationElement "sidebar-section-1" "Open local folders" 12 100 220 121), + (New-SidebarNavigationElement "open-project-1" "UIA project" 12 121 220 153), + (New-SidebarNavigationElement "sidebar-section-2" "Open remote repositories" 12 168 220 189), + (New-SidebarNavigationElement "open-project-2" "Temporary remote" 12 192 220 224), + (New-SidebarNavigationElement "overview-destination" "Graph" 12 17 220 49), + (New-SidebarNavigationElement "quick-chats-destination" "Quick Chats" 12 53 220 85), + (New-SidebarNavigationElement "project-new-loop-1" "New Loop" 174 17 198 49), + (New-SidebarNavigationElement "project-disclosure-2" "Collapse project" 198 17 220 49), + (New-SidebarNavigationElement "quick-chat-new-3" "New Chat" 174 53 198 85), + (New-SidebarNavigationElement "quick-chats-disclosure-4" "Collapse Quick Chats" 198 53 220 85), + (New-SidebarNavigationElement "project-new-loop-5" "New Loop" 174 121 198 153), + (New-SidebarNavigationElement "project-disclosure-6" "Collapse project" 198 121 220 153) + ) + $initial = Get-SidebarNavigationSnapshot $sidebarFixture + if (($initial.destinationIds -join "|") -cne "overview-destination|quick-chats-destination" -or + ($initial.destinationNames -join "|") -cne "Graph|Quick Chats" -or + $initial.ownerNames.Count -ne 2 -or + ($initial.ownerNames -join "|") -cne "UIA project|Temporary remote" -or + ($initial.sectionNames -join "|") -cne "Open local folders|Open remote repositories" -or + $initial.recentRowIds.Count -ne 0 -or + -not $initial.rowBoundsValid -or -not $initial.controlsValid -or + $initial.controlsChecked -ne 6) { + throw "RED: Sidebar navigation oracle missed destination order, open owners, section names, or contained controls" + } + $localCollapsed = @($sidebarFixture | Where-Object { + $_.Current.AutomationId -notin @( + "open-project-1", + "project-new-loop-5", + "project-disclosure-6" + ) + }) + $afterLocalCollapse = Get-SidebarNavigationSnapshot $localCollapsed + if (($afterLocalCollapse.ownerNames -join "|") -cne "Temporary remote") { + throw "RED: Sidebar navigation oracle did not isolate local owner collapse" + } + $remoteCollapsed = @($sidebarFixture | Where-Object { + $_.Current.AutomationId -ne "open-project-2" + }) + $afterRemoteCollapse = Get-SidebarNavigationSnapshot $remoteCollapsed + if (($afterRemoteCollapse.ownerNames -join "|") -cne "UIA project") { + throw "RED: Sidebar navigation oracle did not isolate remote owner collapse" + } + $restoredFixture = @($sidebarFixture | Where-Object { + $_.Current.AutomationId -notin @( + "sidebar-section-2", + "open-project-2" + ) + }) + $restored = Get-SidebarNavigationSnapshot $restoredFixture + if (($restored.ownerNames -join "|") -cne "UIA project" -or + ($restored.sectionNames -join "|") -cne "Open local folders" -or + $restored.recentRowIds.Count -ne 0) { + throw "RED: Sidebar navigation oracle accepted a temporary remote owner after fixture restore" + } + $recentMutation = Get-SidebarNavigationSnapshot @( + $sidebarFixture + (New-SidebarNavigationElement "project-row-recent" "Recent folder" 12 228 220 260) + ) + if ($recentMutation.recentRowIds.Count -ne 1) { + throw "RED: Sidebar navigation oracle failed to detect a projected unopened recent folder" + } + $clippedMutation = @($sidebarFixture | Where-Object { + $_.Current.AutomationId -ne "project-new-loop-5" + }) + (New-SidebarNavigationElement "project-new-loop-5" "New Loop" 174 121 228 153) + $clipped = Get-SidebarNavigationSnapshot $clippedMutation + if ($clipped.controlsValid) { + throw "RED: Sidebar navigation oracle accepted a row control extending beyond its owner" + } + $reorderedOwners = @($sidebarFixture | Where-Object { + $_.Current.AutomationId -notin @( + "open-project-1", + "open-project-2" + ) + }) + @( + (New-SidebarNavigationElement "open-project-2" "Temporary remote" 12 124 220 156), + (New-SidebarNavigationElement "open-project-1" "UIA project" 12 192 220 224) + ) + $reordered = Get-SidebarNavigationSnapshot $reorderedOwners + if (($reordered.ownerNames -join "|") -cne "Temporary remote|UIA project") { + throw "RED: Sidebar navigation oracle failed to preserve the owner order evidence" + } + } foreach ($helperName in @( "Protect-UiaStartupDiagnosticText", "Get-UiaStartupDiagnosticValue", diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index d2ac7f07..40795ed2 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2006,6 +2006,69 @@ function Get-DirectChildren( } } +function Get-SidebarNavigationSnapshot([object[]] $children) { + $destinations = @($children | Where-Object { + $_.Current.AutomationId -in @("overview-destination", "quick-chats-destination") + } | Sort-Object { $_.Current.BoundingRectangle.Top }) + $recentRows = @($children | Where-Object { $_.Current.AutomationId -match '^project-row-' }) + $owners = @($children | Where-Object { $_.Current.AutomationId -match '^open-project-' } | + Sort-Object { $_.Current.BoundingRectangle.Top }) + $sections = @($children | Where-Object { $_.Current.AutomationId -match '^sidebar-section-' } | + Sort-Object { $_.Current.BoundingRectangle.Top }) + $controls = @($children | Where-Object { + $_.Current.AutomationId -match '^(project-new-loop-|project-disclosure-|quick-chat-new-|quick-chats-disclosure-)' + }) + $rowBoundsValid = $true + foreach ($element in @($destinations + $owners + $sections)) { + $bounds = $element.Current.BoundingRectangle + if (($bounds.Width -le 0) -or ($bounds.Height -le 0)) { + $rowBoundsValid = $false + break + } + } + $controlsValid = $true + foreach ($control in $controls) { + $automationId = [string]$control.Current.AutomationId + $ownerCandidates = if ($automationId -match '^(quick-chat-new-|quick-chats-disclosure-)') { + @($destinations | Where-Object { $_.Current.AutomationId -eq "quick-chats-destination" }) + } else { + @($destinations | Where-Object { $_.Current.AutomationId -eq "overview-destination" }) + $owners + } + $controlBounds = $control.Current.BoundingRectangle + if (($ownerCandidates.Count -eq 0) -or ($controlBounds.Width -le 0) -or ($controlBounds.Height -le 0)) { + $controlsValid = $false + break + } + $contained = $false + foreach ($owner in $ownerCandidates) { + $ownerBounds = $owner.Current.BoundingRectangle + if (($controlBounds.Left -ge $ownerBounds.Left) -and + ($controlBounds.Top -ge $ownerBounds.Top) -and + ($controlBounds.Right -le $ownerBounds.Right) -and + ($controlBounds.Bottom -le $ownerBounds.Bottom)) { + $contained = $true + break + } + } + if (-not $contained) { + $controlsValid = $false + break + } + } + return [pscustomobject]@{ + destinationIds = @($destinations | ForEach-Object { [string]$_.Current.AutomationId }) + destinationNames = @($destinations | ForEach-Object { [string]$_.Current.Name }) + recentRowIds = @($recentRows | ForEach-Object { [string]$_.Current.AutomationId }) + ownerIds = @($owners | ForEach-Object { [string]$_.Current.AutomationId }) + ownerNames = @($owners | ForEach-Object { [string]$_.Current.Name }) + sectionIds = @($sections | ForEach-Object { [string]$_.Current.AutomationId }) + sectionNames = @($sections | ForEach-Object { [string]$_.Current.Name }) + rowBoundsValid = $rowBoundsValid + controlsValid = $controlsValid + controlsChecked = $controls.Count + } +} + function Assert-Ids([string[]] $actual, [string[]] $expected, [string] $label) { Require (@($actual).Count -eq @($expected).Count) "$label count expected $($expected.Count) but found $($actual.Count): $($actual -join ',')" Require ((@($actual) -join "|") -eq (@($expected) -join "|")) "$label expected $($expected -join ',') but found $($actual -join ',')" @@ -5376,25 +5439,43 @@ try { Require (-not $process.HasExited) ` "shell exited with code $($process.ExitCode) after a genuine click on the sidebar update banner" + Require ([GraphCodeUiaGateState]::PostFixtureMutation($process.MainWindowHandle, 22)) ` + "temporary sidebar remote owner fixture mutation was rejected" + Start-Sleep -Milliseconds 150 $navigationIds = @("overview-destination", "quick-chats-destination") $canvasActionIds = @("canvas-primary-action", "zoom-out", "actual-size", "zoom-in", "fit-canvas") - $projectRows = @(Get-DirectChildren $projects $rawWalker | Where-Object { $_.Current.AutomationId -match '^project-row-' }) - $openProjectRows = @(Get-DirectChildren $projects $rawWalker | Where-Object { $_.Current.AutomationId -match '^open-project-' }) $graphDestination = Find-FragmentById $root "overview-destination" $rawWalker + $quickChatsDestination = Find-FragmentById $root "quick-chats-destination" $rawWalker + Require ($null -ne $graphDestination) "Graph sidebar destination was not exposed" + Require ($null -ne $quickChatsDestination) "Quick Chats sidebar destination was not exposed" + $sidebarNavigationChildren = @(Get-DirectChildren $projects $rawWalker) + if (@($sidebarNavigationChildren | Where-Object { $_.Current.AutomationId -eq "overview-destination" }).Count -eq 0) { + $sidebarNavigationChildren += $graphDestination + } + if (@($sidebarNavigationChildren | Where-Object { $_.Current.AutomationId -eq "quick-chats-destination" }).Count -eq 0) { + $sidebarNavigationChildren += $quickChatsDestination + } + $sidebarNavigation = Get-SidebarNavigationSnapshot $sidebarNavigationChildren Require (($null -ne $graphDestination) -and ($graphDestination.Current.Name -eq "Graph")) ` "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" - 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) { - Require (($projectRow.Current.BoundingRectangle.Width -gt 0) -and - ($projectRow.Current.BoundingRectangle.Height -gt 0)) "dynamic project row has empty bounds" - } - foreach ($projectRow in $openProjectRows) { - Require (($projectRow.Current.BoundingRectangle.Width -gt 0) -and - ($projectRow.Current.BoundingRectangle.Height -gt 0)) "open project row has empty bounds" - } + Require ($quickChatsDestination.Current.Name -eq "Quick Chats") ` + "Quick Chats sidebar destination did not expose its pinned identity" + Assert-Ids $sidebarNavigation.destinationIds $navigationIds "sidebar destinations" + Assert-Ids $sidebarNavigation.destinationNames @("Graph", "Quick Chats") "sidebar destination names" + Require ($sidebarNavigation.recentRowIds.Count -eq 0) ` + "unopened recent folders were projected as sidebar destinations" + Assert-Ids $sidebarNavigation.ownerNames @("UIA project", "Temporary remote") "open sidebar owner order" + Assert-Ids $sidebarNavigation.sectionNames @("Open local folders", "Open remote repositories") "open sidebar sections" + Require ($sidebarNavigation.rowBoundsValid) "sidebar destinations, sections, or open owners had empty bounds" + Require ($sidebarNavigation.controlsValid -and $sidebarNavigation.controlsChecked -gt 0) ` + "sidebar row controls were missing or outside their owner row" + $projectRows = @($sidebarNavigationChildren | Where-Object { $_.Current.AutomationId -match '^project-row-' }) + $openProjectRows = @($sidebarNavigationChildren | Where-Object { $_.Current.AutomationId -match '^open-project-' } | + Sort-Object { $_.Current.BoundingRectangle.Top }) + Require ($projectRows.Count -eq 0) "unopened recent folders appeared as project rows" + Require ($openProjectRows.Count -eq 2) "open sidebar owner rows were not both exposed" + Require (($openProjectRows | ForEach-Object { $_.Current.Name }) -join "|" -eq "UIA project|Temporary remote") ` + "open owner names or navigation order were not synchronized" # The fixture's "UIA loop B" is awaitingInput, so Needs-you must be populated # here. Reaching the detail checks is mandatory: an empty set is a failure, not # a pass. Evidence is captured as values now, because the elements themselves @@ -5432,35 +5513,46 @@ try { } Write-Host ("UIA_NEEDS_YOU_EVIDENCE=" + ($needsYouEvidence | ConvertTo-Json -Compress -Depth 4)) $null = $projects.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) - $null = $projectRows[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) - $projectRowInvoke = $projectRows[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) - $projectRowIds = @($projectRows | ForEach-Object { $_.Current.AutomationId }) + $null = $openProjectRows[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $projectRowInvoke = $openProjectRows[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + $projectRowIds = @($openProjectRows | ForEach-Object { $_.Current.AutomationId }) $projectChildIds = @(Get-DirectChildren $projects $rawWalker | ForEach-Object { $_.Current.AutomationId } | Where-Object { $_ }) $null = Assert-FragmentLinks $projects $rawWalker $projectChildIds "RawView Projects" $null = Assert-FragmentLinks $projects $controlWalker $projectChildIds "ControlView Projects" + $localSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Open local folders" + }) | Select-Object -First 1 + Require ($null -ne $localSection) "Open local folders section did not expose its section action" + $localSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $afterLocalCollapse = Get-SidebarNavigationSnapshot @(Get-DirectChildren $projects $rawWalker) + Assert-Ids $afterLocalCollapse.ownerNames @("Temporary remote") "owners after local-section collapse" + $localSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Open local folders" + }) | Select-Object -First 1 + $localSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 $remoteSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { - $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Remote Repositories" + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Open remote repositories" }) | Select-Object -First 1 - Require ($null -ne $remoteSection) "Remote sidebar section did not expose its section action" - $remoteRowId = @($projectRows | Where-Object { $_.Current.Name -eq "Fixture remote" })[0].Current.AutomationId + Require ($null -ne $remoteSection) "Open remote repositories section did not expose its section action" $remoteSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() Start-Sleep -Milliseconds 150 - $collapsedProjectNames = @(Get-DirectChildren $projects $rawWalker | - Where-Object { $_.Current.AutomationId -match '^(project-row|open-project)-' } | - ForEach-Object { $_.Current.Name }) - Require (("Fixture remote" -notin $collapsedProjectNames) -and - ("UIA project" -in $collapsedProjectNames)) ` - "Remote section collapse did not hide only the unopened recent folder" - $openProjectAfterRemoteCollapse = @(Get-DirectChildren $projects $rawWalker | Where-Object { - $_.Current.AutomationId -match '^open-project-' -and $_.Current.Name -eq 'UIA project' - }) | Select-Object -First 1 - Require ($null -ne $openProjectAfterRemoteCollapse) "open project row disappeared when collapsing recents" + $afterRemoteCollapse = Get-SidebarNavigationSnapshot @(Get-DirectChildren $projects $rawWalker) + Assert-Ids $afterRemoteCollapse.ownerNames @("UIA project") "owners after remote-section collapse" $remoteSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { - $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Remote Repositories" + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Open remote repositories" }) | Select-Object -First 1 $remoteSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() Start-Sleep -Milliseconds 150 + Require ([GraphCodeUiaGateState]::PostFixtureMutation($process.MainWindowHandle, 20)) ` + "sidebar parity fixture restore was rejected" + Start-Sleep -Milliseconds 150 + $restoredSidebar = Get-SidebarNavigationSnapshot @(Get-DirectChildren $projects $rawWalker) + Assert-Ids $restoredSidebar.ownerNames @("UIA project") "restored open sidebar owners" + Assert-Ids $restoredSidebar.sectionNames @("Open local folders") "restored sidebar sections" + Require ($restoredSidebar.recentRowIds.Count -eq 0) "restored sidebar fixture retained unopened recent rows" $loopRows = @(Get-DirectChildren $loops $rawWalker | Where-Object { $_.Current.AutomationId -match '^loop-row-' diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 1fc266ef..08fa0295 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -365,11 +365,13 @@ the workspace identity region remains separate from the terminal's physical tabs ### Sidebar scrolling and short windows -The sidebar rows, Needs you, and Activity scroll under the fixed GRAPH/Projects -header and above the footer (status line, update banner, inline error footer). -What is painted, what UIA publishes, and what a click hits are the same rect, -clipped to that scroll region; the right-hand Activity control sits inside the -220-pixel rail. +Graph and Quick Chats stay first, followed by the open local-folder and remote- +repository groups. Unopened recent folders remain available from File > Add +Folder > Recent Folders rather than appearing as sidebar destinations. Sidebar +rows, Needs you, and Activity scroll beneath the fixed window header and above +the footer (status line, update banner, inline error footer). What is painted, +what UIA publishes, and what a click hits are the same rect, clipped to that +scroll region; the right-hand Activity control sits inside the 220-DIP rail. Keyboard and assistive clients reach offscreen rows through UIA, not a hidden shortcut: each sidebar list (Projects, Loops, Worktrees) exposes a vertical-only @@ -380,16 +382,17 @@ with it (rows, controls, the Graph and Quick Chats destinations) exposes footer do not scroll and expose no scroll item. The root's coordinate hit-test returns the element under the point (banner above header above scrolled rows). -Short windows: the sidebar keeps a 52 px scroll region (`Sidebar.min_content_region`, -room for two 24 px row slots; the tests assert the region height and at least one -published element, not two elements at every offset, and a 34 px Needs-you or +Short windows: the sidebar keeps a 52-DIP scroll region (`Sidebar.min_content_region`, +room for two 24-DIP loop-row slots; the tests assert the region height and at +least one published element, not two elements at every offset, and a 34-DIP Needs-you or Activity card can fill it). If the footer would leave less, the error footer collapses first, then the update banner; the status line is the floor. A collapsed part is not painted, published, or clickable. The window's minimum height is the smallest client height at which the status line, Activity strip, and open Workspace panel still leave that region (`min_client_height`, enforced through -`WM_GETMINMAXINFO`; width keeps the system minimum). This is layout and message -evidence from App and native provider tests, not a screen-reader or Dev Box run. +`WM_GETMINMAXINFO`; width keeps the system minimum, and the 517-DIP minimum-height +expression is preserved). This is layout and message evidence from App and +native provider tests, not a screen-reader or Dev Box run. ## Workspace lifecycle diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index c1125bbb..8bc5c498 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -4308,10 +4308,7 @@ 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), - ); + self.ensureLoopVisible(selection.project_path, node_index); self.syncAccessibility(); _ = c.InvalidateRect(self.window.hwnd, null, 0); } @@ -5986,6 +5983,21 @@ pub const App = struct { return self.uia_fixture_project_path; } + fn appendTemporarySidebarOwner(self: *App) !void { + const path = "ssh://sidebar-fixture.invalid/remote"; + const name = "Temporary remote"; + for (self.model.open_projects.items) |project| { + if (!std.mem.eql(u8, project.path, path)) continue; + if (std.mem.eql(u8, project.name, name)) return; + return error.SidebarFixtureOwnerConflict; + } + const owner_path = try self.model.allocator.dupe(u8, path); + errdefer self.model.allocator.free(owner_path); + const owner_name = try self.model.allocator.dupe(u8, name); + errdefer self.model.allocator.free(owner_name); + try self.model.open_projects.append(.{ .path = owner_path, .name = owner_name }); + } + fn ownedUiaFixturePath(self: *const App, kind: UiaFixturePath) ![]u8 { return uiaFixturePath(self.allocator, try self.requiredUiaFixtureProject(), kind); } @@ -6360,6 +6372,15 @@ pub const App = struct { self.editSelectedNodeDetails(); return; } + if (mutation == 22) { + self.appendTemporarySidebarOwner() catch |err| { + self.reportWorktreeError("Unable to prepare temporary sidebar owner", err); + return; + }; + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return; + } const dialog = if (self.worktree_dialog) |*value| value else return; const target_path = if (mutation == 2 or mutation == 3) self.ownedUiaFixturePath(if (mutation == 2) .safe else .unsafe) catch |err| { self.reportWorktreeError("UIA fixture owner unavailable", err); @@ -6627,15 +6648,92 @@ pub const App = struct { } fn ensureWorktreeVisible(self: *App, index: usize) void { + const inspection = if (self.worktree_inspection) |*value| value else null; + var rows = Sidebar.appendRows(self.allocator, &self.model, inspection, 0, &self.sidebar_state) catch { + self.setStatus("Unable to calculate sidebar worktree position"); + return; + }; + defer rows.deinit(self.allocator); + for (rows.items) |row| { + if (row.kind != .worktree or row.index != index) continue; + self.ensureSidebarRowVisible(row, inspection); + return; + } + } + + fn ensureLoopVisible(self: *App, project_path: []const u8, node_index: usize) void { + const inspection = self.currentWorktreeInspection(); + var rows = Sidebar.appendRows(self.allocator, &self.model, inspection, 0, &self.sidebar_state) catch { + self.setStatus("Unable to calculate sidebar loop position"); + return; + }; + defer rows.deinit(self.allocator); + if (findSidebarLoopRow(rows.items, project_path, node_index)) |row| { + self.ensureSidebarRowVisible(row, inspection); + return; + } + + const graph = Sidebar.graphForProject(&self.model, project_path) orelse return; + if (!graph.project().isGlobal()) { + if (graph.project().isRemote()) { + self.sidebar_state.remote_collapsed = false; + } else { + self.sidebar_state.local_collapsed = false; + } + rows.deinit(self.allocator); + rows = .empty; + rows = Sidebar.appendRows(self.allocator, &self.model, inspection, 0, &self.sidebar_state) catch { + self.setStatus("Unable to calculate sidebar loop position"); + return; + }; + if (findSidebarLoopRow(rows.items, project_path, node_index)) |row| { + self.ensureSidebarRowVisible(row, inspection); + return; + } + } + for (rows.items) |row| { + const is_owner = if (graph.project().isGlobal()) + row.kind == .overview + else + row.kind == .open_project and row.project_path != null and + std.mem.eql(u8, row.project_path.?, project_path); + if (!is_owner) continue; + self.ensureSidebarRowVisible(row, inspection); + return; + } + } + + fn findSidebarLoopRow(rows: []const Sidebar.Row, project_path: []const u8, node_index: usize) ?Sidebar.Row { + for (rows) |row| { + if (row.kind != .loop or row.index != node_index or row.project_path == null or + !std.mem.eql(u8, row.project_path.?, project_path)) continue; + return row; + } + return null; + } + + fn ensureSidebarRowVisible( + self: *App, + row: Sidebar.Row, + inspection: ?*const WorktreeStatus.Inspection, + ) 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 bottom = top + 34; - const viewport_top = Sidebar.content_top; + const bounds = Sidebar.rowBounds(row); const viewport_bottom = self.sidebarContentBottom(client.bottom); - if (top < viewport_top) self.sidebar_scroll -= viewport_top - top; - if (bottom > viewport_bottom) self.sidebar_scroll += bottom - viewport_bottom; - self.clampSidebarScroll(); + const viewport_height = viewport_bottom - Sidebar.content_top; + if (viewport_height <= 0) return; + var next = self.sidebar_scroll; + if (bounds.bottom - next > viewport_bottom) { + next = if (bounds.bottom - bounds.top > viewport_height) + bounds.top - Sidebar.content_top + else + bounds.bottom - viewport_bottom; + } + if (bounds.top - next < Sidebar.content_top) next = bounds.top - Sidebar.content_top; + self.sidebar_scroll = Sidebar.clampScroll( + next, + Sidebar.maxScroll(&self.model, inspection, viewport_bottom, &self.sidebar_state), + ); } const SidebarScrollSpan = struct { top: i32, bottom: i32 }; @@ -7627,7 +7725,7 @@ 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(); + 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( @@ -7693,29 +7791,32 @@ pub const App = struct { }; for (sidebar_rows.items) |row| { - const bounds = clipSidebarAccessibilityBounds( - .{ .left = 12, .top = row.top - 3, .right = 232, .bottom = row.top + 23 }, - sidebar_bottom, - ); + const bounds = clipSidebarAccessibilityBounds(Sidebar.rowBounds(row), sidebar_bottom); switch (row.kind) { - .local_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "local", "Local Projects", 1, .{ .logical = bounds }, false, false) catch return, - .remote_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "remote", "Remote Repositories", 1, .{ .logical = bounds }, false, false) catch return, + .local_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "local", row.project_name orelse "Open local folders", 1, .{ .logical = bounds }, false, false) catch return, + .remote_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "remote", row.project_name orelse "Open remote repositories", 1, .{ .logical = bounds }, false, false) catch return, .project => { const project = self.model.recent_projects.items[row.index]; 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; - const new_bounds = clipSidebarAccessibilityBounds( - .{ .left = 174, .top = row.top, .right = 198, .bottom = row.top + 24 }, - sidebar_bottom, - ); - self.appendAccessibilityElement(&elements, &owned_identities, "project-new-loop", path, "New Loop", 1, .{ .logical = new_bounds }, false, false) catch return; + .open_project => if (row.project_path) |path| { + self.appendAccessibilityElement( + &elements, + &owned_identities, + "open-project", + path, + row.project_name orelse path, + 1, + .{ .logical = bounds }, + self.model.selected_project_path != null and std.mem.eql(u8, self.model.selected_project_path.?, path), + false, + ) catch return; + if (Sidebar.graphForProject(&self.model, path) != null) { + const new_bounds = clipSidebarAccessibilityBounds(Sidebar.newLoopBounds(row), 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 }, - sidebar_bottom, - ); + const disclosure_bounds = clipSidebarAccessibilityBounds(Sidebar.disclosureBounds(row), sidebar_bottom); self.appendAccessibilityElement( &elements, &owned_identities, @@ -7729,17 +7830,15 @@ pub const App = struct { ) catch return; } }, - .loop => if (row.project_path) |path| if (self.model.graphFor(path)) |graph| { - if (row.index < graph.nodes.items.len) { - const node = graph.nodes.items[row.index]; + .loop => if (row.project_path) |path| if (Sidebar.graphForProject(&self.model, path)) |graph| { + const nodes = graph.nodes(); + if (row.index < nodes.len) { + const node = nodes[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; if (row.has_children) { - const disclosure_bounds = clipSidebarAccessibilityBounds( - .{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + 24 }, - sidebar_bottom, - ); + const disclosure_bounds = clipSidebarAccessibilityBounds(Sidebar.disclosureBounds(row), sidebar_bottom); self.appendAccessibilityElement( &elements, &owned_identities, @@ -7769,20 +7868,34 @@ 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 }, self.surface == .overview, false) catch return; + if (Sidebar.graphForProject(&self.model, "graphcode://global") != null) { + const new_bounds = clipSidebarAccessibilityBounds(Sidebar.newLoopBounds(row), sidebar_bottom); + self.appendAccessibilityElement(&elements, &owned_identities, "project-new-loop", "graphcode://global", "New Loop", 1, .{ .logical = new_bounds }, false, false) catch return; + } + if (row.has_children) { + const disclosure_bounds = clipSidebarAccessibilityBounds(Sidebar.disclosureBounds(row), sidebar_bottom); + self.appendAccessibilityElement( + &elements, + &owned_identities, + "project-disclosure", + "graphcode://global", + if (self.sidebar_state.isProjectCollapsed("graphcode://global")) "Expand project" else "Collapse project", + 1, + .{ .logical = disclosure_bounds }, + false, + 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; - const new_bounds = clipSidebarAccessibilityBounds( - .{ .left = 174, .top = row.top, .right = 198, .bottom = row.top + 24 }, - sidebar_bottom, - ); + const new_bounds = clipSidebarAccessibilityBounds(Sidebar.newChatBounds(row), 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 }, - sidebar_bottom, - ); + const disclosure_bounds = clipSidebarAccessibilityBounds(Sidebar.chatsDisclosureBounds(row), sidebar_bottom); self.appendAccessibilityElement( &elements, &owned_identities, @@ -8175,6 +8288,8 @@ pub const App = struct { const static_targets = [_]struct { identity: []const u8, target: UiaDynamicTarget }{ .{ .identity = "sidebar-section:local", .target = .local_section }, .{ .identity = "sidebar-section:remote", .target = .remote_section }, + .{ .identity = "project-new-loop:graphcode://global", .target = .{ .project_new_loop = "graphcode://global" } }, + .{ .identity = "project-disclosure:graphcode://global", .target = .{ .project_disclosure = "graphcode://global" } }, .{ .identity = "quick-chats-header:quick-chats", .target = .quick_chats_header }, .{ .identity = "quick-chats-disclosure:quick-chats", .target = .quick_chats_disclosure }, .{ .identity = "quick-chat-new:quick-chats", .target = .new_quick_chat }, @@ -8220,23 +8335,25 @@ pub const App = struct { } } for (self.model.graphs.items) |graph| { - const project_identity = std.fmt.allocPrint(self.allocator, "open-project:{s}", .{graph.project.path}) catch return false; - defer self.allocator.free(project_identity); - if (Accessibility.worktreeIdentityPayload(project_identity) == payload) { - if (target != null) return false; - target = .{ .open_project = graph.project.path }; - } - const project_new_identity = std.fmt.allocPrint(self.allocator, "project-new-loop:{s}", .{graph.project.path}) catch return false; - defer self.allocator.free(project_new_identity); - if (Accessibility.worktreeIdentityPayload(project_new_identity) == payload) { - if (target != null) return false; - target = .{ .project_new_loop = graph.project.path }; - } - const project_disclosure_identity = std.fmt.allocPrint(self.allocator, "project-disclosure:{s}", .{graph.project.path}) catch return false; - defer self.allocator.free(project_disclosure_identity); - if (Accessibility.worktreeIdentityPayload(project_disclosure_identity) == payload) { - if (target != null) return false; - target = .{ .project_disclosure = graph.project.path }; + if (!graph.project.isGlobal()) { + const project_identity = std.fmt.allocPrint(self.allocator, "open-project:{s}", .{graph.project.path}) catch return false; + defer self.allocator.free(project_identity); + if (Accessibility.worktreeIdentityPayload(project_identity) == payload) { + if (target != null) return false; + target = .{ .open_project = graph.project.path }; + } + const project_new_identity = std.fmt.allocPrint(self.allocator, "project-new-loop:{s}", .{graph.project.path}) catch return false; + defer self.allocator.free(project_new_identity); + if (Accessibility.worktreeIdentityPayload(project_new_identity) == payload) { + if (target != null) return false; + target = .{ .project_new_loop = graph.project.path }; + } + const project_disclosure_identity = std.fmt.allocPrint(self.allocator, "project-disclosure:{s}", .{graph.project.path}) catch return false; + defer self.allocator.free(project_disclosure_identity); + if (Accessibility.worktreeIdentityPayload(project_disclosure_identity) == payload) { + if (target != null) return false; + target = .{ .project_disclosure = graph.project.path }; + } } for (graph.nodes.items, 0..) |node, index| { const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ graph.project.path, node.id }) catch return false; @@ -9637,8 +9754,9 @@ fn onWindowMessage( const inspection = if (app.worktree_inspection) |*value| value else null; if (Sidebar.rowAt(x, y, &app.model, inspection, app.sidebar_scroll, app.sidebarContentBottom(client.bottom), &app.sidebar_state)) |row| { if (row.kind == .loop and row.depth == 0 and x < 198) { - if (row.project_path) |path| if (app.model.graphFor(path)) |graph| { - if (row.index < graph.nodes.items.len) app.beginSidebarRootDrag(path, graph.nodes.items[row.index].id, y); + if (row.project_path) |path| if (Sidebar.graphForProject(&app.model, path)) |graph| { + const nodes = graph.nodes(); + if (row.index < nodes.len) app.beginSidebarRootDrag(path, nodes[row.index].id, y); }; } else { app.clearSidebarRootDrag(); @@ -9903,20 +10021,23 @@ fn onWindowMessage( .remote_heading => app.sidebar_state.remote_collapsed = !app.sidebar_state.remote_collapsed, .project => app.openProject(app.model.recent_projects.items[row.index].path), .open_project => if (row.project_path) |path| { - if (x >= 198 and row.has_children) { - app.sidebar_state.toggleProject(path) catch app.setStatus("Sidebar state could not be updated"); - app.clampSidebarScroll(); - app.syncAccessibility(); - _ = c.InvalidateRect(hwnd, null, 0); - result.* = 0; - return true; - } - if (x >= 174 and x < 198) { - if (app.selectProject(path)) app.createNode(); - result.* = 0; - return true; - } - if (app.selectProject(path)) { + if (Sidebar.rowControlAt(x, y, row)) |control| switch (control) { + .disclosure => { + app.sidebar_state.toggleProject(path) catch app.setStatus("Sidebar state could not be updated"); + app.clampSidebarScroll(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + }, + .add_loop => { + if (app.selectProject(path)) app.createNode(); + result.* = 0; + return true; + }, + else => {}, + }; + if (Sidebar.graphForProject(&app.model, path) != null and app.selectProject(path)) { app.surface = .project; app.workspace_controls.panel_visible = false; app.layoutWorkspace(); @@ -9924,11 +10045,29 @@ fn onWindowMessage( app.rebindWorkspace(path); } }, - .overview => app.openGlobalOverview(), - .loop => if (row.project_path) |path| if (app.model.graphFor(path)) |graph| { - if (row.index < graph.nodes.items.len) { - if (x >= 198 and row.has_children) { - app.sidebar_state.toggleNode(graph.nodes.items[row.index].id) catch app.setStatus("Sidebar state could not be updated"); + .overview => { + if (Sidebar.rowControlAt(x, y, row)) |control| { + if (control == .disclosure) { + app.sidebar_state.toggleProject("graphcode://global") catch app.setStatus("Sidebar state could not be updated"); + app.clampSidebarScroll(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + if (control == .add_loop) { + if (app.selectProject("graphcode://global")) app.createNode(); + result.* = 0; + return true; + } + } + app.openGlobalOverview(); + }, + .loop => if (row.project_path) |path| if (Sidebar.graphForProject(&app.model, path)) |graph| { + const nodes = graph.nodes(); + if (row.index < nodes.len) { + if (Sidebar.rowControlAt(x, y, row) == .disclosure) { + app.sidebar_state.toggleNode(nodes[row.index].id) catch app.setStatus("Sidebar state could not be updated"); if (app.sidebar_store) |*store| store.save(&app.sidebar_state) catch app.setStatus("Sidebar expansion could not be saved"); app.clampSidebarScroll(); app.syncAccessibility(); @@ -9936,7 +10075,7 @@ fn onWindowMessage( result.* = 0; return true; } - _ = app.activateLoop(path, graph.nodes.items[row.index].id); + _ = app.activateLoop(path, nodes[row.index].id); } }, .worktree => if (app.worktree_inspection) |inspection| { @@ -9948,19 +10087,22 @@ fn onWindowMessage( app.ensureWorktreeVisible(row.index); }, .quick_chat_overview => { - if (x >= 198 and app.model.quick_chats.items.len != 0) { - app.sidebar_state.chats_collapsed = !app.sidebar_state.chats_collapsed; - app.clampSidebarScroll(); - app.syncAccessibility(); - _ = c.InvalidateRect(hwnd, null, 0); - result.* = 0; - return true; - } - if (x >= 174 and x < 198) { - app.createQuickChat(); - result.* = 0; - return true; - } + if (Sidebar.rowControlAt(x, y, row)) |control| switch (control) { + .chats_disclosure => { + app.sidebar_state.chats_collapsed = !app.sidebar_state.chats_collapsed; + app.clampSidebarScroll(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + }, + .new_chat => { + app.createQuickChat(); + result.* = 0; + return true; + }, + else => {}, + }; app.surface = .quick_chats; app.workspace_controls.panel_visible = false; app.layoutWorkspace(); @@ -10010,10 +10152,15 @@ fn onWindowMessage( project_path = project.path; remote = project.isRemote(); }, - .open_project => if (row.index < app.model.graphs.items.len) { - const project = app.model.graphs.items[row.index].project; - project_path = project.path; - remote = project.isRemote(); + .open_project => if (row.project_path) |path| { + project_path = path; + remote = if (Sidebar.graphForProject(&app.model, path)) |graph| + graph.project().isRemote() + else + std.mem.startsWith(u8, path, "ssh://") or std.mem.startsWith(u8, path, "codespace://"); + }, + .overview => { + project_path = "graphcode://global"; }, else => {}, } @@ -10029,11 +10176,12 @@ fn onWindowMessage( &onContextAction, ); } else switch (row.kind) { - .loop => if (row.project_path) |path| if (app.model.graphFor(path)) |graph| { - if (row.index < graph.nodes.items.len) { + .loop => if (row.project_path) |path| if (Sidebar.graphForProject(&app.model, path)) |graph| { + const nodes = graph.nodes(); + if (row.index < nodes.len) { var screen = c.POINT{ .x = physical_point.x, .y = physical_point.y }; _ = c.ClientToScreen(hwnd, &screen); - app.showOwnedNodeContextMenu(path, null, graph.nodes.items[row.index].id, false, screen.x, screen.y); + app.showOwnedNodeContextMenu(path, null, nodes[row.index].id, false, screen.x, screen.y); } }, .quick_chat => if (row.index < app.model.quick_chats.items.len) { @@ -11686,6 +11834,271 @@ const SidebarUiaSink = struct { } }; +fn countSidebarUiaIdentity(sink: *const SidebarUiaSink, identity: []const u8) usize { + var count: usize = 0; + for (0..sink.count) |index| if (std.mem.eql(u8, sink.name(index), identity)) { + count += 1; + }; + return count; +} + +fn navigationRowForProject(rows: []const Sidebar.Row, kind: Sidebar.RowKind, path: []const u8) ?Sidebar.Row { + for (rows) |row| { + if (row.kind == kind and row.project_path != null and std.mem.eql(u8, row.project_path.?, path)) return row; + } + return null; +} + +fn countSidebarRows(rows: []const Sidebar.Row, kind: Sidebar.RowKind) usize { + var count: usize = 0; + for (rows) |row| if (row.kind == kind) { + count += 1; + }; + return count; +} + +fn updateSidebarNavigationTestGraph( + app: *App, + sequence: usize, + path: []const u8, + name: []const u8, + nodes: []const u8, + edges: []const u8, +) !void { + const quoted_path = try std.json.Stringify.valueAlloc(app.allocator, path, .{}); + defer app.allocator.free(quoted_path); + const quoted_name = try std.json.Stringify.valueAlloc(app.allocator, name, .{}); + defer app.allocator.free(quoted_name); + const frame = try std.fmt.allocPrint( + app.allocator, + "{{\"version\":2,\"kind\":\"event\",\"sequence\":{d},\"event\":{{\"graphChanged\":{{\"project\":{{\"path\":{s},\"name\":{s}}},\"nodes\":{s},\"edges\":{s}}}}}}}", + .{ sequence, quoted_path, quoted_name, nodes, edges }, + ); + defer app.allocator.free(frame); + _ = try app.model.updateFromFrame(frame); +} + +fn expectSidebarNavigationElement( + sink: *const SidebarUiaSink, + identity: []const u8, + label: []const u8, + row: Sidebar.Row, + dpi: u32, +) !c.RECT { + try std.testing.expectEqual(@as(usize, 1), countSidebarUiaIdentity(sink, identity)); + const index = sink.indexOfName(identity) orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings(label, sink.label(index)); + const expected = c.RECT{ + .left = physicalCoordinate(12, dpi), + .top = physicalCoordinate(row.top - 3, dpi), + .right = physicalCoordinate(Tokens.sidebar_width, dpi), + .bottom = physicalCoordinate(row.top + 29, dpi), + }; + try std.testing.expectEqualDeep(expected, sink.rects[index]); + return sink.rects[index]; +} + +test "parity sidebar navigation UIA matches painted owners and clipped row controls" { + for ([_]u32{ 96, 144, 192 }) |dpi| { + var app = try overviewTestApp(dpi); + defer deinitOverviewTestApp(&app); + const frames = [_][]const u8{ + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\local","name":"Local"},"nodes":[{"id":"local-loop","title":"Local loop","state":"idle"}],"edges":[]}}} + , + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"project":{"path":"ssh://host/remote","name":"Remote"},"nodes":[],"edges":[]}}} + , + \\{"version":2,"kind":"event","sequence":3,"event":{"graphChanged":{"project":{"path":"graphcode://global","name":"Graph"},"nodes":[{"id":"global-loop","title":"Global loop","state":"idle"}],"edges":[]}}} + , + \\{"version":2,"kind":"event","sequence":4,"event":{"quickChatsListed":[{"id":"chat","title":"Chat","backend":"claudeCode"}]}} + , + \\{"version":2,"kind":"event","sequence":5,"event":{"recentProjectsListed":[{"path":"C:\\recent","name":"Recent"}]}} + , + }; + for (frames) |frame| _ = try app.model.updateFromFrame(frame); + try app.model.open_projects.append(.{ + .path = try app.allocator.dupe(u8, "codespace://fixture/loading"), + .name = try app.allocator.dupe(u8, "Loading remote"), + }); + app.surface = .overview; + + const client = c.RECT{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }; + const sidebar_bottom = app.sidebarContentBottom(client.bottom); + var rows = try Sidebar.appendRows(app.allocator, &app.model, null, 0, &app.sidebar_state); + defer rows.deinit(app.allocator); + try std.testing.expectEqual(@as(usize, 0), countSidebarRows(rows.items, .project)); + const graph_row = for (rows.items) |row| { + if (row.kind == .overview) break row; + } else return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("graphcode://global", graph_row.project_path orelse ""); + const local_row = navigationRowForProject(rows.items, .open_project, "C:\\local") orelse + return error.TestUnexpectedResult; + const remote_row = navigationRowForProject(rows.items, .open_project, "ssh://host/remote") orelse + return error.TestUnexpectedResult; + const loading_row = navigationRowForProject(rows.items, .open_project, "codespace://fixture/loading") orelse + return error.TestUnexpectedResult; + const quick_chats_row = for (rows.items) |row| { + if (row.kind == .quick_chat_overview) break row; + } else return error.TestUnexpectedResult; + var sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&sink, client); + const graph_bounds = try expectSidebarNavigationElement(&sink, "overview-destination:graph", "Graph", graph_row, dpi); + const quick_bounds = try expectSidebarNavigationElement(&sink, "quick-chats-destination:quick-chats", "Quick Chats", quick_chats_row, dpi); + const local_bounds = try expectSidebarNavigationElement(&sink, "open-project:C:\\local", "Local", local_row, dpi); + const remote_bounds = try expectSidebarNavigationElement(&sink, "open-project:ssh://host/remote", "Remote", remote_row, dpi); + const loading_bounds = try expectSidebarNavigationElement(&sink, "open-project:codespace://fixture/loading", "Loading remote", loading_row, dpi); + for ([_]c.RECT{ graph_bounds, quick_bounds, local_bounds, remote_bounds, loading_bounds }) |bounds| { + try std.testing.expect(bounds.left >= 0 and bounds.right <= physicalCoordinate(Tokens.sidebar_width, dpi)); + try std.testing.expect(bounds.top >= physicalCoordinate(Sidebar.content_top, dpi)); + try std.testing.expect(bounds.bottom <= physicalCoordinate(sidebar_bottom, dpi)); + } + + for (0..sink.count) |index| { + const identity = sink.name(index); + const is_control = std.mem.startsWith(u8, identity, "project-new-loop:") or + std.mem.startsWith(u8, identity, "project-disclosure:") or + std.mem.startsWith(u8, identity, "quick-chat-new:") or + std.mem.startsWith(u8, identity, "quick-chats-disclosure:"); + if (!is_control or rectIsEmpty(sink.rects[index])) continue; + const is_quick_control = std.mem.startsWith(u8, identity, "quick-chat-new:") or + std.mem.startsWith(u8, identity, "quick-chats-disclosure:"); + const owner = if (is_quick_control) blk: { + break :blk quick_bounds; + } else blk: { + const prefix = if (std.mem.startsWith(u8, identity, "project-new-loop:")) + "project-new-loop:" + else + "project-disclosure:"; + const owner_path = identity[prefix.len..]; + break :blk if (std.mem.eql(u8, owner_path, "graphcode://global")) + graph_bounds + else if (std.mem.eql(u8, owner_path, "ssh://host/remote")) + remote_bounds + else + local_bounds; + }; + const control = sink.rects[index]; + try std.testing.expect(control.left >= owner.left and control.top >= owner.top); + try std.testing.expect(control.right <= owner.right and control.bottom <= owner.bottom); + try std.testing.expect(control.right <= physicalCoordinate(Tokens.sidebar_width, dpi)); + try std.testing.expect(control.bottom <= physicalCoordinate(sidebar_bottom, dpi)); + } + try std.testing.expect(sink.indexOfName("project-new-loop:C:\\local") != null); + try std.testing.expect(sink.indexOfName("quick-chat-new:quick-chats") != null); + } +} + +test "parity sidebar navigation Jump and worktree tail scrolling preserve folds" { + const remote_path = "ssh://host/target"; + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + + var filler_nodes: std.ArrayList(u8) = .empty; + defer filler_nodes.deinit(app.allocator); + try filler_nodes.append(app.allocator, '['); + for (0..18) |index| { + if (index != 0) try filler_nodes.append(app.allocator, ','); + try filler_nodes.writer(app.allocator).print( + "{{\"id\":\"filler-{d}\",\"title\":\"Filler {d}\",\"loopType\":\"turnBased\",\"state\":\"idle\"}}", + .{ index, index }, + ); + } + try filler_nodes.append(app.allocator, ']'); + try updateSidebarNavigationTestGraph( + &app, + 1, + "C:\\sidebar-filler", + "Sidebar filler", + filler_nodes.items, + "[]", + ); + try updateSidebarNavigationTestGraph( + &app, + 2, + remote_path, + "Target project", + \\[{"id":"target-root","title":"Target root","loopType":"turnBased","state":"idle"},{"id":"target-child","title":"Target child","loopType":"turnBased","state":"idle"}] + , + \\[{"id":"target-edge","from":"target-root","to":"target-child","kind":"handoff"}] + , + ); + if (c.MoveWindow(app.window.hwnd, 0, 0, 1200, 360, 0) == 0) return error.TestWindowResizeFailed; + + try app.sidebar_state.toggleProject(remote_path); + app.ensureLoopVisible(remote_path, 0); + try std.testing.expect(app.sidebar_state.isProjectCollapsed(remote_path)); + try std.testing.expect(app.sidebar_scroll > 0); + var rows = try Sidebar.appendRows(app.allocator, &app.model, null, 0, &app.sidebar_state); + defer rows.deinit(app.allocator); + const owner = navigationRowForProject(rows.items, .open_project, remote_path) orelse + return error.TestExpectedVisibleOwner; + const client = logicalClientRect(app.window.hwnd, app.dpi); + const viewport_bottom = app.sidebarContentBottom(client.bottom); + const collapsed_owner_bounds = Sidebar.rowBounds(owner); + try std.testing.expect(collapsed_owner_bounds.top - app.sidebar_scroll >= Sidebar.content_top); + try std.testing.expect(collapsed_owner_bounds.bottom - app.sidebar_scroll <= viewport_bottom); + + try app.sidebar_state.toggleProject(remote_path); + app.sidebar_scroll = 0; + app.ensureLoopVisible(remote_path, 1); + try std.testing.expect(!app.sidebar_state.isProjectCollapsed(remote_path)); + try std.testing.expect(!app.sidebar_state.isNodeExpanded("target-root")); + try std.testing.expect(app.sidebar_scroll > 0); + rows.deinit(app.allocator); + rows = try Sidebar.appendRows(app.allocator, &app.model, null, 0, &app.sidebar_state); + const child_visible = App.findSidebarLoopRow(rows.items, remote_path, 1) != null; + try std.testing.expect(!child_visible); + const expanded_owner = navigationRowForProject(rows.items, .open_project, remote_path) orelse + return error.TestExpectedVisibleOwner; + const expanded_owner_bounds = Sidebar.rowBounds(expanded_owner); + try std.testing.expect(expanded_owner_bounds.top - app.sidebar_scroll >= Sidebar.content_top); + try std.testing.expect(expanded_owner_bounds.bottom - app.sidebar_scroll <= viewport_bottom); + + try installNoticeTestInspection(&app, "C:\\sidebar-filler", 12, 4096); + app.ensureWorktreeVisible(11); + try std.testing.expect(app.sidebar_scroll > 0); + var tail_rows = try Sidebar.appendRows( + app.allocator, + &app.model, + app.currentWorktreeInspection(), + 0, + &app.sidebar_state, + ); + defer tail_rows.deinit(app.allocator); + const tail = for (tail_rows.items) |row| { + if (row.kind == .worktree and row.index == 11) break row; + } else return error.TestExpectedTailWorktree; + const tail_bounds = Sidebar.rowBounds(tail); + try std.testing.expect(tail_bounds.top - app.sidebar_scroll >= Sidebar.content_top); + try std.testing.expect(tail_bounds.bottom - app.sidebar_scroll <= viewport_bottom); +} + +test "parity sidebar navigation temporary remote fixture is bounded and idempotent" { + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\fixture","name":"Fixture local"},"nodes":[],"edges":[]}}} + ); + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":2,"event":{"recentProjectsListed":[{"path":"C:\\recent","name":"Recent local"},{"path":"ssh://host/recent","name":"Recent remote"}]}} + ); + try app.appendTemporarySidebarOwner(); + try app.appendTemporarySidebarOwner(); + try std.testing.expectEqual(@as(usize, 2), app.model.open_projects.items.len); + const temporary = for (app.model.open_projects.items) |project| { + if (std.mem.eql(u8, project.path, "ssh://sidebar-fixture.invalid/remote")) break project; + } else return error.TestExpectedTemporarySidebarOwner; + try std.testing.expectEqualStrings("Temporary remote", temporary.name); + try std.testing.expectEqual(@as(usize, 2), app.model.recent_projects.items.len); + try std.testing.expectEqual(@as(usize, 1), app.model.graphs.items.len); + + var rows = try Sidebar.appendRows(app.allocator, &app.model, null, 0, &app.sidebar_state); + defer rows.deinit(app.allocator); + try std.testing.expectEqual(@as(usize, 0), countSidebarRows(rows.items, .project)); + try std.testing.expectEqual(@as(usize, 2), countSidebarRows(rows.items, .open_project)); + try std.testing.expect(navigationRowForProject(rows.items, .open_project, "C:\\fixture") != null); + try std.testing.expect(navigationRowForProject(rows.items, .open_project, "ssh://sidebar-fixture.invalid/remote") != null); +} + fn rectIsEmpty(bounds: c.RECT) bool { return bounds.left >= bounds.right or bounds.top >= bounds.bottom; } diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index ba60cae5..398b75fd 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -20,6 +20,11 @@ pub const State = struct { return .{ .allocator = allocator }; } + pub fn rowHeight(row: Row) i32 { + const bounds = rowBounds(row); + return bounds.bottom - bounds.top; + } + pub fn deinit(self: *State) void { freeSet(self.allocator, &self.collapsed_projects); freeSet(self.allocator, &self.expanded_nodes); @@ -199,12 +204,15 @@ pub fn draw( }; defer rows.deinit(allocator); for (rows.items) |row| { + const bounds = rowBounds(row); switch (row.kind) { .local_heading => { - drawText(hdc, allocator, if (state.local_collapsed) "> LOCAL" else "v LOCAL", 18, row.top, 10, 0x007A7A7A); + drawDisclosureIcon(hdc, rect(18, row.top + 6, 30, row.top + 18), state.local_collapsed, 0x007A7A7A); + drawTextRect(hdc, allocator, "OPEN LOCAL", rect(36, row.top, 168, row.top + 20), 10, 0x007A7A7A, loop_text_format); }, .remote_heading => { - drawText(hdc, allocator, if (state.remote_collapsed) "> REMOTE" else "v REMOTE", 18, row.top, 10, 0x007A7A7A); + drawDisclosureIcon(hdc, rect(18, row.top + 6, 30, row.top + 18), state.remote_collapsed, 0x007A7A7A); + drawTextRect(hdc, allocator, "OPEN REMOTE", rect(36, row.top, 168, row.top + 20), 10, 0x007A7A7A, loop_text_format); }, .project => { const project = model.recent_projects.items[row.index]; @@ -212,25 +220,45 @@ pub fn draw( drawText(hdc, allocator, project.name, 34, row.top, 13, 0x00E6E6E6); }, .overview => { - drawText(hdc, allocator, "G", 18, row.top, 11, 0x007AB8FF); - drawText(hdc, allocator, "Graph", 34, row.top, 13, 0x00E6E6E6); + const selected = if (model.selected_project_path) |selected_path| + std.mem.eql(u8, selected_path, global_project_path) + else + false; + if (selected) fill(hdc, bounds, 0x003A3A44); + drawOutlineIcon(hdc, .graph, rect(18, row.top + 8, 32, row.top + 22), 0x007AB8FF); + drawTextRect(hdc, allocator, "Graph", rect(40, row.top + 5, 172, row.top + 27), 13, 0x00E6E6E6, loop_text_format); + if (hover_y >= bounds.top and hover_y < bounds.bottom and row.has_graph) { + drawPlusIcon(hdc, newLoopBounds(row), 0x00B8B8B8); + if (row.has_children) drawDisclosureIcon(hdc, disclosureBounds(row), state.isProjectCollapsed(global_project_path), 0x00B8B8B8); + } }, - .open_project => if (row.project_path) |path| if (model.graphFor(path)) |summary| { + .open_project => if (row.project_path) |path| { 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); + if (selected) fill(hdc, bounds, 0x003A3A44); + const graph = graphForProject(model, path); + const remote = if (graph) |value| value.project().isRemote() else std.mem.startsWith(u8, path, "ssh://") or std.mem.startsWith(u8, path, "codespace://"); + drawOutlineIcon(hdc, if (remote) .remote_folder else .local_folder, rect(18, row.top + 8, 32, row.top + 22), 0x007A7A7A); + drawTextRect( + hdc, + allocator, + row.project_name orelse path, + rect(40, row.top + 5, 172, row.top + 27), + 12, + if (selected) 0x00FFFFFF else 0x00D0D0D0, + loop_text_format, + ); + if (hover_y >= bounds.top and hover_y < bounds.bottom and row.has_graph) { + drawPlusIcon(hdc, newLoopBounds(row), 0x00B8B8B8); + if (row.has_children) drawDisclosureIcon(hdc, disclosureBounds(row), state.isProjectCollapsed(path), 0x00B8B8B8); } }, - .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]; + .loop => if (row.project_path) |path| if (graphForProject(model, path)) |graph| { + const nodes = graph.nodes(); + if (row.index < nodes.len) { + const node = nodes[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)); @@ -240,15 +268,15 @@ pub fn draw( 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 (row.has_children and hover_y >= bounds.top and hover_y < bounds.bottom) + drawDisclosureIcon(hdc, rect(198, row.top + 4, 220, row.top + 20), !state.isNodeExpanded(node.id), 0x00B8B8B8); } }, .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) - fill(hdc, rect(12, row.top - 3, Tokens.sidebar_width - 12, row.top + 25), 0x003A3A44); + fill(hdc, bounds, 0x003A3A44); drawText(hdc, allocator, entry.path, 24, row.top, 11, 0x00E6E6E6); const size = WorktreeStatus.sizeCoverageText(allocator, entry.sizeCoverage()) catch null; defer if (size) |value_text| allocator.free(value_text); @@ -260,23 +288,20 @@ 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); - if (model.quick_chats.items.len != 0) - drawText(hdc, allocator, if (state.chats_collapsed) ">" else "v", 204, row.top, 9, 0x00B8B8B8); + if (state.chats_collapsed) { + drawDisclosureIcon(hdc, rect(18, row.top + 9, 30, row.top + 21), true, 0x007A7A7A); + } + drawOutlineIcon(hdc, .chat, rect(34, row.top + 8, 48, row.top + 22), 0x007A7A7A); + drawTextRect(hdc, allocator, "Quick Chats", rect(54, row.top + 5, 172, row.top + 27), 13, 0x00E6E6E6, loop_text_format); + if (hover_y >= bounds.top and hover_y < bounds.bottom) { + drawPlusIcon(hdc, newChatBounds(row), 0x00B8B8B8); + if (row.has_children) drawDisclosureIcon(hdc, chatsDisclosureBounds(row), state.chats_collapsed, 0x00B8B8B8); } }, .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(42, row.top + 5, Tokens.sidebar_width - 12, row.top + 27), 11, 0x00E6E6E6, loop_text_format), } } - 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) { @@ -533,86 +558,114 @@ fn elapsedText(allocator: std.mem.Allocator, created_at: i64, now: i64) ![]u8 { return std.fmt.allocPrint(allocator, "{d}d", .{@divTrunc(seconds, 86400)}); } -pub fn loopRowTop(project_count: usize, index: usize) i32 { - const layout = Layout{ .base = Tokens.header_height + 78, .project_count = project_count, .loop_count = 0, .worktree_count = 0 }; - 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 worktreeRowTop(project_count: usize, loop_count: usize, index: usize) i32 { - const layout = Layout{ .base = Tokens.header_height + 78, .project_count = project_count, .loop_count = loop_count, .worktree_count = 0 }; - 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 const RowKind = enum { local_heading, remote_heading, project, open_project, overview, loop, worktree, quick_chat_overview, quick_chat }; pub const Row = struct { kind: RowKind, index: usize, top: i32, project_path: ?[]const u8 = null, + project_name: ?[]const u8 = null, depth: usize = 0, + has_graph: bool = false, has_children: bool = false, }; -const HierarchyItem = struct { index: usize, depth: usize, has_children: bool }; -pub const Layout = struct { - base: i32, - project_count: usize, - project_heading_count: usize = 0, - loop_count: usize, - worktree_count: usize, - quick_chat_count: usize = 0, - graph_present: bool = true, - inspection_present: bool = false, - graph_section_height: i32 = 0, - - pub fn projectTop(self: Layout, index: usize) i32 { - return self.base + @as(i32, @intCast(index * 24)); - } - pub fn overviewTop(self: Layout) i32 { - return self.base + self.projectSectionHeight() + 24; - } - pub fn loopTop(self: Layout, index: usize) i32 { - return self.base + self.projectSectionHeight() + 86 + - @as(i32, @intCast(index * 24)); - } - pub fn worktreeTop(self: Layout, index: usize) i32 { - return self.base + self.projectSectionHeight() + 140 + - @as(i32, @intCast(self.loop_count * 24)) + - @as(i32, @intCast(index * 34)); - } - pub fn quickChatHeadingTop(self: Layout) i32 { - return self.quickChatRowTop(0) - 32; - } - 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)); +pub const ProjectGraph = union(enum) { + summary: *const GraphModel.GraphSummary, + legacy: *const GraphModel.Graph, + + pub fn project(self: ProjectGraph) GraphModel.Project { + return switch (self) { + .summary => |graph| graph.project, + .legacy => |graph| graph.project, + }; } - fn projectSectionHeight(self: Layout) i32 { - return @as(i32, @intCast((self.project_count + self.project_heading_count) * 24)); + + pub fn nodes(self: ProjectGraph) []const GraphModel.Node { + return switch (self) { + .summary => |graph| graph.nodes.items, + .legacy => |graph| graph.nodes.items, + }; + } + + pub fn edges(self: ProjectGraph) []const GraphModel.Edge { + return switch (self) { + .summary => |graph| graph.edges.items, + .legacy => |graph| graph.edges.items, + }; } }; +pub const RowControl = enum { add_loop, disclosure, new_chat, chats_disclosure }; + +const navigation_row_height: i32 = 32; +const section_gap: i32 = 12; +const global_project_path = "graphcode://global"; + +pub fn graphForProject(model: *const GraphModel.Model, path: []const u8) ?ProjectGraph { + if (model.graphFor(path)) |graph| return .{ .summary = graph }; + if (model.graph) |*graph| { + if (std.mem.eql(u8, graph.project.path, path)) return .{ .legacy = graph }; + } + return null; +} + +pub fn projectHasChildren(model: *const GraphModel.Model, path: []const u8) bool { + const graph = graphForProject(model, path) orelse return false; + return graph.nodes().len != 0; +} + +pub fn projectName(model: *const GraphModel.Model, path: []const u8) ?[]const u8 { + const graph = graphForProject(model, path) orelse return null; + return graph.project().name; +} + +pub fn rowBounds(row: Row) c.RECT { + const top_offset: i32 = if (row.kind == .loop) -2 else -3; + const bottom_offset: i32 = switch (row.kind) { + .local_heading, .remote_heading => 21, + .loop => 22, + .worktree => 31, + else => 29, + }; + return rect(12, row.top + top_offset, Tokens.sidebar_width, row.top + bottom_offset); +} + +pub fn newLoopBounds(row: Row) c.RECT { + return rect(174, row.top - 3, 198, row.top + 29); +} + +pub fn disclosureBounds(row: Row) c.RECT { + return rect(198, row.top - 3, 220, row.top + 29); +} + +pub fn newChatBounds(row: Row) c.RECT { + return newLoopBounds(row); +} + +pub fn chatsDisclosureBounds(row: Row) c.RECT { + return disclosureBounds(row); +} + +pub fn rowControlAt(x: i32, y: i32, row: Row) ?RowControl { + const bounds = rowBounds(row); + if (x < bounds.left or x >= bounds.right or y < bounds.top or y >= bounds.bottom) return null; + return switch (row.kind) { + .overview, .open_project, .loop => if (x >= 198 and x < 220 and row.has_children) + .disclosure + else if (x >= 174 and x < 198 and row.has_graph) + .add_loop + else + null, + .quick_chat_overview => if (x >= 198 and x < 220 and row.has_children) + .chats_disclosure + else if (x >= 174 and x < 198) + .new_chat + else + null, + else => null, + }; +} + +const HierarchyItem = struct { index: usize, depth: usize, has_children: bool }; pub fn appendRows( allocator: std.mem.Allocator, @@ -626,134 +679,87 @@ pub fn appendRows( 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; + const global_graph = graphForProject(model, global_project_path); + try rows.append(allocator, .{ + .kind = .overview, + .index = 0, + .top = top, + .project_path = global_project_path, + .project_name = "Graph", + .has_graph = global_graph != null, + .has_children = if (global_graph) |graph| graph.nodes().len != 0 else false, + }); + top += navigation_row_height; + if (global_graph != null) { + if (state == null or !state.?.isProjectCollapsed(global_project_path)) { + top = try appendProjectLoops(allocator, &rows, model, global_project_path, top, state); + } } - 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; + + top += section_gap; + try rows.append(allocator, .{ + .kind = .quick_chat_overview, + .index = 0, + .top = top, + .has_children = model.quick_chats.items.len != 0, + }); + top += navigation_row_height; + if (!chats_collapsed) { + for (model.quick_chats.items, 0..) |_, index| { + try rows.append(allocator, .{ .kind = .quick_chat, .index = index, .top = top }); + top += navigation_row_height; } } - if (hasRemoteProjects(model)) { - try rows.append(allocator, .{ .kind = .remote_heading, .index = 0, .top = top }); + top += section_gap; + + if (hasOpenOwnerInSection(model, .local)) { + try rows.append(allocator, .{ .kind = .local_heading, .index = 0, .top = top, .project_name = "Open local folders" }); top += 24; + if (!local_collapsed) top = try appendOpenOwners(allocator, &rows, model, .local, top, state); + top += section_gap; } - 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; - } + if (hasOpenOwnerInSection(model, .remote)) { + try rows.append(allocator, .{ .kind = .remote_heading, .index = 0, .top = top, .project_name = "Open remote repositories" }); + top += 24; + if (!remote_collapsed) top = try appendOpenOwners(allocator, &rows, model, .remote, top, state); + top += section_gap; } if (inspection) |value| { - top += 24; for (value.entries.items, 0..) |_, index| { try rows.append(allocator, .{ .kind = .worktree, .index = index, .top = top }); 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; } -pub fn layoutFor(model: *const GraphModel.Model, inspection: ?*const WorktreeStatus.Inspection) Layout { - var loop_count: usize = 0; - var graph_section_height: i32 = 62 + @as(i32, @intCast(openProjectPlaceholderCount(model) * 62)); - if (model.graphs.items.len != 0) { - for (model.graphs.items) |summary| { - loop_count += summary.nodes.items.len; - graph_section_height += 62 + @as(i32, @intCast(summary.nodes.items.len * 24)); - } - } else if (model.graph) |graph| { - loop_count = graph.nodes.items.len; - graph_section_height += 62 + @as(i32, @intCast(graph.nodes.items.len * 24)); - } - - return .{ - .base = Tokens.header_height + 78, - .project_count = visibleProjectCount(model), - .project_heading_count = projectHeadingCount(model), - .loop_count = loop_count, - .worktree_count = if (inspection) |value| value.entries.items.len else 0, - .quick_chat_count = model.quick_chats.items.len, - .graph_present = true, - .inspection_present = inspection != null, - .graph_section_height = graph_section_height, - }; -} - 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)); + if (graph_index >= model.graphs.items.len) return Tokens.header_height + 78; + const path = model.graphs.items[graph_index].project.path; + var rows = appendRows(std.heap.page_allocator, model, null, 0, null) catch return Tokens.header_height + 78; + defer rows.deinit(std.heap.page_allocator); + for (rows.items) |row| { + if (row.kind == .open_project and row.project_path != null and std.mem.eql(u8, row.project_path.?, path)) return row.top; } - return top; + return Tokens.header_height + 78; } 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; - 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)); + if (graph_index >= model.graphs.items.len) return sharedGraphTop(model, graph_index) + navigation_row_height; + const path = model.graphs.items[graph_index].project.path; + var rows = appendRows(std.heap.page_allocator, model, null, 0, null) catch return sharedGraphTop(model, graph_index); + defer rows.deinit(std.heap.page_allocator); + for (rows.items) |row| { + if (row.kind == .loop and row.index == node_index and row.project_path != null and std.mem.eql(u8, row.project_path.?, path)) return row.top; + } + return sharedGraphTop(model, graph_index) + navigation_row_height; } pub fn sharedWorktreeTop(model: *const GraphModel.Model, 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) |summary| { - top += 62 + @as(i32, @intCast(summary.nodes.items.len * 24)); - } - return top + 24 + @as(i32, @intCast(index * 34)); + var rows = appendRows(std.heap.page_allocator, model, null, 0, null) catch return Tokens.header_height + 78; + defer rows.deinit(std.heap.page_allocator); + for (rows.items) |row| if (row.kind == .worktree and row.index == index) return row.top; + return Tokens.header_height + 78; } pub fn rowAt( @@ -769,78 +775,149 @@ pub fn rowAt( var rows = appendRows(std.heap.page_allocator, model, inspection, scroll_offset, state) catch return null; 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, - .worktree => 34, - }; - if (y >= row.top and y < row.top + height) return row; + const bounds = rowBounds(row); + if (y >= bounds.top and y < bounds.bottom) return row; } return null; } -fn hasLocalProjects(model: *const GraphModel.Model) bool { - for (model.recent_projects.items) |project| { - if (projectIsVisibleInSection(model, project, .local)) return true; - } - return false; -} +const OwnerSource = enum { open_project, graph_summary, legacy_graph }; +const OwnerGroup = enum { local, remote }; -fn hasRemoteProjects(model: *const GraphModel.Model) bool { - for (model.recent_projects.items) |project| { - if (projectIsVisibleInSection(model, project, .remote)) return true; +fn appendGraphLoops( + allocator: std.mem.Allocator, + rows: *std.ArrayList(Row), + graph: anytype, + initial_top: i32, + state: ?*const State, +) !i32 { + var top = initial_top; + 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; } - return false; + return top; } -const ProjectSection = enum { local, remote }; - -fn projectIsVisibleInSection( +fn appendProjectLoops( + allocator: std.mem.Allocator, + rows: *std.ArrayList(Row), model: *const GraphModel.Model, - project: GraphModel.Project, - section: ProjectSection, -) bool { - if (isProjectOpen(model, project.path)) return false; - return project.isRemote() == (section == .remote); + path: []const u8, + top: i32, + state: ?*const State, +) !i32 { + return switch (graphForProject(model, path) orelse return top) { + .summary => |graph| try appendGraphLoops(allocator, rows, graph, top, state), + .legacy => |graph| try appendGraphLoops(allocator, rows, graph, top, state), + }; } -fn visibleProjectCount(model: *const GraphModel.Model) usize { - var count: usize = 0; - for (model.recent_projects.items) |project| { - if (projectIsVisibleInSection(model, project, if (project.isRemote()) .remote else .local)) count += 1; +fn ownerPathSeenEarlier(model: *const GraphModel.Model, path: []const u8, source: OwnerSource, index: usize) bool { + const open_limit = if (source == .open_project) index else model.open_projects.items.len; + for (model.open_projects.items[0..open_limit]) |project| { + if (std.mem.eql(u8, project.path, path)) return true; } - return count; + if (source == .open_project) return false; + + const graph_limit = if (source == .graph_summary) index else model.graphs.items.len; + for (model.graphs.items[0..graph_limit]) |graph| { + if (std.mem.eql(u8, graph.project.path, path)) return true; + } + return false; } -fn projectHeadingCount(model: *const GraphModel.Model) usize { - return @as(usize, @intFromBool(hasLocalProjects(model))) + - @as(usize, @intFromBool(hasRemoteProjects(model))); +fn projectInGroup(project: GraphModel.Project, group: OwnerGroup) bool { + if (project.isGlobal()) return false; + return project.isRemote() == (group == .remote); } -fn hasGraphForProject(model: *const GraphModel.Model, path: []const u8) bool { - for (model.graphs.items) |graph| { - if (std.mem.eql(u8, graph.project.path, path)) return true; +fn hasOpenOwnerInSection(model: *const GraphModel.Model, group: OwnerGroup) bool { + for (model.open_projects.items, 0..) |project, index| { + if (projectInGroup(project, group) and !ownerPathSeenEarlier(model, project.path, .open_project, index)) return true; + } + for (model.graphs.items, 0..) |graph, index| { + if (projectInGroup(graph.project, group) and !ownerPathSeenEarlier(model, graph.project.path, .graph_summary, index)) return true; + } + if (model.graph) |graph| { + if (projectInGroup(graph.project, group) and !ownerPathSeenEarlier(model, graph.project.path, .legacy_graph, 0)) return true; } - if (model.graph) |graph| return std.mem.eql(u8, graph.project.path, path); return false; } -fn openProjectPlaceholderCount(model: *const GraphModel.Model) usize { +fn openOwnerCount(model: *const GraphModel.Model, group: OwnerGroup) usize { var count: usize = 0; - for (model.open_projects.items) |project| { - if (!hasGraphForProject(model, project.path)) count += 1; + for (model.open_projects.items, 0..) |project, index| { + if (projectInGroup(project, group) and !ownerPathSeenEarlier(model, project.path, .open_project, index)) count += 1; + } + for (model.graphs.items, 0..) |graph, index| { + if (projectInGroup(graph.project, group) and !ownerPathSeenEarlier(model, graph.project.path, .graph_summary, index)) count += 1; + } + if (model.graph) |graph| { + if (projectInGroup(graph.project, group) and !ownerPathSeenEarlier(model, graph.project.path, .legacy_graph, 0)) count += 1; } return count; } -fn isProjectOpen(model: *const GraphModel.Model, path: []const u8) bool { - for (model.open_projects.items) |project| { - if (std.mem.eql(u8, project.path, path)) return true; +fn appendOwnerCandidate( + allocator: std.mem.Allocator, + rows: *std.ArrayList(Row), + model: *const GraphModel.Model, + project: GraphModel.Project, + source: OwnerSource, + index: usize, + group: OwnerGroup, + top: *i32, + state: ?*const State, +) !void { + if (!projectInGroup(project, group) or ownerPathSeenEarlier(model, project.path, source, index)) return; + const graph = graphForProject(model, project.path); + const name = if (graph) |value| value.project().name else project.name; + try rows.append(allocator, .{ + .kind = .open_project, + .index = index, + .top = top.*, + .project_path = project.path, + .project_name = if (name.len == 0) project.path else name, + .has_graph = graph != null, + .has_children = if (graph) |value| value.nodes().len != 0 else false, + }); + top.* += navigation_row_height; + if (graph != null) { + if (state == null or !state.?.isProjectCollapsed(project.path)) { + top.* = try appendProjectLoops(allocator, rows, model, project.path, top.*, state); + } } - for (model.graphs.items) |graph| { - if (std.mem.eql(u8, graph.project.path, path)) return true; +} + +fn appendOpenOwners( + allocator: std.mem.Allocator, + rows: *std.ArrayList(Row), + model: *const GraphModel.Model, + group: OwnerGroup, + initial_top: i32, + state: ?*const State, +) !i32 { + var top = initial_top; + for (model.open_projects.items, 0..) |project, index| { + try appendOwnerCandidate(allocator, rows, model, project, .open_project, index, group, &top, state); } - if (model.graph) |graph| return std.mem.eql(u8, graph.project.path, path); - return false; + for (model.graphs.items, 0..) |graph, index| { + try appendOwnerCandidate(allocator, rows, model, graph.project, .graph_summary, index, group, &top, state); + } + if (model.graph) |graph| { + try appendOwnerCandidate(allocator, rows, model, graph.project, .legacy_graph, 0, group, &top, state); + } + return top; } fn hierarchyItems( @@ -978,10 +1055,6 @@ fn hierarchyPosition( return node_index; } -pub fn worktreeSectionBottom(project_count: usize, worktree_count: usize) i32 { - return worktreeRowTop(project_count, 0, worktree_count) + 10; -} - pub fn contentBottom(model: *const GraphModel.Model, inspection: ?*const WorktreeStatus.Inspection, state: ?*const State) i32 { const section = sidebarSectionBottom(model, inspection, state); var bottom = section; @@ -1013,7 +1086,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 rowBounds(last).bottom; } pub fn needsYouStopBounds( @@ -1241,35 +1314,16 @@ pub fn clampScroll(value: i32, maximum: i32) i32 { return @min(@max(value, 0), @max(maximum, 0)); } -pub fn hitTestWorktree(x: i32, y: i32, project_count: usize, count: usize, scroll_offset: i32, viewport_bottom: i32) ?usize { - if (x < 12 or x >= Tokens.sidebar_width) return null; - if (y < content_top or y >= viewport_bottom) return null; - const layout = Layout{ .base = Tokens.header_height + 78, .project_count = project_count, .loop_count = 0, .worktree_count = 0 }; - const top = layout.worktreeTop(0) - scroll_offset; - if (y < top) return null; - const index: usize = @intCast(@divTrunc(y - top, 34)); - if (index >= count) return null; - return index; -} - -pub fn hitTestProject(x: i32, y: i32, model: *const GraphModel.Model, scroll_offset: i32, viewport_bottom: i32) ?usize { - if (x < 0 or x >= Tokens.sidebar_width or y < content_top or y >= viewport_bottom) return null; - const top = Tokens.header_height + 78 - scroll_offset; - if (y < top) return null; - const index: usize = @intCast(@divTrunc(y - top, 24)); - if (index >= model.recent_projects.items.len) return null; - return index; -} - pub fn hitTestOverviewLoop(x: i32, y: i32, model: *const GraphModel.Model, scroll_offset: i32, viewport_bottom: i32) ?usize { if (x < 0 or x >= Tokens.sidebar_width or y < content_top or y >= viewport_bottom) return null; - const top = Tokens.header_height + 78 + - @as(i32, @intCast((visibleProjectCount(model) + projectHeadingCount(model)) * 24)) - - scroll_offset; - if (model.graph == null and model.graphs.items.len == 0 or y < top) return null; - const index: usize = @intCast(@divTrunc(y - top, 24)); - if (index >= layoutFor(model, null).loop_count) return null; - return index; + var rows = appendRows(std.heap.page_allocator, model, null, scroll_offset, null) catch return null; + defer rows.deinit(std.heap.page_allocator); + for (rows.items) |row| { + if (row.kind != .loop or row.project_path == null or !std.mem.eql(u8, row.project_path.?, global_project_path)) continue; + const bounds = rowBounds(row); + if (y >= bounds.top and y < bounds.bottom) return row.index; + } + return null; } fn reason(entry: WorktreeStatus.Entry) []const u8 { @@ -1283,13 +1337,42 @@ fn reason(entry: WorktreeStatus.Entry) []const u8 { return if (WorktreeStatus.decision(entry) == .reclaimable) "safe to reclaim" else "unsafe to reclaim"; } -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)); - try std.testing.expectEqual(@as(?usize, 1), hitTestWorktree(24, top + 34 + 4, 2, 2, 0, 700)); - try std.testing.expectEqual(@as(?usize, null), hitTestWorktree(Tokens.sidebar_width + 1, top, 2, 2, 0, 700)); - try std.testing.expectEqual(@as(?usize, null), hitTestWorktree(24, top + 68, 2, 2, 0, 700)); - try std.testing.expectEqual(@as(i32, worktreeRowTop(3, 0, 0) - worktreeRowTop(1, 0, 0)), 48); +test "worktree row hit testing uses generated visible rows" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + var inspection = WorktreeStatus.Inspection{ + .entries = std.array_list.Managed(WorktreeStatus.Entry).init(allocator), + .default_branch = try allocator.dupe(u8, "main"), + .project_path = try allocator.dupe(u8, "project"), + }; + defer WorktreeStatus.deinitInspection(allocator, &inspection); + for (0..2) |index| try inspection.entries.append(.{ + .path = try std.fmt.allocPrint(allocator, "worktree-{d}", .{index}), + .branch = try allocator.dupe(u8, "branch"), + }); + var rows = try appendRows(allocator, &model, &inspection, 0, null); + defer rows.deinit(allocator); + const worktree_rows = [_]Row{ + for (rows.items) |row| { + if (row.kind == .worktree and row.index == 0) break row; + } else return error.TestUnexpectedResult, + for (rows.items) |row| { + if (row.kind == .worktree and row.index == 1) break row; + } else return error.TestUnexpectedResult, + }; + for (worktree_rows) |row| { + const hit = rowAt(24, row.top + 14, &model, &inspection, 0, 700, null) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(RowKind.worktree, hit.kind); + try std.testing.expectEqual(row.index, hit.index); + } + try std.testing.expectEqual(@as(i32, 34), worktree_rows[1].top - worktree_rows[0].top); + try std.testing.expect(rowAt(Tokens.sidebar_width + 1, worktree_rows[0].top + 14, &model, &inspection, 0, 700, null) == null); + const handoff = rowAt(24, worktree_rows[0].top + 31, &model, &inspection, 0, 700, null) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(worktree_rows[1].index, handoff.index); + try std.testing.expect(rowAt(24, 700, &model, &inspection, 0, 700, null) == null); } test "root reorder validates uniqueness and replaces order atomically" { @@ -1375,7 +1458,7 @@ test "needs-you stop and activity hit targets remain bounded" { try std.testing.expectEqual(ActivityControl.filter, activityControlAt(filter.left + 2, filter.top + 2, &model, null, &state, 0).?); } -test "shared sidebar layout routes every loop row after project rows and scroll" { +test "sidebar layout routes generated loop rows after owner rows and scroll" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); try model.recent_projects.append(.{ @@ -1407,12 +1490,22 @@ test "shared sidebar layout routes every loop row after project rows and scroll" .presence = try std.testing.allocator.dupe(u8, ""), }); model.graph = graph; - const layout = layoutFor(&model, null); + var rows = try appendRows(std.testing.allocator, &model, null, 0, null); + defer rows.deinit(std.testing.allocator); + var loop_rows: usize = 0; for (0..2) |index| { - const row = rowAt(24, layout.loopTop(index) - 11, &model, null, 11, 700, null) orelse return error.TestUnexpectedResult; + const row = for (rows.items) |candidate| { + if (candidate.kind == .loop and candidate.index == index) break candidate; + } else return error.TestUnexpectedResult; + loop_rows += 1; + const hit = rowAt(24, row.top - 11 + 12, &model, null, 11, 700, null) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(RowKind.loop, hit.kind); + try std.testing.expectEqual(index, hit.index); try std.testing.expectEqual(RowKind.loop, row.kind); try std.testing.expectEqual(index, row.index); } + try std.testing.expectEqual(@as(usize, 2), loop_rows); } test "multi-project rows share render and hit-test offsets with project identity" { @@ -1431,8 +1524,11 @@ 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.?); + var rows = try appendRows(allocator, &model, null, 0, null); + defer rows.deinit(allocator); + const last = rows.items[rows.items.len - 1]; try std.testing.expectEqual( - layoutFor(&model, null).quickChatRowTop(model.quick_chats.items.len + 1), + rowBounds(last).bottom, sidebarSectionBottom(&model, null, null), ); } @@ -1562,16 +1658,23 @@ test "sidebar scroll clamps overflow, shrink, and resize" { inspection.entries.deinit(); } -test "sidebar without graph counts only static rendered content" { +test "sidebar without graph excludes recent-project rows from its content extent" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); try model.recent_projects.append(.{ .path = try std.testing.allocator.dupe(u8, "project"), .name = try std.testing.allocator.dupe(u8, "Project"), }); + var rows = try appendRows(std.testing.allocator, &model, null, 0, null); + defer rows.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 0), countRows(rows.items, .project)); + try std.testing.expectEqual(@as(usize, 0), countRows(rows.items, .open_project)); + try std.testing.expectEqual(@as(usize, 1), model.recent_projects.items.len); + const last = rows.items[rows.items.len - 1]; 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)); + const expected_max = @max(rowBounds(last).bottom - 100, 0); + try std.testing.expectEqual(expected_max, no_graph_max); + try std.testing.expectEqual(expected_max, clampScroll(180, no_graph_max)); } test "global Graph row remains pinned without an open project" { @@ -1585,27 +1688,33 @@ test "global Graph row remains pinned without an open project" { try std.testing.expectEqual(RowKind.overview, graph_row.kind); } -test "recent projects are grouped into local and remote sections" { +test "open owners are grouped by locality while recent projects stay out of navigation" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); try model.recent_projects.append(.{ .path = try std.testing.allocator.dupe(u8, "ssh://host/repo"), - .name = try std.testing.allocator.dupe(u8, "Remote"), + .name = try std.testing.allocator.dupe(u8, "Recent remote"), }); try model.recent_projects.append(.{ .path = try std.testing.allocator.dupe(u8, "C:\\repo"), - .name = try std.testing.allocator.dupe(u8, "Local"), + .name = try std.testing.allocator.dupe(u8, "Recent local"), }); + try appendTestOpenProject(std.testing.allocator, &model, "ssh://host/open", "Remote"); + try appendTestOpenProject(std.testing.allocator, &model, "C:\\open", "Local"); 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.open_project, rows.items[3].kind); + try std.testing.expectEqualStrings("C:\\open", rows.items[3].project_path.?); + try std.testing.expectEqual(RowKind.remote_heading, rows.items[4].kind); + try std.testing.expectEqual(RowKind.open_project, rows.items[5].kind); + try std.testing.expectEqualStrings("ssh://host/open", rows.items[5].project_path.?); + try std.testing.expectEqual(@as(usize, 0), countRows(rows.items, .project)); } -test "recent project rows exclude folders already open in the projects list" { +test "recent project data remains available without adding sidebar destinations" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); try model.recent_projects.append(.{ @@ -1622,12 +1731,11 @@ 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(@as(usize, 2), model.recent_projects.items.len); + try std.testing.expectEqualStrings("C:\\recent", model.recent_projects.items[1].path); + try std.testing.expectEqual(@as(usize, 0), countRows(rows.items, .project)); + try std.testing.expectEqual(@as(usize, 1), countProjectRows(rows.items, .open_project, "C:\\open")); + try std.testing.expectEqual(@as(usize, 0), countProjectRows(rows.items, .open_project, "C:\\recent")); } test "handoff edges derive stable nested loop order and depth" { @@ -1669,23 +1777,19 @@ test "message and spawn edges do not define sidebar hierarchy" { test "sidebar groups and nested disclosures collapse independently and persist expansion" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); - try model.recent_projects.append(.{ - .path = try std.testing.allocator.dupe(u8, "C:\\local"), - .name = try std.testing.allocator.dupe(u8, "Local"), - }); - try model.recent_projects.append(.{ - .path = try std.testing.allocator.dupe(u8, "ssh://host/remote"), - .name = try std.testing.allocator.dupe(u8, "Remote"), - }); + try appendTestOpenProject(std.testing.allocator, &model, "C:\\local", "Local"); + try appendTestOpenProject(std.testing.allocator, &model, "ssh://host/remote", "Remote"); var state = State.init(std.testing.allocator); defer state.deinit(); 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.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.remote_heading, rows.items[3].kind); + try std.testing.expectEqual(RowKind.open_project, rows.items[4].kind); + try std.testing.expectEqualStrings("ssh://host/remote", rows.items[4].project_path.?); try state.toggleNode("root"); const encoded = try state.encode(std.testing.allocator); @@ -1724,6 +1828,201 @@ fn countRows(rows: []const Row, kind: RowKind) usize { return count; } +fn appendTestOpenProject(allocator: std.mem.Allocator, model: *GraphModel.Model, path: []const u8, name: []const u8) !void { + try model.open_projects.append(.{ + .path = try allocator.dupe(u8, path), + .name = try allocator.dupe(u8, name), + }); +} + +fn countProjectRows(rows: []const Row, kind: RowKind, path: []const u8) usize { + var count: usize = 0; + for (rows) |row| { + if (row.kind == kind and row.project_path != null and std.mem.eql(u8, row.project_path.?, path)) count += 1; + } + return count; +} + +fn expectRowProjectPath(row: Row, path: []const u8) !void { + try std.testing.expectEqualStrings(path, row.project_path orelse return error.TestUnexpectedResult); +} + +test "parity sidebar navigation pins destinations before open folder groups" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + try model.recent_projects.append(.{ + .path = try allocator.dupe(u8, "C:\\recent"), + .name = try allocator.dupe(u8, "Recent local"), + }); + try model.recent_projects.append(.{ + .path = try allocator.dupe(u8, "ssh://host/recent"), + .name = try allocator.dupe(u8, "Recent remote"), + }); + try appendTestOpenProject(allocator, &model, "C:\\local", "Local"); + try appendTestOpenProject(allocator, &model, "ssh://host/remote", "Remote"); + try model.quick_chats.append(.{ + .id = try allocator.dupe(u8, "chat"), + .title = try allocator.dupe(u8, "Chat"), + .backend = try allocator.dupe(u8, "claudeCode"), + }); + + var rows = try appendRows(allocator, &model, null, 0, null); + defer rows.deinit(allocator); + + const expected = [_]RowKind{ + .overview, + .quick_chat_overview, + .quick_chat, + .local_heading, + .open_project, + .remote_heading, + .open_project, + }; + try std.testing.expectEqual(expected.len, rows.items.len); + for (expected, rows.items) |kind, row| try std.testing.expectEqual(kind, row.kind); + try std.testing.expectEqual(@as(usize, 0), countRows(rows.items, .project)); + try expectRowProjectPath(rows.items[4], "C:\\local"); + try expectRowProjectPath(rows.items[6], "ssh://host/remote"); +} + +test "parity sidebar navigation emits each open owner once including global and loading owners" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + try appendTestOpenProject(allocator, &model, "C:\\same-a", "Same"); + try appendTestOpenProject(allocator, &model, "C:\\same-b", "Same"); + try appendTestOpenProject(allocator, &model, "C:\\loading", "Loading owner"); + try appendTestOpenProject(allocator, &model, "C:\\loading", "Loading owner duplicate"); + try appendTestOpenProject(allocator, &model, "graphcode://global", "Global graph"); + try model.recent_projects.append(.{ + .path = try allocator.dupe(u8, "graphcode://global"), + .name = try allocator.dupe(u8, "Global graph fallback"), + }); + + var global = GraphModel.Graph{ + .project = .{ + .path = try allocator.dupe(u8, "graphcode://global"), + .name = try allocator.dupe(u8, "Graph"), + }, + .nodes = std.array_list.Managed(GraphModel.Node).init(allocator), + .edges = std.array_list.Managed(GraphModel.Edge).init(allocator), + }; + try global.nodes.append(.{ + .id = try allocator.dupe(u8, "global-loop"), + .title = try allocator.dupe(u8, "Global loop"), + .loop_type = try allocator.dupe(u8, "goalBased"), + .state = try allocator.dupe(u8, "idle"), + .activity = try allocator.dupe(u8, ""), + .presence = try allocator.dupe(u8, "idle"), + }); + model.graph = global; + + var rows = try appendRows(allocator, &model, null, 0, null); + defer rows.deinit(allocator); + + try std.testing.expectEqual(RowKind.overview, rows.items[0].kind); + try expectRowProjectPath(rows.items[0], "graphcode://global"); + try std.testing.expect(rows.items[0].has_children); + try std.testing.expectEqual(RowKind.loop, rows.items[1].kind); + try expectRowProjectPath(rows.items[1], "graphcode://global"); + try std.testing.expectEqual(@as(usize, 1), countProjectRows(rows.items, .open_project, "C:\\same-a")); + try std.testing.expectEqual(@as(usize, 1), countProjectRows(rows.items, .open_project, "C:\\same-b")); + try std.testing.expectEqual(@as(usize, 1), countProjectRows(rows.items, .open_project, "C:\\loading")); + try std.testing.expectEqual(@as(usize, 0), countProjectRows(rows.items, .open_project, "graphcode://global")); + try std.testing.expectEqual(@as(usize, 0), countRows(rows.items, .project)); + + var loaded_model = GraphModel.Model.init(allocator); + defer loaded_model.deinit(); + const loaded_graph = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"loaded","project":{"path":"C:\\loaded","name":"Loaded graph"},"nodes":[],"edges":[]}}} + ; + _ = try loaded_model.updateFromFrame(loaded_graph); + try appendTestOpenProject(allocator, &loaded_model, "C:\\loaded", "Duplicate metadata"); + var loaded_rows = try appendRows(allocator, &loaded_model, null, 0, null); + defer loaded_rows.deinit(allocator); + try std.testing.expectEqual(@as(usize, 1), countProjectRows(loaded_rows.items, .open_project, "C:\\loaded")); +} + +test "parity sidebar navigation folds do not hide peer destinations" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + try appendTestOpenProject(allocator, &model, "C:\\local", "Local"); + try appendTestOpenProject(allocator, &model, "ssh://host/remote", "Remote"); + try model.quick_chats.append(.{ + .id = try allocator.dupe(u8, "chat"), + .title = try allocator.dupe(u8, "Chat"), + .backend = try allocator.dupe(u8, "claudeCode"), + }); + var state = State.init(allocator); + defer state.deinit(); + state.local_collapsed = true; + + var local_collapsed = try appendRows(allocator, &model, null, 0, &state); + defer local_collapsed.deinit(allocator); + try std.testing.expectEqual(RowKind.overview, local_collapsed.items[0].kind); + try std.testing.expectEqual(RowKind.quick_chat_overview, local_collapsed.items[1].kind); + try std.testing.expectEqual(@as(usize, 1), countRows(local_collapsed.items, .quick_chat)); + try std.testing.expectEqual(@as(usize, 0), countProjectRows(local_collapsed.items, .open_project, "C:\\local")); + try std.testing.expectEqual(@as(usize, 1), countProjectRows(local_collapsed.items, .open_project, "ssh://host/remote")); + + state.local_collapsed = false; + state.remote_collapsed = true; + var remote_collapsed = try appendRows(allocator, &model, null, 0, &state); + defer remote_collapsed.deinit(allocator); + try std.testing.expectEqual(@as(usize, 1), countProjectRows(remote_collapsed.items, .open_project, "C:\\local")); + try std.testing.expectEqual(@as(usize, 0), countProjectRows(remote_collapsed.items, .open_project, "ssh://host/remote")); + try std.testing.expectEqual(@as(usize, 1), countRows(remote_collapsed.items, .quick_chat)); +} + +test "parity sidebar navigation shares row extents across scroll and hit testing" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + for (0..14) |index| { + const path = try std.fmt.allocPrint(allocator, "C:\\project-{d}", .{index}); + defer allocator.free(path); + const name = try std.fmt.allocPrint(allocator, "Project {d}", .{index}); + defer allocator.free(name); + try appendTestOpenProject(allocator, &model, path, name); + } + try model.quick_chats.append(.{ + .id = try allocator.dupe(u8, "chat"), + .title = try allocator.dupe(u8, "Chat"), + .backend = try allocator.dupe(u8, "claudeCode"), + }); + + var rows = try appendRows(allocator, &model, null, 0, null); + defer rows.deinit(allocator); + for (rows.items) |row| { + if (row.kind != .overview and row.kind != .quick_chat_overview and + row.kind != .open_project and row.kind != .quick_chat) continue; + const center = row.top + 16; + const center_hit = rowAt(24, center, &model, null, 0, 1000, null) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(row.kind, center_hit.kind); + try std.testing.expectEqual(row.project_path, center_hit.project_path); + const lower_slot_hit = rowAt(24, row.top + 28, &model, null, 0, 1000, null) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(row.kind, lower_slot_hit.kind); + try std.testing.expectEqual(row.project_path, lower_slot_hit.project_path); + } + + const viewport_bottom: i32 = 240; + const content_bottom = contentViewportBottom(viewport_bottom, false, false); + const scroll = maxScroll(&model, null, content_bottom, null); + try std.testing.expect(scroll > 0); + var scrolled = try appendRows(allocator, &model, null, scroll, null); + defer scrolled.deinit(allocator); + const last = scrolled.items[scrolled.items.len - 1]; + const last_hit = rowAt(24, last.top + 16, &model, null, scroll, content_bottom, null) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(last.kind, last_hit.kind); + try std.testing.expectEqual(last.project_path, last_hit.project_path); + try std.testing.expect(rowAt(24, content_bottom, &model, null, scroll, content_bottom, null) == null); +} + test "quick chats remain selectable without an open graph or inspection" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); @@ -1732,33 +2031,47 @@ test "quick chats remain selectable without an open graph or inspection" { .title = try std.testing.allocator.dupe(u8, "Scratch"), .backend = try std.testing.allocator.dupe(u8, "claudeCode"), }); - const layout = layoutFor(&model, null); - try std.testing.expect(layout.quickChatHeadingTop() + 11 < layout.quickChatRowTop(0)); - const overview = rowAt(24, layout.quickChatRowTop(0) + 4, &model, null, 0, 700, null) orelse + var rows = try appendRows(std.testing.allocator, &model, null, 0, null); + defer rows.deinit(std.testing.allocator); + const overview_row = for (rows.items) |row| { + if (row.kind == .quick_chat_overview) break row; + } else return error.TestUnexpectedResult; + const chat_row = for (rows.items) |row| { + if (row.kind == .quick_chat) break row; + } else return error.TestUnexpectedResult; + const overview = rowAt(24, overview_row.top + 16, &model, null, 0, 700, null) orelse return error.TestUnexpectedResult; try std.testing.expectEqual(RowKind.quick_chat_overview, overview.kind); - const row = rowAt(24, layout.quickChatRowTop(1) + 4, &model, null, 0, 700, null) orelse + const row = rowAt(24, chat_row.top + 16, &model, null, 0, 700, null) orelse return error.TestUnexpectedResult; 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), - layout.quickChatRowTop(0), - ); - try std.testing.expect(rowAt(24, layout.quickChatHeadingTop() + 4, &model, null, 0, 700, null) == null); - try std.testing.expectEqual(layout.quickChatRowTop(2), sidebarSectionBottom(&model, null, null)); + try std.testing.expectEqual(@as(i32, navigation_row_height), chat_row.top - overview_row.top); + try std.testing.expectEqual(rowBounds(rows.items[rows.items.len - 1]).bottom, sidebarSectionBottom(&model, null, null)); } -test "quick chat heading and rows stay distinct across graph layouts" { - const layouts = [_]Layout{ - .{ .base = 100, .project_count = 2, .loop_count = 0, .worktree_count = 0, .graph_present = false }, - .{ .base = 100, .project_count = 2, .loop_count = 3, .worktree_count = 0 }, - .{ .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)); - } +test "quick chat rows use the navigation row height after graph loops" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"graphcode://global","name":"Graph"},"nodes":[{"id":"one","title":"One","state":"idle"},{"id":"two","title":"Two","state":"idle"}],"edges":[]}}} + ); + try model.quick_chats.append(.{ + .id = try std.testing.allocator.dupe(u8, "chat"), + .title = try std.testing.allocator.dupe(u8, "Chat"), + .backend = try std.testing.allocator.dupe(u8, "claudeCode"), + }); + var rows = try appendRows(std.testing.allocator, &model, null, 0, null); + defer rows.deinit(std.testing.allocator); + const last_graph_loop = for (rows.items) |row| { + if (row.kind == .loop and std.mem.eql(u8, row.project_path.?, global_project_path) and row.index == 1) + break row; + } else return error.TestUnexpectedResult; + const chats_row = for (rows.items) |row| { + if (row.kind == .quick_chat_overview) break row; + } else return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(i32, 36), chats_row.top - last_graph_loop.top); + try std.testing.expectEqual(@as(usize, 1), countRows(rows.items, .quick_chat)); } test "sidebar loop presentation preserves type and terminal states" { @@ -1861,10 +2174,13 @@ test "sidebar footer reserve keeps content, status text, banner, and error foote test "sidebar rows beneath the update banner are neither hit nor counted as visible" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); - for (0..12) |index| try model.recent_projects.append(.{ - .path = try std.fmt.allocPrint(std.testing.allocator, "project-{d}", .{index}), - .name = try std.fmt.allocPrint(std.testing.allocator, "Project {d}", .{index}), - }); + for (0..12) |index| { + const path = try std.fmt.allocPrint(std.testing.allocator, "C:\\project-{d}", .{index}); + const name = try std.fmt.allocPrint(std.testing.allocator, "Project {d}", .{index}); + try appendTestOpenProject(std.testing.allocator, &model, path, name); + std.testing.allocator.free(path); + std.testing.allocator.free(name); + } const viewport_bottom: i32 = 420; const banner = updateBannerRect(viewport_bottom, false); const content_bottom = contentViewportBottom(viewport_bottom, true, false); @@ -1885,7 +2201,7 @@ test "sidebar rows beneath the update banner are neither hit nor counted as visi const max = maxScroll(&model, null, content_bottom, null); try std.testing.expectEqual(contentBottom(&model, null, null) - content_bottom, max); const last = rows.items[rows.items.len - 1]; - try std.testing.expect(last.top + 24 - max <= content_bottom); + try std.testing.expect(rowBounds(last).bottom - max <= content_bottom); } test "update banner is a bounded footer action" { @@ -2141,6 +2457,78 @@ fn measuredWidth(hdc: c.HDC, label: []const u8) !i32 { return size.cx; } +const SidebarIcon = enum { graph, local_folder, remote_folder, chat }; + +fn drawOutlineIcon(hdc: c.HDC, icon: SidebarIcon, bounds: c.RECT, color: u32) void { + const pen = c.CreatePen(c.PS_SOLID, 1, color) orelse return; + const previous_pen = c.SelectObject(hdc, pen); + const previous_brush = c.SelectObject(hdc, c.GetStockObject(c.HOLLOW_BRUSH)); + switch (icon) { + .graph => { + _ = c.MoveToEx(hdc, bounds.left + 4, bounds.top + 10, null); + _ = c.LineTo(hdc, bounds.left + 8, bounds.top + 4); + _ = c.LineTo(hdc, bounds.left + 12, bounds.top + 10); + _ = c.MoveToEx(hdc, bounds.left + 4, bounds.top + 10, null); + _ = c.LineTo(hdc, bounds.left + 12, bounds.top + 10); + _ = c.Ellipse(hdc, bounds.left + 1, bounds.top + 7, bounds.left + 7, bounds.top + 13); + _ = c.Ellipse(hdc, bounds.left + 5, bounds.top + 1, bounds.left + 11, bounds.top + 7); + _ = c.Ellipse(hdc, bounds.left + 9, bounds.top + 7, bounds.left + 15, bounds.top + 13); + }, + .local_folder, .remote_folder => { + _ = c.MoveToEx(hdc, bounds.left + 1, bounds.top + 4, null); + _ = c.LineTo(hdc, bounds.left + 6, bounds.top + 4); + _ = c.LineTo(hdc, bounds.left + 8, bounds.top + 6); + _ = c.LineTo(hdc, bounds.right - 1, bounds.top + 6); + _ = c.LineTo(hdc, bounds.right - 2, bounds.bottom - 1); + _ = c.LineTo(hdc, bounds.left + 2, bounds.bottom - 1); + _ = c.LineTo(hdc, bounds.left + 1, bounds.top + 4); + if (icon == .remote_folder) { + _ = c.Ellipse(hdc, bounds.left + 8, bounds.top + 8, bounds.left + 13, bounds.top + 13); + } + }, + .chat => { + _ = c.RoundRect(hdc, bounds.left + 1, bounds.top + 1, bounds.right - 1, bounds.bottom - 3, 4, 4); + _ = c.MoveToEx(hdc, bounds.left + 4, bounds.bottom - 3, null); + _ = c.LineTo(hdc, bounds.left + 2, bounds.bottom - 1); + _ = c.LineTo(hdc, bounds.left + 7, bounds.bottom - 3); + }, + } + _ = c.SelectObject(hdc, previous_brush); + _ = c.SelectObject(hdc, previous_pen); + _ = c.DeleteObject(pen); +} + +fn drawPlusIcon(hdc: c.HDC, bounds: c.RECT, color: u32) void { + const pen = c.CreatePen(c.PS_SOLID, 1, color) orelse return; + const previous = c.SelectObject(hdc, pen); + const x = @divTrunc(bounds.left + bounds.right, 2); + const y = @divTrunc(bounds.top + bounds.bottom, 2); + _ = c.MoveToEx(hdc, x - 4, y, null); + _ = c.LineTo(hdc, x + 5, y); + _ = c.MoveToEx(hdc, x, y - 4, null); + _ = c.LineTo(hdc, x, y + 5); + _ = c.SelectObject(hdc, previous); + _ = c.DeleteObject(pen); +} + +fn drawDisclosureIcon(hdc: c.HDC, bounds: c.RECT, collapsed: bool, color: u32) void { + const pen = c.CreatePen(c.PS_SOLID, 1, color) orelse return; + const previous = c.SelectObject(hdc, pen); + const x = @divTrunc(bounds.left + bounds.right, 2); + const y = @divTrunc(bounds.top + bounds.bottom, 2); + if (collapsed) { + _ = c.MoveToEx(hdc, x - 2, y - 4, null); + _ = c.LineTo(hdc, x + 2, y); + _ = c.LineTo(hdc, x - 2, y + 4); + } else { + _ = c.MoveToEx(hdc, x - 4, y - 2, null); + _ = c.LineTo(hdc, x, y + 2); + _ = c.LineTo(hdc, x + 4, y - 2); + } + _ = c.SelectObject(hdc, previous); + _ = c.DeleteObject(pen); +} + fn rect(left: i32, top: i32, right: i32, bottom: i32) c.RECT { return .{ .left = left, .top = top, .right = right, .bottom = bottom }; } From 7794fc88e56ad9ccf3debbba1c636d784865e5fe Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 10:01:28 -0700 Subject: [PATCH 2/6] Correct sidebar UIA geometry expectations Update the named assertions for the navigation row extents and the DPI-scaled local section bounds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0db84998-f1cc-4cc7-a927-6c8912b42cdb Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 8bc5c498..e70916aa 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -12459,11 +12459,20 @@ 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); +fn expectedSidebarRowRect(row: Sidebar.Row, region_top: i32, region_bottom: i32) c.RECT { + const top_offset: i32 = if (row.kind == .loop) -2 else -3; + const bottom_offset: i32 = switch (row.kind) { + .loop => 22, + .local_heading, .remote_heading => 21, + .worktree => 31, + else => 29, + }; + const row_top = row.top + top_offset; + const row_bottom = row.top + bottom_offset; + const top = @max(row_top, region_top); + const bottom = @min(row_bottom, 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 = 12, .top = top, .right = Tokens.sidebar_width, .bottom = bottom }; } test "sidebar UIA bounds are the drawn rect clipped to the scroll region at short heights, 96 and 144 DPI" { @@ -12506,7 +12515,7 @@ test "sidebar UIA bounds are the drawn rect clipped to the scroll region at shor var rows = try Sidebar.appendRows(app.allocator, &app.model, inspection, app.sidebar_scroll, &app.sidebar_state); defer rows.deinit(app.allocator); for (rows.items) |row| { - const expected = (AccessibilityBounds{ .logical = expectedSidebarRowRect(row.top, region_top, region_bottom) }).physicalRect(app.dpi); + const expected = (AccessibilityBounds{ .logical = expectedSidebarRowRect(row, region_top, region_bottom) }).physicalRect(app.dpi); var identity_buffer: [64]u8 = undefined; const identity: []const u8 = switch (row.kind) { .loop => blk: { @@ -20295,7 +20304,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 = .{ .{ 12, 229, 220, 253 }, .{ 18, 344, 330, 380 }, .{ 24, 458, 440, 506 } } }, .{ .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 From 7639c00665667f60421d06d6aa2e6450f58c350d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 10:14:29 -0700 Subject: [PATCH 3/6] Report sidebar navigation fixture coverage Emit the executed positive and negative sidebar oracle fixture count in ValidationRunner output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0db84998-f1cc-4cc7-a927-6c8912b42cdb Signed-off-by: Colin Neilens --- Tools/windows/Tests/ValidationRunner.Tests.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index ad42f6a0..edb42846 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -3330,6 +3330,7 @@ try { throw "RED: Sidebar navigation oracle failed to preserve the owner order evidence" } } + Write-Host "SIDEBAR_NAVIGATION_FIXTURES=7 (5 positive, 2 negative; includes temporary fixture restoration)" foreach ($helperName in @( "Protect-UiaStartupDiagnosticText", "Get-UiaStartupDiagnosticValue", From 1e1c0963227a1838f4e583b67260b1338ada377d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 12:19:21 -0700 Subject: [PATCH 4/6] Align sidebar selection with active surface Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0db84998-f1cc-4cc7-a927-6c8912b42cdb Signed-off-by: Colin Neilens --- .../windows/Tests/ValidationRunner.Tests.ps1 | 31 ++- Tools/windows/uia-live-gate.ps1 | 16 +- graphcode-windows/README.md | 17 +- graphcode-windows/src/App.zig | 204 +++++++++++++++--- graphcode-windows/src/GraphCanvas.zig | 3 +- graphcode-windows/src/Sidebar.zig | 44 ++-- 6 files changed, 249 insertions(+), 66 deletions(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index edb42846..9b6a3488 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -3227,6 +3227,9 @@ try { } . ([scriptblock]::Create($sidebarOracleHelper.Extent.Text)) & { + $sidebarFixtureCount = 0 + $sidebarPositiveCount = 0 + $sidebarNegativeCount = 0 function New-SidebarNavigationElement( [string] $automationId, [string] $name, @@ -3249,9 +3252,9 @@ try { $sidebarFixture = @( (New-SidebarNavigationElement "overview-destination:graph" "Graph" 12 20 220 52), (New-SidebarNavigationElement "quick-chats-destination:quick-chats" "Quick Chats" 12 56 220 88), - (New-SidebarNavigationElement "sidebar-section-1" "Open local folders" 12 100 220 121), + (New-SidebarNavigationElement "sidebar-section-1" "Local Projects" 12 100 220 121), (New-SidebarNavigationElement "open-project-1" "UIA project" 12 121 220 153), - (New-SidebarNavigationElement "sidebar-section-2" "Open remote repositories" 12 168 220 189), + (New-SidebarNavigationElement "sidebar-section-2" "Remote Repositories" 12 168 220 189), (New-SidebarNavigationElement "open-project-2" "Temporary remote" 12 192 220 224), (New-SidebarNavigationElement "overview-destination" "Graph" 12 17 220 49), (New-SidebarNavigationElement "quick-chats-destination" "Quick Chats" 12 53 220 85), @@ -3267,12 +3270,14 @@ try { ($initial.destinationNames -join "|") -cne "Graph|Quick Chats" -or $initial.ownerNames.Count -ne 2 -or ($initial.ownerNames -join "|") -cne "UIA project|Temporary remote" -or - ($initial.sectionNames -join "|") -cne "Open local folders|Open remote repositories" -or + ($initial.sectionNames -join "|") -cne "Local Projects|Remote Repositories" -or $initial.recentRowIds.Count -ne 0 -or -not $initial.rowBoundsValid -or -not $initial.controlsValid -or $initial.controlsChecked -ne 6) { throw "RED: Sidebar navigation oracle missed destination order, open owners, section names, or contained controls" } + $sidebarFixtureCount++ + $sidebarPositiveCount++ $localCollapsed = @($sidebarFixture | Where-Object { $_.Current.AutomationId -notin @( "open-project-1", @@ -3284,6 +3289,8 @@ try { if (($afterLocalCollapse.ownerNames -join "|") -cne "Temporary remote") { throw "RED: Sidebar navigation oracle did not isolate local owner collapse" } + $sidebarFixtureCount++ + $sidebarPositiveCount++ $remoteCollapsed = @($sidebarFixture | Where-Object { $_.Current.AutomationId -ne "open-project-2" }) @@ -3291,6 +3298,8 @@ try { if (($afterRemoteCollapse.ownerNames -join "|") -cne "UIA project") { throw "RED: Sidebar navigation oracle did not isolate remote owner collapse" } + $sidebarFixtureCount++ + $sidebarPositiveCount++ $restoredFixture = @($sidebarFixture | Where-Object { $_.Current.AutomationId -notin @( "sidebar-section-2", @@ -3299,16 +3308,20 @@ try { }) $restored = Get-SidebarNavigationSnapshot $restoredFixture if (($restored.ownerNames -join "|") -cne "UIA project" -or - ($restored.sectionNames -join "|") -cne "Open local folders" -or + ($restored.sectionNames -join "|") -cne "Local Projects" -or $restored.recentRowIds.Count -ne 0) { throw "RED: Sidebar navigation oracle accepted a temporary remote owner after fixture restore" } + $sidebarFixtureCount++ + $sidebarPositiveCount++ $recentMutation = Get-SidebarNavigationSnapshot @( $sidebarFixture + (New-SidebarNavigationElement "project-row-recent" "Recent folder" 12 228 220 260) ) if ($recentMutation.recentRowIds.Count -ne 1) { throw "RED: Sidebar navigation oracle failed to detect a projected unopened recent folder" } + $sidebarFixtureCount++ + $sidebarNegativeCount++ $clippedMutation = @($sidebarFixture | Where-Object { $_.Current.AutomationId -ne "project-new-loop-5" }) + (New-SidebarNavigationElement "project-new-loop-5" "New Loop" 174 121 228 153) @@ -3316,6 +3329,8 @@ try { if ($clipped.controlsValid) { throw "RED: Sidebar navigation oracle accepted a row control extending beyond its owner" } + $sidebarFixtureCount++ + $sidebarNegativeCount++ $reorderedOwners = @($sidebarFixture | Where-Object { $_.Current.AutomationId -notin @( "open-project-1", @@ -3329,8 +3344,14 @@ try { if (($reordered.ownerNames -join "|") -cne "Temporary remote|UIA project") { throw "RED: Sidebar navigation oracle failed to preserve the owner order evidence" } + $sidebarFixtureCount++ + $sidebarPositiveCount++ + if ($sidebarFixtureCount -ne 7 -or $sidebarPositiveCount -ne 5 -or $sidebarNegativeCount -ne 2) { + throw "RED: Sidebar navigation fixture execution count changed unexpectedly" + } + Write-Host ("SIDEBAR_NAVIGATION_FIXTURES={0} ({1} positive, {2} negative; App 22->20 restoration has a separate hidden test)" -f ` + $sidebarFixtureCount, $sidebarPositiveCount, $sidebarNegativeCount) } - Write-Host "SIDEBAR_NAVIGATION_FIXTURES=7 (5 positive, 2 negative; includes temporary fixture restoration)" foreach ($helperName in @( "Protect-UiaStartupDiagnosticText", "Get-UiaStartupDiagnosticValue", diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 40795ed2..73c15f92 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -5465,7 +5465,7 @@ try { Require ($sidebarNavigation.recentRowIds.Count -eq 0) ` "unopened recent folders were projected as sidebar destinations" Assert-Ids $sidebarNavigation.ownerNames @("UIA project", "Temporary remote") "open sidebar owner order" - Assert-Ids $sidebarNavigation.sectionNames @("Open local folders", "Open remote repositories") "open sidebar sections" + Assert-Ids $sidebarNavigation.sectionNames @("Local Projects", "Remote Repositories") "open sidebar sections" Require ($sidebarNavigation.rowBoundsValid) "sidebar destinations, sections, or open owners had empty bounds" Require ($sidebarNavigation.controlsValid -and $sidebarNavigation.controlsChecked -gt 0) ` "sidebar row controls were missing or outside their owner row" @@ -5521,28 +5521,28 @@ try { $null = Assert-FragmentLinks $projects $rawWalker $projectChildIds "RawView Projects" $null = Assert-FragmentLinks $projects $controlWalker $projectChildIds "ControlView Projects" $localSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { - $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Open local folders" + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Local Projects" }) | Select-Object -First 1 - Require ($null -ne $localSection) "Open local folders section did not expose its section action" + Require ($null -ne $localSection) "Local Projects section did not expose its section action" $localSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() Start-Sleep -Milliseconds 150 $afterLocalCollapse = Get-SidebarNavigationSnapshot @(Get-DirectChildren $projects $rawWalker) Assert-Ids $afterLocalCollapse.ownerNames @("Temporary remote") "owners after local-section collapse" $localSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { - $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Open local folders" + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Local Projects" }) | Select-Object -First 1 $localSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() Start-Sleep -Milliseconds 150 $remoteSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { - $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Open remote repositories" + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Remote Repositories" }) | Select-Object -First 1 - Require ($null -ne $remoteSection) "Open remote repositories section did not expose its section action" + Require ($null -ne $remoteSection) "Remote Repositories section did not expose its section action" $remoteSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() Start-Sleep -Milliseconds 150 $afterRemoteCollapse = Get-SidebarNavigationSnapshot @(Get-DirectChildren $projects $rawWalker) Assert-Ids $afterRemoteCollapse.ownerNames @("UIA project") "owners after remote-section collapse" $remoteSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { - $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Open remote repositories" + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Remote Repositories" }) | Select-Object -First 1 $remoteSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() Start-Sleep -Milliseconds 150 @@ -5551,7 +5551,7 @@ try { Start-Sleep -Milliseconds 150 $restoredSidebar = Get-SidebarNavigationSnapshot @(Get-DirectChildren $projects $rawWalker) Assert-Ids $restoredSidebar.ownerNames @("UIA project") "restored open sidebar owners" - Assert-Ids $restoredSidebar.sectionNames @("Open local folders") "restored sidebar sections" + Assert-Ids $restoredSidebar.sectionNames @("Local Projects") "restored sidebar sections" Require ($restoredSidebar.recentRowIds.Count -eq 0) "restored sidebar fixture retained unopened recent rows" $loopRows = @(Get-DirectChildren $loops $rawWalker | Where-Object { diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 08fa0295..b87c028a 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -365,13 +365,16 @@ the workspace identity region remains separate from the terminal's physical tabs ### Sidebar scrolling and short windows -Graph and Quick Chats stay first, followed by the open local-folder and remote- -repository groups. Unopened recent folders remain available from File > Add -Folder > Recent Folders rather than appearing as sidebar destinations. Sidebar -rows, Needs you, and Activity scroll beneath the fixed window header and above -the footer (status line, update banner, inline error footer). What is painted, -what UIA publishes, and what a click hits are the same rect, clipped to that -scroll region; the right-hand Activity control sits inside the 220-DIP rail. +Graph and Quick Chats stay first, followed by the “Local Projects” and “Remote +Repositories” groups. Unopened recent folders remain available from File > Add +Folder > Recent Folders rather than appearing as sidebar destinations. The +normal-sidebar parity claim covers these navigation destinations only; the +existing Needs-you and Worktrees sections are supplementary Windows content. +The recorded Activity bottom strip is present, not absent. Sidebar rows, Needs +you, and Activity scroll beneath the fixed window header and above the footer +(status line, update banner, inline error footer). What is painted, what UIA +publishes, and what a click hits are the same rect, clipped to that scroll +region; the right-hand Activity control sits inside the 220-DIP rail. Keyboard and assistive clients reach offscreen rows through UIA, not a hidden shortcut: each sidebar list (Projects, Loops, Worktrees) exposes a vertical-only diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index e70916aa..94cb20ef 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -4893,6 +4893,28 @@ pub const App = struct { self.presentWorktreeSweep(); } + fn sidebarProjectContextTarget(self: *const App, row: Sidebar.Row) ?GraphContextMenu.Target { + var project_path: ?[]const u8 = null; + var remote = false; + switch (row.kind) { + .project => if (row.index < self.model.recent_projects.items.len) { + const project = self.model.recent_projects.items[row.index]; + project_path = project.path; + remote = project.isRemote(); + }, + .open_project => if (row.project_path) |path| { + project_path = path; + remote = if (Sidebar.graphForProject(&self.model, path)) |graph| + graph.project().isRemote() + else + std.mem.startsWith(u8, path, "ssh://") or std.mem.startsWith(u8, path, "codespace://"); + }, + else => {}, + } + const path = project_path orelse return null; + return .{ .project = self.projectContextMenuTarget(path, remote) }; + } + fn handleContextAction(self: *App, action: GraphContextMenu.Action, target: GraphContextMenu.Target) void { switch (target) { .project => |stable| { @@ -7793,8 +7815,8 @@ pub const App = struct { for (sidebar_rows.items) |row| { const bounds = clipSidebarAccessibilityBounds(Sidebar.rowBounds(row), sidebar_bottom); switch (row.kind) { - .local_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "local", row.project_name orelse "Open local folders", 1, .{ .logical = bounds }, false, false) catch return, - .remote_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "remote", row.project_name orelse "Open remote repositories", 1, .{ .logical = bounds }, false, false) catch return, + .local_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "local", row.project_name orelse "Local Projects", 1, .{ .logical = bounds }, false, false) catch return, + .remote_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "remote", row.project_name orelse "Remote Repositories", 1, .{ .logical = bounds }, false, false) catch return, .project => { const project = self.model.recent_projects.items[row.index]; self.appendAccessibilityElement(&elements, &owned_identities, "project", project.path, project.name, 1, .{ .logical = bounds }, false, false) catch return; @@ -7808,7 +7830,7 @@ pub const App = struct { row.project_name orelse path, 1, .{ .logical = bounds }, - self.model.selected_project_path != null and std.mem.eql(u8, self.model.selected_project_path.?, path), + Sidebar.navigationRowSelected(row, self.surface, self.model.selected_project_path), false, ) catch return; if (Sidebar.graphForProject(&self.model, path) != null) { @@ -7869,7 +7891,7 @@ pub const App = struct { } }, .overview => { - self.appendAccessibilityElement(&elements, &owned_identities, "overview-destination", "graph", "Graph", 1, .{ .logical = bounds }, self.surface == .overview, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "overview-destination", "graph", "Graph", 1, .{ .logical = bounds }, Sidebar.navigationRowSelected(row, self.surface, self.model.selected_project_path), false) catch return; if (Sidebar.graphForProject(&self.model, "graphcode://global") != null) { const new_bounds = clipSidebarAccessibilityBounds(Sidebar.newLoopBounds(row), sidebar_bottom); self.appendAccessibilityElement(&elements, &owned_identities, "project-new-loop", "graphcode://global", "New Loop", 1, .{ .logical = new_bounds }, false, false) catch return; @@ -7890,8 +7912,8 @@ pub const App = struct { } }, .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.navigationRowSelected(row, self.surface, self.model.selected_project_path), false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chats-header", "quick-chats", "Quick Chats", 1, .{ .logical = bounds }, Sidebar.navigationRowSelected(row, self.surface, self.model.selected_project_path), false) catch return; const new_bounds = clipSidebarAccessibilityBounds(Sidebar.newChatBounds(row), 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) { @@ -10144,32 +10166,12 @@ fn onWindowMessage( app.sidebarContentBottom(client.bottom), &app.sidebar_state, )) |row| { - var project_path: ?[]const u8 = null; - var remote = false; - switch (row.kind) { - .project => if (row.index < app.model.recent_projects.items.len) { - const project = app.model.recent_projects.items[row.index]; - project_path = project.path; - remote = project.isRemote(); - }, - .open_project => if (row.project_path) |path| { - project_path = path; - remote = if (Sidebar.graphForProject(&app.model, path)) |graph| - graph.project().isRemote() - else - std.mem.startsWith(u8, path, "ssh://") or std.mem.startsWith(u8, path, "codespace://"); - }, - .overview => { - project_path = "graphcode://global"; - }, - else => {}, - } - if (project_path) |path| { + if (app.sidebarProjectContextTarget(row)) |target| { var screen = c.POINT{ .x = physical_point.x, .y = physical_point.y }; _ = c.ClientToScreen(hwnd, &screen); GraphContextMenu.show( hwnd, - .{ .project = app.projectContextMenuTarget(path, remote) }, + target, screen.x, screen.y, app, @@ -11788,6 +11790,7 @@ const SidebarUiaSink = struct { rects: [192]c.RECT = undefined, names: [192][160]u8 = undefined, name_lens: [192]usize = undefined, + selected: [192]bool = undefined, invokable: [192]bool = undefined, scrolls: [192]bool = undefined, labels: [192][40]u8 = undefined, @@ -11811,6 +11814,7 @@ const SidebarUiaSink = struct { const len = @min(element.identity.len, self.names[self.count].len); @memcpy(self.names[self.count][0..len], element.identity[0..len]); self.name_lens[self.count] = len; + self.selected[self.count] = element.selected; const label_len = @min(element.name.len, self.labels[self.count].len); @memcpy(self.labels[self.count][0..label_len], element.name[0..label_len]); self.label_lens[self.count] = label_len; @@ -11842,6 +11846,11 @@ fn countSidebarUiaIdentity(sink: *const SidebarUiaSink, identity: []const u8) us return count; } +fn expectSidebarNavigationSelection(sink: *const SidebarUiaSink, identity: []const u8, selected: bool) !void { + const index = sink.indexOfName(identity) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(selected, sink.selected[index]); +} + fn navigationRowForProject(rows: []const Sidebar.Row, kind: Sidebar.RowKind, path: []const u8) ?Sidebar.Row { for (rows) |row| { if (row.kind == kind and row.project_path != null and std.mem.eql(u8, row.project_path.?, path)) return row; @@ -11939,6 +11948,7 @@ test "parity sidebar navigation UIA matches painted owners and clipped row contr const quick_chats_row = for (rows.items) |row| { if (row.kind == .quick_chat_overview) break row; } else return error.TestUnexpectedResult; + try std.testing.expect(app.model.selectProject("C:\\local")); var sink: SidebarUiaSink = .{}; app.syncAccessibilityTo(&sink, client); const graph_bounds = try expectSidebarNavigationElement(&sink, "overview-destination:graph", "Graph", graph_row, dpi); @@ -11946,6 +11956,50 @@ test "parity sidebar navigation UIA matches painted owners and clipped row contr const local_bounds = try expectSidebarNavigationElement(&sink, "open-project:C:\\local", "Local", local_row, dpi); const remote_bounds = try expectSidebarNavigationElement(&sink, "open-project:ssh://host/remote", "Remote", remote_row, dpi); const loading_bounds = try expectSidebarNavigationElement(&sink, "open-project:codespace://fixture/loading", "Loading remote", loading_row, dpi); + const route_cases = [_]struct { + surface: GraphCanvas.Surface, + quick_chat_workspace: bool, + graph: bool, + quick_chats: bool, + local: bool, + }{ + .{ .surface = .overview, .quick_chat_workspace = false, .graph = true, .quick_chats = false, .local = false }, + .{ .surface = .quick_chats, .quick_chat_workspace = false, .graph = false, .quick_chats = true, .local = false }, + .{ .surface = .project, .quick_chat_workspace = false, .graph = false, .quick_chats = false, .local = true }, + .{ .surface = .workspace, .quick_chat_workspace = false, .graph = false, .quick_chats = false, .local = false }, + .{ .surface = .workspace, .quick_chat_workspace = true, .graph = false, .quick_chats = false, .local = false }, + }; + for (route_cases) |case| { + app.surface = case.surface; + app.workspace_is_quick_chat = case.quick_chat_workspace; + var route_sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&route_sink, client); + try expectSidebarNavigationSelection(&route_sink, "overview-destination:graph", case.graph); + try expectSidebarNavigationSelection(&route_sink, "quick-chats-destination:quick-chats", case.quick_chats); + try expectSidebarNavigationSelection(&route_sink, "open-project:C:\\local", case.local); + try expectSidebarNavigationSelection(&route_sink, "open-project:ssh://host/remote", false); + try expectSidebarNavigationSelection(&route_sink, "open-project:codespace://fixture/loading", false); + + { + var bitmap = try paintSidebarBitmap(&app, client.bottom, false, 0); + defer bitmap.deinit(); + const row_selections = [_]struct { row: Sidebar.Row, selected: bool }{ + .{ .row = graph_row, .selected = case.graph }, + .{ .row = quick_chats_row, .selected = case.quick_chats }, + .{ .row = local_row, .selected = case.local }, + }; + for (row_selections) |selection| { + const expected_fill = dibColor(if (selection.selected) 0x003A3A44 else Tokens.workspace_rail); + try std.testing.expectEqual(expected_fill, bitmap.pixel(190, selection.row.top + 10)); + } + } + } + app.surface = .overview; + app.workspace_is_quick_chat = false; + const local_section_index = sink.indexOfName("sidebar-section:local") orelse return error.TestUnexpectedResult; + const remote_section_index = sink.indexOfName("sidebar-section:remote") orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("Local Projects", sink.label(local_section_index)); + try std.testing.expectEqualStrings("Remote Repositories", sink.label(remote_section_index)); for ([_]c.RECT{ graph_bounds, quick_bounds, local_bounds, remote_bounds, loading_bounds }) |bounds| { try std.testing.expect(bounds.left >= 0 and bounds.right <= physicalCoordinate(Tokens.sidebar_width, dpi)); try std.testing.expect(bounds.top >= physicalCoordinate(Sidebar.content_top, dpi)); @@ -11987,6 +12041,18 @@ test "parity sidebar navigation UIA matches painted owners and clipped row contr } } +test "parity sidebar Graph row has no project context menu target" { + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + var rows = try Sidebar.appendRows(app.allocator, &app.model, null, 0, &app.sidebar_state); + defer rows.deinit(app.allocator); + const graph_row = for (rows.items) |row| { + if (row.kind == .overview) break row; + } else return error.TestUnexpectedResult; + + try std.testing.expect(app.sidebarProjectContextTarget(graph_row) == null); +} + test "parity sidebar navigation Jump and worktree tail scrolling preserve folds" { const remote_path = "ssh://host/target"; var app = try overviewTestApp(Dpi.base_dpi); @@ -12099,6 +12165,84 @@ test "parity sidebar navigation temporary remote fixture is bounded and idempote try std.testing.expect(navigationRowForProject(rows.items, .open_project, "ssh://sidebar-fixture.invalid/remote") != null); } +test "parity sidebar navigation restores the actual UIA fixture after mutation 22 then 20" { + const allocator = std.testing.allocator; + const gate_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_UIA_GATE"); + const cwd_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_GATE_CWD"); + const reset_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_UIA_RESET_SIDEBAR"); + const update_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_UIA_UPDATE_AVAILABLE"); + const ingress_key = std.unicode.utf8ToUtf16LeStringLiteral("GRAPHCODE_UIA_INGRESS_ERROR"); + var original_environment = try std.process.getEnvMap(allocator); + defer original_environment.deinit(); + defer setWorkspaceTestEnvironment(gate_key, original_environment.get("GRAPHCODE_UIA_GATE")) catch @panic("UIA gate environment restore failed"); + defer setWorkspaceTestEnvironment(cwd_key, original_environment.get("GRAPHCODE_GATE_CWD")) catch @panic("UIA fixture path environment restore failed"); + defer setWorkspaceTestEnvironment(reset_key, original_environment.get("GRAPHCODE_UIA_RESET_SIDEBAR")) catch @panic("UIA sidebar reset environment restore failed"); + defer setWorkspaceTestEnvironment(update_key, original_environment.get("GRAPHCODE_UIA_UPDATE_AVAILABLE")) catch @panic("UIA update environment restore failed"); + defer setWorkspaceTestEnvironment(ingress_key, original_environment.get("GRAPHCODE_UIA_INGRESS_ERROR")) catch @panic("UIA ingress environment restore failed"); + + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const fixture_path = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(fixture_path); + try setWorkspaceTestEnvironment(gate_key, "1"); + try setWorkspaceTestEnvironment(cwd_key, fixture_path); + try setWorkspaceTestEnvironment(reset_key, null); + try setWorkspaceTestEnvironment(update_key, null); + try setWorkspaceTestEnvironment(ingress_key, null); + + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + try std.testing.expect(app.installUiaFixture(true)); + const FixtureOwner = struct { path: []u8, name: []u8 }; + var baseline_owners = std.array_list.Managed(FixtureOwner).init(allocator); + defer { + for (baseline_owners.items) |owner| { + allocator.free(owner.path); + allocator.free(owner.name); + } + baseline_owners.deinit(); + } + for (app.model.open_projects.items) |owner| { + const path = try allocator.dupe(u8, owner.path); + const name = allocator.dupe(u8, owner.name) catch |err| { + allocator.free(path); + return err; + }; + baseline_owners.append(.{ .path = path, .name = name }) catch |err| { + allocator.free(path); + allocator.free(name); + return err; + }; + } + + app.mutateUiaFixture(22); + try std.testing.expectEqual(baseline_owners.items.len + 1, app.model.open_projects.items.len); + var temporary_owner_count: usize = 0; + for (app.model.open_projects.items) |owner| { + if (!std.mem.eql(u8, owner.path, "ssh://sidebar-fixture.invalid/remote")) continue; + temporary_owner_count += 1; + try std.testing.expectEqualStrings("Temporary remote", owner.name); + } + try std.testing.expectEqual(@as(usize, 1), temporary_owner_count); + var temporary_sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&temporary_sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expect(temporary_sink.indexOfName("open-project:ssh://sidebar-fixture.invalid/remote") != null); + + app.mutateUiaFixture(20); + try std.testing.expectEqual(Sidebar.Surface.project, app.surface); + try std.testing.expectEqual(baseline_owners.items.len, app.model.open_projects.items.len); + for (baseline_owners.items, app.model.open_projects.items) |expected, actual| { + try std.testing.expectEqualStrings(expected.path, actual.path); + try std.testing.expectEqualStrings(expected.name, actual.name); + } + try std.testing.expectEqualStrings(app.uia_fixture_project_path, app.model.graph.?.project.path); + try std.testing.expectEqual(@as(usize, 2), app.model.quick_chats.items.len); + try std.testing.expectEqual(@as(usize, 2), app.model.recent_projects.items.len); + var restored_sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&restored_sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expect(restored_sink.indexOfName("open-project:ssh://sidebar-fixture.invalid/remote") == null); +} + fn rectIsEmpty(bounds: c.RECT) bool { return bounds.left >= bounds.right or bounds.top >= bounds.bottom; } @@ -12240,6 +12384,7 @@ fn paintedIngressFooter(app: *App, sidebar_bottom: i32, update: bool) !?c.RECT { Sidebar.draw( hdc, &app.model, + app.surface, app.currentWorktreeInspection(), "", 0, @@ -12603,6 +12748,7 @@ fn paintSidebarBitmap(app: *App, viewport_bottom: i32, update: bool, scroll: i32 Sidebar.draw( dc, &app.model, + app.surface, app.currentWorktreeInspection(), "", scroll, @@ -20577,7 +20723,9 @@ fn deinitOverviewTestApp(app: *App) void { app.client.deinit(); app.freeLoopProbes(); app.freeLoopHeld(); + if (app.uia_fixture_project_path.len != 0) app.allocator.free(app.uia_fixture_project_path); app.model.deinit(); + app.releaseUiaFixtureModelArena(); app.sidebar_state.deinit(); app.declared_entry_ids.deinit(); app.kept_worktree_paths.deinit(); diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 637e73e0..3c0285b7 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -395,7 +395,7 @@ pub const CardTextLayout = struct { }; pub const RenderBounds = struct { left: i32, top: i32, right: i32, bottom: i32 }; -pub const Surface = enum { project, overview, quick_chats, workspace }; +pub const Surface = Sidebar.Surface; pub const OverviewHit = struct { graph_index: usize, node_index: usize }; pub const OverviewLaneAction = enum { open_project, inspect_worktrees }; pub const overview_worktree_notice_kind = "overview-worktree-notice"; @@ -667,6 +667,7 @@ pub fn paint( Sidebar.draw( hdc, model, + surface, visible_inspection, selected_worktree_path, sidebar_scroll, diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index 398b75fd..78da44ad 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -172,6 +172,7 @@ fn toggleSet(allocator: std.mem.Allocator, set: *std.StringHashMapUnmanaged(void pub fn draw( hdc: c.HDC, model: *const GraphModel.Model, + surface: Surface, inspection: ?*const WorktreeStatus.Inspection, selected_worktree_path: []const u8, scroll_offset: i32, @@ -208,11 +209,11 @@ pub fn draw( switch (row.kind) { .local_heading => { drawDisclosureIcon(hdc, rect(18, row.top + 6, 30, row.top + 18), state.local_collapsed, 0x007A7A7A); - drawTextRect(hdc, allocator, "OPEN LOCAL", rect(36, row.top, 168, row.top + 20), 10, 0x007A7A7A, loop_text_format); + drawTextRect(hdc, allocator, row.project_name orelse "Local Projects", rect(36, row.top, 198, row.top + 20), 10, 0x007A7A7A, loop_text_format); }, .remote_heading => { drawDisclosureIcon(hdc, rect(18, row.top + 6, 30, row.top + 18), state.remote_collapsed, 0x007A7A7A); - drawTextRect(hdc, allocator, "OPEN REMOTE", rect(36, row.top, 168, row.top + 20), 10, 0x007A7A7A, loop_text_format); + drawTextRect(hdc, allocator, row.project_name orelse "Remote Repositories", rect(36, row.top, 198, row.top + 20), 10, 0x007A7A7A, loop_text_format); }, .project => { const project = model.recent_projects.items[row.index]; @@ -220,11 +221,8 @@ pub fn draw( drawText(hdc, allocator, project.name, 34, row.top, 13, 0x00E6E6E6); }, .overview => { - const selected = if (model.selected_project_path) |selected_path| - std.mem.eql(u8, selected_path, global_project_path) - else - false; - if (selected) fill(hdc, bounds, 0x003A3A44); + if (navigationRowSelected(row, surface, model.selected_project_path)) + fill(hdc, bounds, 0x003A3A44); drawOutlineIcon(hdc, .graph, rect(18, row.top + 8, 32, row.top + 22), 0x007AB8FF); drawTextRect(hdc, allocator, "Graph", rect(40, row.top + 5, 172, row.top + 27), 13, 0x00E6E6E6, loop_text_format); if (hover_y >= bounds.top and hover_y < bounds.bottom and row.has_graph) { @@ -233,10 +231,7 @@ pub fn draw( } }, .open_project => if (row.project_path) |path| { - const selected = if (model.selected_project_path) |selected_path| - std.mem.eql(u8, selected_path, path) - else - false; + const selected = navigationRowSelected(row, surface, model.selected_project_path); if (selected) fill(hdc, bounds, 0x003A3A44); const graph = graphForProject(model, path); const remote = if (graph) |value| value.project().isRemote() else std.mem.startsWith(u8, path, "ssh://") or std.mem.startsWith(u8, path, "codespace://"); @@ -288,11 +283,13 @@ 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 => { + const selected = navigationRowSelected(row, surface, model.selected_project_path); + if (selected) fill(hdc, bounds, 0x003A3A44); if (state.chats_collapsed) { drawDisclosureIcon(hdc, rect(18, row.top + 9, 30, row.top + 21), true, 0x007A7A7A); } drawOutlineIcon(hdc, .chat, rect(34, row.top + 8, 48, row.top + 22), 0x007A7A7A); - drawTextRect(hdc, allocator, "Quick Chats", rect(54, row.top + 5, 172, row.top + 27), 13, 0x00E6E6E6, loop_text_format); + drawTextRect(hdc, allocator, "Quick Chats", rect(54, row.top + 5, 172, row.top + 27), 13, if (selected) 0x00FFFFFF else 0x00E6E6E6, loop_text_format); if (hover_y >= bounds.top and hover_y < bounds.bottom) { drawPlusIcon(hdc, newChatBounds(row), 0x00B8B8B8); if (row.has_children) drawDisclosureIcon(hdc, chatsDisclosureBounds(row), state.chats_collapsed, 0x00B8B8B8); @@ -558,6 +555,7 @@ fn elapsedText(allocator: std.mem.Allocator, created_at: i64, now: i64) ![]u8 { return std.fmt.allocPrint(allocator, "{d}d", .{@divTrunc(seconds, 86400)}); } +pub const Surface = enum { project, overview, quick_chats, workspace }; pub const RowKind = enum { local_heading, remote_heading, project, open_project, overview, loop, worktree, quick_chat_overview, quick_chat }; pub const Row = struct { kind: RowKind, @@ -600,6 +598,18 @@ const navigation_row_height: i32 = 32; const section_gap: i32 = 12; const global_project_path = "graphcode://global"; +pub fn navigationRowSelected(row: Row, surface: Surface, selected_project_path: ?[]const u8) bool { + return switch (row.kind) { + .overview => surface == .overview, + .quick_chat_overview => surface == .quick_chats, + .open_project => surface == .project and + row.project_path != null and + selected_project_path != null and + std.mem.eql(u8, row.project_path.?, selected_project_path.?), + else => false, + }; +} + pub fn graphForProject(model: *const GraphModel.Model, path: []const u8) ?ProjectGraph { if (model.graphFor(path)) |graph| return .{ .summary = graph }; if (model.graph) |*graph| { @@ -713,13 +723,13 @@ pub fn appendRows( top += section_gap; if (hasOpenOwnerInSection(model, .local)) { - try rows.append(allocator, .{ .kind = .local_heading, .index = 0, .top = top, .project_name = "Open local folders" }); + try rows.append(allocator, .{ .kind = .local_heading, .index = 0, .top = top, .project_name = "Local Projects" }); top += 24; if (!local_collapsed) top = try appendOpenOwners(allocator, &rows, model, .local, top, state); top += section_gap; } if (hasOpenOwnerInSection(model, .remote)) { - try rows.append(allocator, .{ .kind = .remote_heading, .index = 0, .top = top, .project_name = "Open remote repositories" }); + try rows.append(allocator, .{ .kind = .remote_heading, .index = 0, .top = top, .project_name = "Remote Repositories" }); top += 24; if (!remote_collapsed) top = try appendOpenOwners(allocator, &rows, model, .remote, top, state); top += section_gap; @@ -2292,7 +2302,7 @@ test "sidebar draw clips rows to the content viewport and keeps status text insi var draw_allocator = std.heap.DebugAllocator(.{}){}; defer std.debug.assert(draw_allocator.deinit() == .ok); const long_status = "S" ** 120; - draw(hdc, &model, null, "", 0, long_status, viewport_bottom, "", "", &state, -1, draw_allocator.allocator()); + draw(hdc, &model, .project, null, "", 0, long_status, viewport_bottom, "", "", &state, -1, draw_allocator.allocator()); const content_bottom = contentViewportBottom(viewport_bottom, false, false); const status = statusTextRect(viewport_bottom, false, false); @@ -2339,7 +2349,7 @@ test "sidebar paints an open project whose daemon name is empty" { defer _ = c.SelectObject(hdc, previous); var draw_allocator = std.heap.DebugAllocator(.{}){}; defer std.debug.assert(draw_allocator.deinit() == .ok); - draw(hdc, &model, null, "", 0, "", 800, "", "", &state, -1, draw_allocator.allocator()); + draw(hdc, &model, .project, null, "", 0, "", 800, "", "", &state, -1, draw_allocator.allocator()); } fn manyLoopModel(allocator: std.mem.Allocator, loops: usize) !GraphModel.Model { @@ -2399,7 +2409,7 @@ test "scrolled rows are not drawn over the fixed GRAPH/Projects header" { defer _ = c.SelectObject(hdc, previous); var draw_allocator = std.heap.DebugAllocator(.{}){}; defer std.debug.assert(draw_allocator.deinit() == .ok); - draw(hdc, &model, null, "", scroll, "", viewport_bottom, "", "", &state, -1, draw_allocator.allocator()); + draw(hdc, &model, .project, null, "", scroll, "", viewport_bottom, "", "", &state, -1, draw_allocator.allocator()); // The goalBased accent bar is only ever painted by loop rows. const accent: u32 = 0x0048C78E; From 9c9cb914eaa8ef2735f827f10a229fcd22f69b1c Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 13:10:03 -0700 Subject: [PATCH 5/6] Keep sidebar project selection route-aware Match owner selection validation to the active surface: a project row is selected only on its matching project canvas, while the selected loop remains checked across routes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0db84998-f1cc-4cc7-a927-6c8912b42cdb Signed-off-by: Colin Neilens --- .../windows/Tests/ValidationRunner.Tests.ps1 | 54 ++++++++++++++++++- Tools/windows/uia-live-gate.ps1 | 13 ++++- graphcode-windows/src/App.zig | 8 +-- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 9b6a3488..34bc423a 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -1604,6 +1604,56 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate return "Actual surface predicate rejects workspace chrome under a project expectation" } $rosterOwners = @($workspaceOwner, @{ path = "B"; node = $b; name = "Beta"; title = "Beta loop" }) + Invoke-MultiCase "matching project surface selects its parent and selected loop" { + Assert-MultiCase (Test-MultiProjectOwnerSelection "project" $workspaceOwner $workspaceOwner $true $true) ` + "matching project parent/loop selection rejected" + return "Matching project surface selects its owner and selected loop" + } + Invoke-MultiCase "other project owner stays unselected on project surface" { + Assert-MultiCase (Test-MultiProjectOwnerSelection "project" $rosterOwners[1] $workspaceOwner $false $false) ` + "nonmatching project owner expected unselected" + return "Only the matching project owner and loop are selected" + } + Invoke-MultiCase "workspace keeps selected loop without parent-project selection" { + Assert-MultiCase (Test-MultiProjectOwnerSelection "workspace" $workspaceOwner $workspaceOwner $false $true) ` + "workspace parent/loop selection expectation rejected" + return "Workspace preserves selected loop without selecting its parent project" + } + Invoke-MultiCase "overview keeps selected loop without parent-project selection" { + Assert-MultiCase (Test-MultiProjectOwnerSelection "overview" $workspaceOwner $workspaceOwner $false $true) ` + "overview parent/loop selection expectation rejected" + return "Overview preserves selected loop without selecting its parent project" + } + Invoke-MultiCase "Quick Chats keeps selected loop without parent-project selection" { + Assert-MultiCase (Test-MultiProjectOwnerSelection "quick_chats" $workspaceOwner $workspaceOwner $false $true) ` + "Quick Chats parent/loop selection expectation rejected" + return "Quick Chats does not select a stale parent project" + } + Invoke-MultiCase "workspace rejects selected parent project row" -Negative { + Assert-MultiCase (-not (Test-MultiProjectOwnerSelection "workspace" $workspaceOwner $workspaceOwner $true $true)) ` + "workspace accepted selected parent project row" + return "Stale workspace parent-project selection rejected" + } + Invoke-MultiCase "project surface rejects missing selected parent row" -Negative { + Assert-MultiCase (-not (Test-MultiProjectOwnerSelection "project" $workspaceOwner $workspaceOwner $false $true)) ` + "project surface accepted missing selected parent row" + return "Missing matching project selection rejected" + } + Invoke-MultiCase "overview rejects stale selected parent project row" -Negative { + Assert-MultiCase (-not (Test-MultiProjectOwnerSelection "overview" $workspaceOwner $workspaceOwner $true $true)) ` + "overview accepted stale selected parent project row" + return "Stale overview parent-project selection rejected" + } + Invoke-MultiCase "Quick Chats rejects stale selected parent project row" -Negative { + Assert-MultiCase (-not (Test-MultiProjectOwnerSelection "quick_chats" $workspaceOwner $workspaceOwner $true $true)) ` + "Quick Chats accepted stale selected parent project row" + return "Stale Quick Chats parent-project selection rejected" + } + Invoke-MultiCase "workspace rejects loss of selected loop owner" -Negative { + Assert-MultiCase (-not (Test-MultiProjectOwnerSelection "workspace" $workspaceOwner $workspaceOwner $false $false)) ` + "workspace accepted loss of expected loop selection" + return "Missing selected loop-owner state rejected" + } function New-MultiRosterProjection { return [ordered]@{ projectRows = @( @@ -3599,8 +3649,10 @@ Start-Sleep -Seconds 60 if (-not $metadataObserver -or $metadataObserver.Extent.Text.Contains(".StartsWith(") -or -not $metadataObserver.Extent.Text.Contains("Test-MultiProjectMetadataBatch") -or -not $metadataObserver.Extent.Text.Contains("unresolvedMetadataCount") -or + -not $metadataObserver.Extent.Text.Contains("Test-MultiProjectOwnerSelection `$surface `$owner `$selectedOwner `$projectSelected `$loopSelected") -or + $metadataObserver.Extent.Text.Contains("`$projectSelected -eq `$selected") -or -not $uiaLiveGateSource.Contains("UIA_MULTIPROJECT_METADATA_REACQUIRE=")) { - throw "RED: new observer prefix/typed metadata boundary remains unguarded or silently omitted" + throw "RED: new observer prefix/typed metadata/active-surface selection boundary remains unguarded or silently omitted" } foreach ($required in @("function Complete-MultiProjectCapture", "function Wait-MultiProjectCapture", "function Initialize-MultiProjectProcessJob", "StandaloneProcessJob", "rootStartUtcTicks", "targetStartUtcTicks", diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 73c15f92..8749c169 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -3310,6 +3310,14 @@ function Get-MultiProjectExpectedCanvasRoster([string] $surface, $owners, $selec projectPath = $selectedOwner.path; nodeID = $selectedOwner.node }) } +function Test-MultiProjectOwnerSelection([string] $surface, $owner, $selectedOwner, + [bool] $projectSelected, [bool] $loopSelected) { + $selected = $false + if ($null -ne $selectedOwner) { $selected = $selectedOwner.path -ceq $owner.path } + $expectedProjectSelected = $surface -ceq "project" -and $selected + return $projectSelected -eq $expectedProjectSelected -and $loopSelected -eq $selected +} + function Test-MultiProjectObservedRoster([string] $surface, $projection, $expectedOwners, $selectedOwner, [int] $processId) { if ($surface -cnotin @("overview", "project", "workspace")) { throw "MULTIPROJECT_SURFACE: unsupported observed surface" } if ($projection -isnot [Collections.IDictionary] -or $projection.Count -ne 5 -or @@ -4649,7 +4657,6 @@ function Invoke-MultiProjectRenamePhase { $cardId = Get-MultiProjectAutomationId $kind $owner.path $owner.node $card = @($ownedGraphMetadata | Where-Object { $_.automationID.value -ceq $cardId } | ForEach-Object { $_.element }) $cardRequired = $surface -ceq "overview" -or ($null -ne $selectedOwner -and $selectedOwner.path -ceq $owner.path) - $selected = $null -ne $selectedOwner -and $selectedOwner.path -ceq $owner.path $projectSelected = $false; $loopSelected = $false if ($null -ne $project) { $projectSelected = $project.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected } if ($null -ne $loop) { $loopSelected = $loop.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected } @@ -4660,7 +4667,9 @@ 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 (Test-MultiProjectOwnerSelection $surface $owner $selectedOwner $projectSelected $loopSelected) + } } $cardEvidence = $null if ($cardRequired) { diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 94cb20ef..dd0f533a 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -19421,7 +19421,7 @@ 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, if (surface == .project) 1 else 0), F.selected_beta_projects); try std.testing.expectEqual(@as(usize, if (surface == .overview) 0 else 1), F.selected_beta_cards); } @@ -20854,7 +20854,7 @@ test "cross-project overview Open actions select exact projects and emit DPI sco else => .{ 524, 884, 1024, 1076 }, }; var overview = OverviewAccessibilitySink{ .card_count = 6, .expected = &.{ - .{ .identity = "open-project:A", .selected = true }, + .{ .identity = "open-project:A" }, .{ .identity = "open-project:B" }, .{ .identity = "overview-card:A:a1" }, .{ .identity = "overview-card:A:a2" }, @@ -20935,7 +20935,7 @@ 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 = "open-project:A" }, - .{ .identity = "open-project:B", .selected = true }, + .{ .identity = "open-project:B" }, .{ .identity = "overview-card:A:a4", .name = "Alpha refreshed", .bounds = .{ 262, 118, 512, 214 } }, .{ .identity = "overview-card:A:a1", .name = "Alpha 1", .bounds = .{ 522, 118, 772, 214 } }, .{ .identity = "overview-card:A:a2" }, @@ -21127,7 +21127,7 @@ fn exerciseOverviewWorktreesFixture() !void { defer allocator.free(other_identity); var sink = OverviewAccessibilitySink{ .card_count = 6, .expected = &.{ .{ .identity = row_identity, .name = row_name }, - .{ .identity = project_identity, .selected = true }, + .{ .identity = project_identity }, .{ .identity = other_identity }, } }; try sink.expect(&app); From ab1218078b88b017e2fe3591b39e999d6d1f6d94 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 23:15:10 -0700 Subject: [PATCH 6/6] Bind sidebar route test to Quick Chat identity Exercise Quick Chat workspace selection through the same bound identity the application uses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0db84998-f1cc-4cc7-a927-6c8912b42cdb Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index dd0f533a..ac90a930 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -11949,6 +11949,16 @@ test "parity sidebar navigation UIA matches painted owners and clipped row contr if (row.kind == .quick_chat_overview) break row; } else return error.TestUnexpectedResult; try std.testing.expect(app.model.selectProject("C:\\local")); + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const directory = try temporary.dir.realpathAlloc(app.allocator, "."); + defer app.allocator.free(directory); + const layout_path = try std.fs.path.join(app.allocator, &.{ directory, "layout.json" }); + defer app.allocator.free(layout_path); + var quick_chat_workspace = try reopenTestWorkspace(app.allocator, layout_path); + defer quick_chat_workspace.deinit(); + try quick_chat_workspace.scopeToLoop("chat", TerminalWorkspace.quick_chat_scope_project, false); + defer app.workspace = null; var sink: SidebarUiaSink = .{}; app.syncAccessibilityTo(&sink, client); const graph_bounds = try expectSidebarNavigationElement(&sink, "overview-destination:graph", "Graph", graph_row, dpi); @@ -11971,7 +11981,8 @@ test "parity sidebar navigation UIA matches painted owners and clipped row contr }; for (route_cases) |case| { app.surface = case.surface; - app.workspace_is_quick_chat = case.quick_chat_workspace; + app.workspace = if (case.quick_chat_workspace) &quick_chat_workspace else null; + try std.testing.expectEqual(case.quick_chat_workspace, app.isQuickChatWorkspace()); var route_sink: SidebarUiaSink = .{}; app.syncAccessibilityTo(&route_sink, client); try expectSidebarNavigationSelection(&route_sink, "overview-destination:graph", case.graph); @@ -11995,7 +12006,7 @@ test "parity sidebar navigation UIA matches painted owners and clipped row contr } } app.surface = .overview; - app.workspace_is_quick_chat = false; + app.workspace = null; const local_section_index = sink.indexOfName("sidebar-section:local") orelse return error.TestUnexpectedResult; const remote_section_index = sink.indexOfName("sidebar-section:remote") orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings("Local Projects", sink.label(local_section_index));