Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a0d74a1
Validate folder lanes, attention rail, connector handles, and zoom co…
coneilen Sep 23, 2026
5d21ac6
Maximize shell window before wide-window overview lane geometry check
coneilen Sep 24, 2026
efe8c5a
Force an explicit window rect instead of maximizing for lane geometry
coneilen Sep 24, 2026
d638173
Iteratively grow requested window rect using measured client width
coneilen Sep 24, 2026
29bedbc
Compute overview lane geometry analytically instead of forcing window…
coneilen Sep 24, 2026
fb4ec26
Widen graph-fragment retry after dynamic project/loop invocation
coneilen Sep 24, 2026
5dac8aa
Retrigger CI after apparent runner hang on windows-shell
coneilen Sep 24, 2026
3994bab
Retrigger CI to distinguish transient load from a real regression
coneilen Sep 24, 2026
8b73961
Stop swallowing ElementNotAvailableException in Get-DirectChildren
coneilen Sep 24, 2026
1aa48f8
Restore a narrow COMException retry in Get-DirectChildren
coneilen Sep 24, 2026
8deeba9
Absorb the shell UIA provider startup race once, not per call
coneilen Sep 24, 2026
efe1411
Widen and instrument the UIA provider settle wait
coneilen Sep 24, 2026
1284012
Wait for workspace card children after dynamic project/loop invocation
coneilen Sep 24, 2026
82ea96a
Instrument Get-DirectChildren's COM retry to disambiguate the next fa…
coneilen Sep 24, 2026
5f15486
Fix silently-disabled COM retry; make its budget wall-clock, not atte…
coneilen Sep 24, 2026
f4959f4
Also retry genuine ElementNotAvailableException in Get-DirectChildren
coneilen Sep 24, 2026
5c8f8d8
Re-resolve graphcode-root after modal teardown instead of retrying a …
coneilen Sep 24, 2026
d8c53e6
Wait-ForRootReconnect: verify both RawView and ControlView walkers be…
coneilen Sep 24, 2026
af4058a
Rewrite Wait-ForRootReconnect comment to observation-only; add handle…
coneilen Sep 24, 2026
0f6992e
Make handle diagnostic deterministic; add call-site and window-enumer…
coneilen Sep 24, 2026
867e958
Report shell crashes with stderr from UIA tree-walk exhaustion
coneilen Sep 24, 2026
0aac1ad
Preserve terminal cell buffers when replacing a live surface
coneilen Sep 24, 2026
f3f5288
Harden terminal replacement test cleanup
coneilen Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
739 changes: 715 additions & 24 deletions Tools/windows/uia-live-gate.ps1

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions graphcode-windows/src/Accessibility.zig
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ extern fn gc_uia_create(hwnd: c.HWND) ?*NativeProvider;
extern fn gc_uia_release(provider: *NativeProvider) void;
extern fn gc_uia_get_object(hwnd: c.HWND, wparam: c.WPARAM, lparam: c.LPARAM, provider: *NativeProvider) c.LRESULT;
extern fn gc_uia_set_status(provider: *NativeProvider, status: [*:0]const u8) c.HRESULT;
extern fn gc_uia_set_canvas_bounds(provider: *NativeProvider, left: c_int, top: c_int, right: c_int, bottom: c_int) c.HRESULT;
extern fn gc_uia_update(
provider: *NativeProvider,
status: [*:0]const u8,
Expand Down Expand Up @@ -246,6 +247,15 @@ pub const Provider = struct {
defer self.allocator.free(status_z);
_ = gc_uia_set_status(native, status_z.ptr);
}
/// Reports the real, current client-relative rect of the rendered canvas
/// so the "graph" fixed UIA element (id 4) exposes accurate
/// BoundingRectangle geometry for automation and testing, instead of a
/// disconnected placeholder rect.
pub fn syncCanvasBounds(self: *Provider, bounds: c.RECT) void {
if (!builtin.link_libc) return;
const native = self.native_provider orelse return;
_ = gc_uia_set_canvas_bounds(native, bounds.left, bounds.top, bounds.right, bounds.bottom);
}
pub fn add(self: *Provider, element: Element) !usize {
const index = self.elements.items.len;
try self.elements.append(element);
Expand Down
44 changes: 44 additions & 0 deletions graphcode-windows/src/AccessibilityProvider.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <UIAutomation.h>
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
Expand Down Expand Up @@ -43,6 +44,11 @@ struct State {
bool allow_reclaim = false;
bool confirm_each_reclaim = true;
bool active = true;
// Real, current client-relative rect of the rendered canvas ("graph" fixed
// element, id_ == 4). Populated by gc_uia_set_canvas_bounds; until the app
// reports one, get_BoundingRectangle falls back to a placeholder rect.
RECT canvas_bounds{};
bool has_canvas_bounds = false;
};

static std::wstring wide(const char *value) {
Expand Down Expand Up @@ -275,13 +281,18 @@ class Node final : public IRawElementProviderSimple,
HWND hwnd = nullptr;
RECT dynamic_bounds{};
bool has_dynamic_bounds = false;
RECT canvas_bounds{};
bool has_canvas_bounds = false;
{
std::lock_guard<std::mutex> lock(state_->mutex);
if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE;
hwnd = state_->hwnd;
if (isRowKey(id_)) {
dynamic_bounds = state_->rows.at(id_).bounds;
has_dynamic_bounds = true;
} else if (id_ == 4 && state_->has_canvas_bounds) {
canvas_bounds = state_->canvas_bounds;
has_canvas_bounds = true;
}
}
RECT rect{};
Expand All @@ -297,6 +308,14 @@ class Node final : public IRawElementProviderSimple,
value->top = origin.y + dynamic_bounds.top;
value->width = dynamic_bounds.right - dynamic_bounds.left;
value->height = dynamic_bounds.bottom - dynamic_bounds.top;
} else if (has_canvas_bounds) {
// The "graph" fixed element (id_ == 4) reflects the real, current
// rendered canvas rect once the app has reported one, instead of the
// disconnected placeholder rect used before any report arrives.
value->left = origin.x + canvas_bounds.left;
value->top = origin.y + canvas_bounds.top;
value->width = canvas_bounds.right - canvas_bounds.left;
value->height = canvas_bounds.bottom - canvas_bounds.top;
} else if (id_ == 14 || id_ == 15) {
value->left = origin.x + 8;
value->top = origin.y + (id_ == 14 ? 142 : 358);
Expand Down Expand Up @@ -629,6 +648,24 @@ class Node final : public IRawElementProviderSimple,
}
raiseStatusChanged(status_node, old_status, new_status);
}
void setCanvasBounds(int left, int top, int right, int bottom) {
Node *graph_node = nullptr;
bool changed = false;
{
std::lock_guard<std::mutex> lock(state_->mutex);
if (!state_->active) return;
const RECT next{left, top, right, bottom};
changed = !state_->has_canvas_bounds || memcmp(&state_->canvas_bounds, &next, sizeof(RECT)) != 0;
state_->canvas_bounds = next;
state_->has_canvas_bounds = true;
if (changed) graph_node = retainElementLocked(4);
}
if (graph_node) {
UiaRaiseAutomationPropertyChangedEvent(
graph_node, UIA_BoundingRectanglePropertyId, VARIANT{}, VARIANT{});
graph_node->Release();
}
}
void shutdown() {
if (id_ != 0) return;
std::vector<Node *> elements;
Expand Down Expand Up @@ -1030,3 +1067,10 @@ extern "C" HRESULT gc_uia_set_status(IRawElementProviderSimple *provider, const
static_cast<Node *>(provider)->setStatus(status);
return S_OK;
}

extern "C" HRESULT gc_uia_set_canvas_bounds(IRawElementProviderSimple *provider, int left, int top,
int right, int bottom) {
if (!provider) return E_INVALIDARG;
static_cast<Node *>(provider)->setCanvasBounds(left, top, right, bottom);
return S_OK;
}
3 changes: 3 additions & 0 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3535,6 +3535,7 @@ pub const App = struct {
},
.cycle_attention => {
self.selectNextAttention();
self.syncAccessibility();
_ = c.InvalidateRect(self.window.hwnd, null, 0);
},
.inspect_worktrees => self.inspectWorktrees(),
Expand Down Expand Up @@ -3936,6 +3937,7 @@ pub const App = struct {
.right = canvas_bounds.right,
.bottom = canvas_bounds.bottom,
};
provider.syncCanvasBounds(canvas_rect);
var sidebar_rows = Sidebar.appendRows(
self.allocator,
&self.model,
Expand Down Expand Up @@ -5608,6 +5610,7 @@ fn onWindowMessage(
app.canvas.beginPan(x, y);
_ = c.SetCapture(hwnd);
}
app.syncAccessibility();
_ = c.InvalidateRect(hwnd, null, 0);
},
.quick_chats => {
Expand Down
39 changes: 39 additions & 0 deletions graphcode-windows/src/GraphCanvas.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1889,6 +1889,45 @@ test "overview and quick chat hit testing follows rendered cards" {
try std.testing.expect(hitTestQuickChat(3, bounds.left - 1, chat.top, &state, bounds) == null);
}

test "cross-project overview stacks every open folder as its own lane" {
const allocator = std.testing.allocator;
var model = GraphModel.Model.init(allocator);
defer model.deinit();
const frameA =
\\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"A","name":"Alpha"},"nodes":[{"id":"a1","title":"Loop A1","state":"running"},{"id":"a2","title":"Loop A2","state":"running"}],"edges":[]}}}
;
const frameB =
\\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"B","name":"Beta"},"nodes":[{"id":"b1","title":"Loop B1","state":"running"}],"edges":[]}}}
;
_ = try model.updateFromFrame(frameA);
_ = try model.updateFromFrame(frameB);
try std.testing.expectEqual(@as(usize, 2), model.graphs.items.len);
var state = CanvasState{};
const bounds = rect(Tokens.sidebar_width, Tokens.header_height, 1200, 800);
const laneA = overviewLaneBounds(&model, 0, bounds, &state);
const laneB = overviewLaneBounds(&model, 1, bounds, &state);
// Every open folder renders as its own lane on the shared canvas: the second
// project's lane must start strictly below the first project's lane (not
// overlap it), by at least that lane's rendered height plus the inter-lane gap.
try std.testing.expect(laneB.top >= laneA.bottom + 20);
try std.testing.expectEqual(laneA.left, laneB.left);
try std.testing.expectEqual(laneA.right, laneB.right);
// Hit testing must resolve a click in the second lane to the second project's
// graph index, proving the lanes are independently addressable, not just
// visually stacked.
const cardB = overviewCardBounds(&model, 1, 0, bounds, &state);
const hit = hitTestOverview(&model, cardB.left + 4, cardB.top + 4, &state, bounds) orelse
return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(usize, 1), hit.graph_index);
try std.testing.expectEqual(@as(usize, 0), hit.node_index);
// Each lane exposes its own Open/Worktrees action targets, independently
// positioned per-lane rather than a single shared control.
const laneAOpen = overviewLaneActionAt(&model, 0, laneA.right - 100, laneA.top + 20, bounds, &state);
const laneBOpen = overviewLaneActionAt(&model, 1, laneB.right - 100, laneB.top + 20, bounds, &state);
try std.testing.expectEqual(OverviewLaneAction.open_project, laneAOpen orelse return error.TestUnexpectedResult);
try std.testing.expectEqual(OverviewLaneAction.open_project, laneBOpen orelse return error.TestUnexpectedResult);
}

test "overview and quick chat geometry applies pan and zoom consistently" {
var model = GraphModel.Model.init(std.testing.allocator);
defer model.deinit();
Expand Down
63 changes: 61 additions & 2 deletions graphcode-windows/src/TerminalSurface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,21 @@ pub fn surfaceIdentityMatches(surface: *const Surface, project_path: []const u8,
std.mem.eql(u8, surface.session_name, session);
}

fn moveReplacementSurface(
surfaces: *[max_surfaces]Surface,
target_index: usize,
replacement_index: usize,
) void {
std.debug.assert(target_index < surfaces.len);
std.debug.assert(replacement_index < surfaces.len);
std.debug.assert(target_index != replacement_index);
std.debug.assert(surfaces[target_index].surface == null);
std.debug.assert(surfaces[target_index].attach == null);
std.debug.assert(surfaces[replacement_index].surface != null or
surfaces[replacement_index].attach != null);
std.mem.swap(Surface, &surfaces[target_index], &surfaces[replacement_index]);
}

pub const Workspace = struct {
parent: c.HWND,
host: ?*c.winghostty_host = null,
Expand Down Expand Up @@ -401,8 +416,7 @@ pub const Workspace = struct {
return err;
};
self.destroySurface(index);
self.surfaces[index] = self.surfaces[replacement_index];
self.surfaces[replacement_index] = .{};
moveReplacementSurface(&self.surfaces, index, replacement_index);
self.syncTopology();
self.clearRecreateSession(index);
return;
Expand Down Expand Up @@ -1790,6 +1804,51 @@ test "surface identity cannot leak a session across project paths" {
try std.testing.expect(!surfaceIdentityMatches(&second, "C:\\work\\first", "node-1"));
}

test "replacement preserves both surface cell buffers for donor reuse" {
var surfaces = [_]Surface{.{}} ** max_surfaces;
defer for (&surfaces) |*surface| {
if (surface.session_name.len != 0) std.testing.allocator.free(surface.session_name);
if (surface.cells.len != 0) std.testing.allocator.free(surface.cells);
};
surfaces[0].cells = try std.testing.allocator.alloc(c.winghostty_terminal_cell, cell_count);
surfaces[1].cells = try std.testing.allocator.alloc(c.winghostty_terminal_cell, cell_count);

const target_cells = surfaces[0].cells.ptr;
const replacement_cells = surfaces[1].cells.ptr;
const live_surface: *c.winghostty_surface = @ptrFromInt(1);
surfaces[1].surface = live_surface;
surfaces[1].session_name = try std.testing.allocator.dupe(u8, "replacement");

moveReplacementSurface(&surfaces, 0, 1);

try std.testing.expectEqual(live_surface, surfaces[0].surface.?);
try std.testing.expectEqualStrings("replacement", surfaces[0].session_name);
try std.testing.expectEqual(cell_count, surfaces[0].cells.len);
try std.testing.expectEqual(replacement_cells, surfaces[0].cells.ptr);
try std.testing.expectEqual(cell_count, surfaces[1].cells.len);
try std.testing.expectEqual(target_cells, surfaces[1].cells.ptr);
try std.testing.expect(surfaces[0].cells.ptr != surfaces[1].cells.ptr);

feedCells(&surfaces[1], "A");
try std.testing.expectEqual(@as(u32, 'A'), surfaces[1].cells[0].codepoint);

surfaces[0].surface = null;
std.testing.allocator.free(surfaces[0].session_name);
surfaces[0].session_name = &.{};
const second_surface: *c.winghostty_surface = @ptrFromInt(2);
surfaces[1].surface = second_surface;
surfaces[1].session_name = try std.testing.allocator.dupe(u8, "second replacement");

moveReplacementSurface(&surfaces, 0, 1);

try std.testing.expectEqual(second_surface, surfaces[0].surface.?);
try std.testing.expectEqualStrings("second replacement", surfaces[0].session_name);
try std.testing.expectEqual(target_cells, surfaces[0].cells.ptr);
try std.testing.expectEqual(replacement_cells, surfaces[1].cells.ptr);
feedCells(&surfaces[1], "B");
try std.testing.expectEqual(@as(u32, 'B'), surfaces[1].cells[0].codepoint);
}

fn minimalWorkspaceForOptionsTest(allocator: std.mem.Allocator) !Workspace {
return Workspace{
.parent = null,
Expand Down
Loading
Loading