From 395b2f010eb77b20fab894b7592f57c7c0fe3401 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 14:50:42 -0700 Subject: [PATCH 1/5] Complete Windows repository and worktree parity Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45640a35-85a6-4f48-a737-4892cce9b34c --- graphcode-windows/src/App.zig | 56 +++-- graphcode-windows/src/NativeForms.zig | 119 ++++++++-- .../src/WindowsRepositoryDialogs.zig | 205 ++++++++++++++++++ graphcode-windows/src/WorktreeDialog.zig | 22 +- graphcode-windows/src/WorktreeStatus.zig | 86 +++++++- investigation/ui-parity-matrix.md | 10 +- 6 files changed, 455 insertions(+), 43 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 92af292b..2bdccbf9 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1252,12 +1252,26 @@ pub const App = struct { self.setStatus("A clone is already running"); return; } - self.clone_operation = RepositoryDialogs.CloneOperation.start(self.allocator, draft) catch { + const operation = RepositoryDialogs.CloneOperation.start(self.allocator, draft) catch { self.setIngressError("Clone could not start"); self.setStatus("Clone could not start"); return; }; - self.setStatus("Cloning repository… (Ctrl+Shift+X cancels)"); + defer operation.deinit(); + const status = RepositoryDialogs.showCloneProgress(self.window.hwnd, self.allocator, operation) catch { + operation.cancel(); + self.setIngressError("Clone progress sheet could not open"); + self.setStatus("Clone progress sheet could not open"); + return; + }; + switch (status) { + .finished => self.setStatus("Repository cloned"), + .cancelled => self.setStatus("Clone cancelled"), + else => { + self.setIngressError("Clone failed"); + self.setStatus("Clone failed"); + }, + } } fn cancelClone(self: *App) void { @@ -1440,11 +1454,15 @@ pub const App = struct { self.setStatus(@errorName(err)); return; }; - RepositoryDialogs.validateRemoteConnection(self.allocator, draft) catch |err| { - self.setIngressError(@errorName(err)); - self.setStatus(@errorName(err)); + if (!RepositoryDialogs.showRemoteValidation(self.window.hwnd, self.allocator, draft) catch { + self.setIngressError("SSH validation could not start"); + self.setStatus("SSH validation could not start"); return; - }; + }) { + self.setIngressError("SSH connection validation failed"); + self.setStatus("SSH connection validation failed"); + return; + } RepositoryDialogs.saveRemoteConfig(self.allocator, draft) catch { self.setIngressError("SSH validated but remote configuration could not be saved"); self.setStatus("SSH validated but remote configuration could not be saved"); @@ -2116,7 +2134,7 @@ pub const App = struct { var selected = std.array_list.Managed([]const u8).init(self.allocator); defer selected.deinit(); for (current_inspection.entries.items[0..@min(current_inspection.entries.items.len, result.count)], 0..) |entry, index| { - if (result.selected[index] and WorktreeStatus.decision(entry) == .reclaimable) + if (result.selected[index] and WorktreeStatus.sweepSelectable(entry)) selected.append(entry.path) catch { self.setStatus("Unable to collect Worktree Sweep selection"); return; @@ -2133,13 +2151,14 @@ pub const App = struct { }; var explicit_policy = WorktreeStatus.Policy{}; explicit_policy.applyResolveAction(.remove); - const removed = WorktreeStatus.reclaimSelectedWithPolicy( + const removed = WorktreeStatus.reclaimSelectedWithPolicyMode( self.allocator, project_path, selected.items, bindings.items, explicit_policy, - true, + result.destructive_confirmed, + result.destructive_confirmed, ) catch |err| { self.setStatus(switch (err) { error.UnsafeSelection => "Worktree Sweep blocked an unsafe selection", @@ -2513,18 +2532,15 @@ pub const App = struct { dialog.policy else WorktreeStatus.loadPolicy(self.allocator, project_path); - const policy = NativeForms.worktreePolicy(self.window.hwnd, self.allocator, initial) catch { + const policy = NativeForms.worktreePolicy(self.window.hwnd, self.allocator, project_path, initial) catch { self.setStatus("Unable to open worktree policy editor"); return; } orelse { self.setStatus("Worktree policy edit cancelled"); return; }; - self.saveWorktreePolicy(policy) catch { - self.setStatus("Unable to save project settings"); - return; - }; - self.setStatus("Project settings saved"); + if (self.worktree_dialog) |*dialog| dialog.setPolicy(policy); + self.setStatus("Project settings updated"); } fn saveCurrentWorktreePolicy(self: *App) void { @@ -3909,7 +3925,15 @@ fn onWindowMessage( c.WM_PAINT => { var paint: c.PAINTSTRUCT = undefined; const hdc = c.BeginPaint(hwnd, &paint); - const inspection = if (app.worktree_inspection) |*value| value else null; + const inspection = if (app.worktree_inspection) |*value| blk: { + const policy = WorktreeStatus.loadPolicy(app.allocator, value.project_path); + const summary = WorktreeStatus.summarize(value.entries.items); + var bytes: u64 = 0; + for (value.entries.items) |entry| bytes += entry.size_bytes; + const threshold_bytes = @as(u64, policy.notice_size_gb) * 1024 * 1024 * 1024; + if (summary.total >= policy.notice_count or bytes >= threshold_bytes) break :blk value; + break :blk null; + } else null; app.update_lock.lock(); if (app.model.currentGraph()) |graph| app.canvas.syncNodeOffsets(graph.nodes.items); const offered_version = if (app.update_state.state == .available) app.update_version else ""; diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index 2b222d91..5a38edf5 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -11,21 +11,25 @@ const DialogState = struct { closed: bool = false, scroll_offset: i32 = 0, checks: [3]c.HWND = .{ null, null, null }, - labels: [20]c.HWND = .{null} ** 20, - helps: [20]c.HWND = .{null} ** 20, - edits: [20]c.HWND = .{ null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null }, - input_kinds: [20]InputKind = .{.edit} ** 20, - choice_groups: [20]ChoiceGroup = .{.none} ** 20, - visible: [20]bool = .{false} ** 20, + labels: [256]c.HWND = .{null} ** 256, + helps: [256]c.HWND = .{null} ** 256, + edits: [256]c.HWND = .{null} ** 256, + input_kinds: [256]InputKind = .{.edit} ** 256, + choice_groups: [256]ChoiceGroup = .{.none} ** 256, + visible: [256]bool = .{false} ** 256, field_count: usize = 0, intro: c.HWND = null, validation: c.HWND = null, - values: [20][]u8 = .{ &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{} }, - initial_values: [20][]u8 = .{ &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{} }, - display_labels: [20][]u8 = .{ &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{} }, + values: [256][]u8 = .{&.{}} ** 256, + initial_values: [256][]u8 = .{&.{}} ** 256, + display_labels: [256][]u8 = .{&.{}} ** 256, + sweep_selectable: [256]bool = .{false} ** 256, + sweep_paths: [256][]const u8 = .{&.{}} ** 256, policy: WorktreeStatus.Policy = .{}, edge_endpoints: []const EdgeEndpoint = &.{}, lock_edge_endpoints: bool = true, + immediate_policy_path: []const u8 = "", + confirmation_armed: bool = false, }; const Kind = enum { node, edge, update, settings, jump, worktree_policy, worktree_sweep }; @@ -34,12 +38,14 @@ const ChoiceGroup = enum { none, loop_type, backend, model_tier, metric_directio const Choice = struct { label: []const u8, value: []const u8 }; pub const EdgeEndpoint = struct { id: []const u8, title: []const u8 }; pub const WorktreeSweepResult = struct { - selected: [20]bool = .{false} ** 20, + selected: [256]bool = .{false} ** 256, count: usize = 0, + destructive_confirmed: bool = false, }; const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeNativeForm"); const ok_id = 1; const cancel_id = 2; +const reveal_id = 3; var active_state: bool = false; var active_state_storage: DialogState = undefined; @@ -407,9 +413,15 @@ pub fn jump(parent: c.HWND, allocator: std.mem.Allocator, initial: []const u8) ! return try allocator.dupe(u8, state.values[0]); } -pub fn worktreePolicy(parent: c.HWND, allocator: std.mem.Allocator, initial: WorktreeStatus.Policy) !?WorktreeStatus.Policy { +pub fn worktreePolicy(parent: c.HWND, allocator: std.mem.Allocator, project_path: []const u8, initial: WorktreeStatus.Policy) !?WorktreeStatus.Policy { const state = try allocator.create(DialogState); - state.* = .{ .allocator = allocator, .kind = .worktree_policy, .parent = parent, .policy = initial }; + state.* = .{ + .allocator = allocator, + .kind = .worktree_policy, + .parent = parent, + .policy = initial, + .immediate_policy_path = project_path, + }; defer { freeValues(state); allocator.destroy(state); @@ -442,21 +454,45 @@ pub fn worktreeSweep( else "LOOK BEFORE REMOVING"; const branch = if (entry.branch.len != 0) entry.branch else entry.path; + const size = WorktreeStatus.sizeText(allocator, entry.size_bytes) catch allocator.dupe(u8, "size unavailable") catch &.{}; state.display_labels[index] = try std.fmt.allocPrint( allocator, - "{s}: {s} - {s}", - .{ tier, branch, WorktreeStatus.failureReasonText(entry) }, + "{s}: {s} - {s} - {s}", + .{ tier, branch, WorktreeStatus.failureReasonText(entry), size }, ); - state.values[index] = try allocator.dupe(u8, if (WorktreeStatus.decision(entry) == .reclaimable) "true" else "false"); + allocator.free(size); + state.values[index] = try allocator.dupe(u8, if (WorktreeStatus.sweepSelectable(entry) and WorktreeStatus.decision(entry) == .reclaimable) "true" else "false"); state.initial_values[index] = try allocator.dupe(u8, state.values[index]); state.input_kinds[index] = .checkbox; state.visible[index] = true; + state.sweep_selectable[index] = WorktreeStatus.sweepSelectable(entry); + state.sweep_paths[index] = entry.path; } state.field_count = count; - const title = try std.fmt.allocPrint(allocator, "Worktrees - {s}", .{project_name}); + var total_bytes: u64 = 0; + var safe_count: usize = 0; + var look_count: usize = 0; + var in_use_count: usize = 0; + for (entries[0..count]) |entry| { + total_bytes += entry.size_bytes; + if (entry.primary or entry.bound_running) { + in_use_count += 1; + } else if (WorktreeStatus.decision(entry) == .reclaimable) { + safe_count += 1; + } else { + look_count += 1; + } + } + const total_text = try WorktreeStatus.sizeText(allocator, total_bytes); + defer allocator.free(total_text); + const title = try std.fmt.allocPrint( + allocator, + "Worktrees - {s} ({d} total, {d} safe, {d} look, {d} in use, {s})", + .{ project_name, count, safe_count, look_count, in_use_count, total_text }, + ); defer allocator.free(title); if (!(try show(state, title, &.{}))) return null; - var result = WorktreeSweepResult{ .count = count }; + var result = WorktreeSweepResult{ .count = count, .destructive_confirmed = state.confirmation_armed }; for (0..count) |index| result.selected[index] = std.mem.eql(u8, state.values[index], "true"); return result; } @@ -692,6 +728,7 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) var client: c.RECT = undefined; _ = c.GetClientRect(safe_hwnd, &client); createButton(safe_hwnd, if (value.kind == .node) "Create" else if (value.kind == .worktree_policy) "Done" else if (value.kind == .worktree_sweep) "Remove Selected" else "OK", ok_id, 478, client.bottom - 38); + if (value.kind == .worktree_sweep) createButton(safe_hwnd, "Show in Explorer", reveal_id, 300, client.bottom - 38); createButton(safe_hwnd, "Cancel", cancel_id, 393, client.bottom - 38); return 0; }, @@ -749,9 +786,21 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) } if ((notification == c.EN_CHANGE or notification == c.BN_CLICKED) and command >= 9100 and command < 9120) setStaticText(value, value.validation, ""); + if (value.kind == .worktree_policy and + (notification == c.EN_CHANGE or notification == c.BN_CLICKED) and + command >= 9100 and command < 9200) + { + readPolicy(value); + persistPolicy(value); + } if (command == ok_id) { readValues(value); readPolicy(value); + if (value.kind == .worktree_sweep and hasDestructiveSelection(value) and !value.confirmation_armed) { + value.confirmation_armed = true; + setStaticText(value, value.validation, "Selected rows contain uncommitted files. Press Remove Selected again to remove anyway."); + return 0; + } if (validationReason(value)) |reason| { setStaticText(value, value.validation, reason); } else { @@ -759,6 +808,18 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) } return 0; } + if (command == reveal_id and value.kind == .worktree_sweep) { + for (0..value.field_count) |index| { + if (!std.mem.eql(u8, value.values[index], "true")) continue; + const args = WorktreeStatus.revealCommand(value.allocator, value.sweep_paths[index]) catch break; + defer value.allocator.free(args); + const wide = utf8ToWideZ(value.allocator, args) catch break; + defer value.allocator.free(wide); + _ = c.ShellExecuteW(safe_hwnd, std.unicode.utf8ToUtf16LeStringLiteral("open").ptr, wide.ptr, null, null, c.SW_SHOWNORMAL); + break; + } + return 0; + } if (command == cancel_id) { applyModalCommand(value, .cancel); return 0; @@ -893,7 +954,7 @@ fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { }, .checkbox => { _ = c.SendMessageW(input, c.BM_SETCHECK, if (std.mem.eql(u8, state.values[index], "true")) c.BST_CHECKED else c.BST_UNCHECKED, 0); - if (state.kind == .worktree_sweep and !std.mem.eql(u8, state.initial_values[index], "true")) + if (state.kind == .worktree_sweep and !state.sweep_selectable[index]) _ = c.EnableWindow(input, 0); }, else => { @@ -1142,6 +1203,28 @@ fn readPolicy(state: *DialogState) void { state.policy.notice_count = std.fmt.parseInt(u32, std.mem.trim(u8, state.values[1], " \t\r\n"), 10) catch state.policy.notice_count; } +fn persistPolicy(state: *DialogState) void { + if (state.immediate_policy_path.len == 0) return; + const size = std.fmt.parseInt(u32, std.mem.trim(u8, state.values[0], " \t\r\n"), 10) catch return; + const count = std.fmt.parseInt(u32, std.mem.trim(u8, state.values[1], " \t\r\n"), 10) catch return; + if (size == 0 or count == 0) return; + WorktreeStatus.savePolicy(state.allocator, state.immediate_policy_path, state.policy) catch { + setStaticText(state, state.validation, "Unable to save project settings"); + }; +} + +fn hasDestructiveSelection(state: *const DialogState) bool { + if (state.kind != .worktree_sweep) return false; + for (0..state.field_count) |index| { + if (!state.sweep_selectable[index] or !std.mem.eql(u8, state.values[index], "true")) continue; + if (std.mem.indexOf(u8, state.display_labels[index], "local changes") != null or + std.mem.indexOf(u8, state.display_labels[index], "untracked files") != null or + std.mem.indexOf(u8, state.display_labels[index], "merge conflicts") != null) + return true; + } + return false; +} + fn validationReason(state: *DialogState) ?[]const u8 { switch (state.kind) { .node => { diff --git a/graphcode-windows/src/WindowsRepositoryDialogs.zig b/graphcode-windows/src/WindowsRepositoryDialogs.zig index 6834c995..38f0b63b 100644 --- a/graphcode-windows/src/WindowsRepositoryDialogs.zig +++ b/graphcode-windows/src/WindowsRepositoryDialogs.zig @@ -250,6 +250,7 @@ pub const CloneOperation = struct { allocator.destroy(process); return err; }; + return operation; } @@ -282,6 +283,44 @@ pub const CloneOperation = struct { self.done.store(true, .release); return; }; + + pub const RemoteValidationStatus = enum { validating, succeeded, failed }; + + pub const RemoteValidationOperation = struct { + allocator: std.mem.Allocator, + fields: RemoteFields, + thread: std.Thread, + done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + status: RemoteValidationStatus = .validating, + + pub fn start(allocator: std.mem.Allocator, fields: RemoteFields) !*RemoteValidationOperation { + const operation = try allocator.create(RemoteValidationOperation); + errdefer allocator.destroy(operation); + operation.* = .{ .allocator = allocator, .fields = fields, .thread = undefined }; + operation.thread = try std.Thread.spawn(.{}, worker, .{operation}); + return operation; + } + + pub fn poll(self: *RemoteValidationOperation) ?RemoteValidationStatus { + if (!self.done.load(.acquire)) return null; + return self.status; + } + + pub fn deinit(self: *RemoteValidationOperation) void { + self.thread.join(); + self.allocator.destroy(self); + } + + fn worker(self: *RemoteValidationOperation) void { + validateRemoteConnection(self.allocator, self.fields) catch { + self.status = .failed; + self.done.store(true, .release); + return; + }; + self.status = .succeeded; + self.done.store(true, .release); + } + }; var stderr_thread = std.Thread.spawn(.{}, drainPipe, .{ self.process, &self.process.child.stderr.?, true, &self.stderr_done }) catch { self.process.terminate(); stdout_thread.join(); @@ -942,6 +981,172 @@ pub fn runClone(allocator: std.mem.Allocator, fields: CloneFields) !CloneStatus return process.finish(); } +const operation_dialog_class = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeRepositoryOperationDialog"); +const operation_timer_id = 7; +const operation_cancel_id = 3; + +const OperationDialogState = struct { + allocator: std.mem.Allocator, + parent: c.HWND, + clone: ?*CloneOperation = null, + remote: ?*RemoteValidationOperation = null, + label: c.HWND = null, + cancel: c.HWND = null, + closed: bool = false, + cancelled: bool = false, +}; + +var operation_dialog_active = false; +var operation_dialog_state: OperationDialogState = undefined; + +pub fn showCloneProgress(parent: c.HWND, allocator: std.mem.Allocator, operation: *CloneOperation) !CloneStatus { + operation_dialog_state = .{ .allocator = allocator, .parent = parent, .clone = operation }; + const status = try showOperationDialog("Cloning repository", "Starting clone…"); + if (status == .cancelled) return .cancelled; + return status; +} + +pub fn showRemoteValidation(parent: c.HWND, allocator: std.mem.Allocator, fields: RemoteFields) !bool { + var operation = try RemoteValidationOperation.start(allocator, fields); + defer operation.deinit(); + operation_dialog_state = .{ .allocator = allocator, .parent = parent, .remote = operation }; + return try showOperationDialog("Validating SSH connection", "Checking host, repository path, and SSH access…") == .finished; +} + +const OperationResult = enum { finished, cancelled, failed }; + +fn showOperationDialog(title: []const u8, initial: []const u8) !OperationResult { + try registerOperationDialogClass(); + operation_dialog_active = true; + operation_dialog_state.closed = false; + operation_dialog_state.cancelled = false; + const wide_title = try wideZ(operation_dialog_state.allocator, title); + defer operation_dialog_state.allocator.free(wide_title); + const hwnd = c.CreateWindowExW( + c.WS_EX_DLGMODALFRAME | c.WS_EX_CONTROLPARENT, + operation_dialog_class.ptr, + wide_title.ptr, + c.WS_OVERLAPPED | c.WS_CAPTION | c.WS_SYSMENU, + c.CW_USEDEFAULT, + c.CW_USEDEFAULT, + 560, + 180, + operation_dialog_state.parent, + null, + c.GetModuleHandleW(null), + null, + ) orelse { + operation_dialog_active = false; + return error.OperationDialogCreationFailed; + }; + operation_dialog_state.label = createOperationControl(hwnd, "STATIC", initial, 24, 28, 500, 46, 0); + operation_dialog_state.cancel = createOperationControl(hwnd, "BUTTON", "Cancel", 430, 96, 88, 30, operation_cancel_id); + _ = c.SetTimer(hwnd, operation_timer_id, 100, null); + _ = c.EnableWindow(operation_dialog_state.parent, 0); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.SetForegroundWindow(hwnd); + var message: c.MSG = undefined; + while (!operation_dialog_state.closed) { + const code = c.GetMessageW(&message, null, 0, 0); + if (code <= 0) { + operation_dialog_state.closed = true; + break; + } + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + _ = c.KillTimer(hwnd, operation_timer_id); + _ = c.DestroyWindow(hwnd); + _ = c.EnableWindow(operation_dialog_state.parent, 1); + _ = c.SetActiveWindow(operation_dialog_state.parent); + operation_dialog_active = false; + if (operation_dialog_state.cancelled) return .cancelled; + if (operation_dialog_state.clone) |operation| { + return switch (operation.poll() orelse .failed) { + .finished => .finished, + .cancelled => .cancelled, + .failed => .failed, + .ready, .cloning => .failed, + }; + } + if (operation_dialog_state.remote) |operation| { + return switch (operation.poll() orelse .failed) { + .succeeded => .finished, + else => .failed, + }; + } + return .failed; +} + +fn registerOperationDialogClass() !void { + var klass: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + klass.lpfnWndProc = @ptrCast(&operationDialogProc); + klass.hInstance = c.GetModuleHandleW(null); + klass.lpszClassName = operation_dialog_class.ptr; + klass.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); + klass.hbrBackground = c.GetSysColorBrush(c.COLOR_WINDOW); + if (c.RegisterClassW(&klass) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.OperationDialogClassRegistrationFailed; +} + +fn operationDialogProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { + if (!operation_dialog_active) return c.DefWindowProcW(hwnd, message, wparam, lparam); + switch (message) { + c.WM_TIMER => { + if (wparam != operation_timer_id) return 0; + if (operation_dialog_state.clone) |operation| { + var progress: [256]u8 = undefined; + var stderr: [256]u8 = undefined; + const snapshot = operation.snapshot(&progress, &stderr); + if (snapshot.progress_len != 0) setOperationText(operation_dialog_state.label, progress[0..snapshot.progress_len]); + if (snapshot.stderr_len != 0) setOperationText(operation_dialog_state.label, stderr[0..snapshot.stderr_len]); + if (operation.poll()) |status| if (status != .cloning) operation_dialog_state.closed = true; + } else if (operation_dialog_state.remote) |operation| { + if (operation.poll()) |status| { + if (status != .validating) operation_dialog_state.closed = true; + } + } + return 0; + }, + c.WM_COMMAND => { + if (@as(u16, @truncate(wparam)) == operation_cancel_id) { + operation_dialog_state.cancelled = true; + if (operation_dialog_state.clone) |operation| operation.cancel(); + setOperationText(operation_dialog_state.label, "Cancelling…"); + if (operation_dialog_state.remote != null) setOperationText(operation_dialog_state.label, "Waiting for SSH validation to finish…"); + return 0; + } + }, + c.WM_CLOSE => { + operation_dialog_state.cancelled = true; + if (operation_dialog_state.clone) |operation| operation.cancel(); + setOperationText(operation_dialog_state.label, "Cancelling…"); + return 0; + }, + else => {}, + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); +} + +fn createOperationControl(hwnd: c.HWND, class: []const u8, text: []const u8, x: i32, y: i32, width: i32, height: i32, id: usize) c.HWND { + const allocator = operation_dialog_state.allocator; + const wide_class = wideZ(allocator, class) catch return null; + defer allocator.free(wide_class); + const wide_text = wideZ(allocator, text) catch return null; + defer allocator.free(wide_text); + const style: c.DWORD = c.WS_CHILD | c.WS_VISIBLE | if (std.mem.eql(u8, class, "BUTTON")) c.WS_TABSTOP else 0; + const control = c.CreateWindowExW(0, wide_class.ptr, wide_text.ptr, style, x, y, width, height, hwnd, controlId(id), c.GetModuleHandleW(null), null) orelse return null; + _ = c.SendMessageW(control, c.WM_SETFONT, @intFromPtr(c.GetStockObject(c.DEFAULT_GUI_FONT)), 1); + return control; +} + +fn setOperationText(control: c.HWND, text: []const u8) void { + if (control == null) return; + const wide = wideZ(operation_dialog_state.allocator, text) catch return; + defer operation_dialog_state.allocator.free(wide); + _ = c.SetWindowTextW(control, wide.ptr); +} + fn drainPipe(process: *CloneProcess, file: *std.fs.File, is_stderr: bool, done: *std.atomic.Value(bool)) void { defer done.store(true, .release); var buffer: [4096]u8 = undefined; diff --git a/graphcode-windows/src/WorktreeDialog.zig b/graphcode-windows/src/WorktreeDialog.zig index 7096150a..da6d7395 100644 --- a/graphcode-windows/src/WorktreeDialog.zig +++ b/graphcode-windows/src/WorktreeDialog.zig @@ -43,6 +43,7 @@ pub const Dialog = struct { pub fn toggle(self: *Dialog, index: usize) bool { if (index >= self.rows.items.len) return false; + if (!WorktreeStatus.sweepSelectable(self.rows.items[index].entry)) return false; self.rows.items[index].selected = !self.rows.items[index].selected; self.confirmation_armed = false; return true; @@ -81,7 +82,7 @@ pub const Dialog = struct { if (!self.policy.allow_reclaim) return error.PolicyDisabled; if (self.selectedCount() == 0) return error.UnsafeSelection; for (self.rows.items) |row| { - if (row.selected and WorktreeStatus.decision(row.entry) != .reclaimable) + if (row.selected and !WorktreeStatus.sweepSelectable(row.entry)) return error.UnsafeSelection; } self.confirmation_armed = true; @@ -100,6 +101,13 @@ pub const Dialog = struct { for (self.rows.items) |row| if (row.selected) return WorktreeStatus.explorerArgs(row.entry.path); return error.EmptyProjectPath; } + + pub fn selectedDiscardsFiles(self: *const Dialog) bool { + for (self.rows.items) |row| { + if (row.selected and WorktreeStatus.discardsFiles(row.entry)) return true; + } + return false; + } }; test "multi-select requires explicit confirmation and fails closed" { @@ -141,3 +149,15 @@ test "confirmed multi-select is consumable exactly once" { try dialog.consumeConfirmation(); try std.testing.expectError(error.ConfirmationRequired, dialog.consumeConfirmation()); } + +test "dirty rows are selectable but require the destructive confirmation gate" { + var entries = [_]WorktreeStatus.Entry{ + .{ .path = @constCast("C:\\dirty"), .branch = @constCast("dirty"), .dirty = true }, + }; + var dialog = try Dialog.init(std.testing.allocator, "C:\\project", &entries, .{ .allow_reclaim = true }); + defer dialog.deinit(); + try std.testing.expect(dialog.toggle(0)); + try std.testing.expect(dialog.selectedDiscardsFiles()); + try dialog.armConfirmation(); + try std.testing.expect(dialog.canConfirm()); +} diff --git a/graphcode-windows/src/WorktreeStatus.zig b/graphcode-windows/src/WorktreeStatus.zig index b9290869..3de71b33 100644 --- a/graphcode-windows/src/WorktreeStatus.zig +++ b/graphcode-windows/src/WorktreeStatus.zig @@ -3,6 +3,7 @@ const std = @import("std"); pub const Entry = struct { path: []u8, branch: []u8, + size_bytes: u64 = 0, primary: bool = false, locked: bool = false, prunable: bool = false, @@ -190,6 +191,21 @@ pub fn decision(entry: Entry) ReclaimDecision { { return .keep; } + + pub fn sweepSelectable(entry: Entry) bool { + return !entry.primary and !entry.locked and !entry.bound_running; + } + + pub fn discardsFiles(entry: Entry) bool { + return !entry.prunable and (entry.dirty or entry.untracked or entry.conflicted); + } + + pub fn sizeText(allocator: std.mem.Allocator, bytes: u64) ![]u8 { + if (bytes < 1024) return std.fmt.allocPrint(allocator, "{d} B", .{bytes}); + if (bytes < 1024 * 1024) return std.fmt.allocPrint(allocator, "{d:.1} KB", .{@as(f64, @floatFromInt(bytes)) / 1024.0}); + if (bytes < 1024 * 1024 * 1024) return std.fmt.allocPrint(allocator, "{d:.1} MB", .{@as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0)}); + return std.fmt.allocPrint(allocator, "{d:.1} GB", .{@as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0 * 1024.0)}); + } return .reclaimable; } @@ -237,11 +253,24 @@ pub fn inspect( errdefer allocator.free(default_branch); for (entries.items, 0..) |*entry, index| { entry.primary = index == 0; + entry.size_bytes = directorySize(entry.path) catch 0; for (bindings) |binding| { if (std.mem.eql(u8, entry.path, binding.path)) { entry.bound_running = true; break; } + +fn directorySize(path: []const u8) !u64 { + var dir = try std.fs.cwd().openDir(path, .{ .iterate = true }); + defer dir.close(); + var walker = try dir.walk(std.heap.page_allocator); + defer walker.deinit(); + var total: u64 = 0; + while (try walker.next()) |item| { + if (item.kind == .file) total += item.stat.size; + } + return total; +} } if (entry.primary or entry.prunable) continue; const status = try runGit(allocator, &.{ @@ -307,6 +336,18 @@ pub fn reclaimSelectedWithPolicy( bindings: []const Binding, policy: Policy, confirmed: bool, +) !usize { + return reclaimSelectedWithPolicyMode(allocator, project_path, selected, bindings, policy, confirmed, false); +} + +pub fn reclaimSelectedWithPolicyMode( + allocator: std.mem.Allocator, + project_path: []const u8, + selected: []const []const u8, + bindings: []const Binding, + policy: Policy, + confirmed: bool, + allow_forced: bool, ) !usize { if (!policy.allow_reclaim) return error.PolicyDisabled; if (policy.confirm_each_reclaim and !confirmed) return error.ConfirmationRequired; @@ -316,10 +357,15 @@ pub fn reclaimSelectedWithPolicy( deinit(allocator, &inspection.entries); allocator.free(inspection.default_branch); } - try validateSelected(allocator, inspection.entries.items, selected, bindings); + try validateSelectedMode(allocator, inspection.entries.items, selected, bindings, allow_forced); var removed: usize = 0; for (selected) |path| { - _ = try runGit(allocator, &.{ "git", "-C", project_path, "worktree", "remove", path }); + const entry = selectedEntry(inspection.entries.items, path) orelse return error.UnsafeSelection; + if (allow_forced and discardsFiles(entry)) { + _ = try runGit(allocator, &.{ "git", "-C", project_path, "worktree", "remove", "--force", path }); + } else { + _ = try runGit(allocator, &.{ "git", "-C", project_path, "worktree", "remove", path }); + } removed += 1; } return removed; @@ -330,6 +376,16 @@ pub fn validateSelected( entries: []const Entry, selected: []const []const u8, bindings: []const Binding, +) !void { + return validateSelectedMode(allocator, entries, selected, bindings, false); +} + +pub fn validateSelectedMode( + allocator: std.mem.Allocator, + entries: []const Entry, + selected: []const []const u8, + bindings: []const Binding, + allow_forced: bool, ) !void { if (selected.len == 0) return error.UnsafeSelection; var seen = std.StringHashMap(void).init(allocator); @@ -341,7 +397,8 @@ pub fn validateSelected( if (std.mem.eql(u8, path, binding.path)) return error.UnsafeSelection; } const entry = selectedEntry(entries, path) orelse return error.UnsafeSelection; - if (decision(entry) != .reclaimable) return error.UnsafeSelection; + if (!sweepSelectable(entry)) return error.UnsafeSelection; + if (!allow_forced and decision(entry) != .reclaimable) return error.UnsafeSelection; } } @@ -656,3 +713,26 @@ test "selected batch validation rejects duplicate missing bound and unsafe rows std.testing.allocator, &entries, &[_][]const u8{"dirty"}, &.{}, )); } + +test "sweep selection allows human-confirmed dirty rows but rejects locked and bound rows" { + try std.testing.expect(sweepSelectable(.{ + .path = @constCast("dirty"), + .branch = @constCast("dirty"), + .dirty = true, + })); + try std.testing.expect(!sweepSelectable(.{ + .path = @constCast("locked"), + .branch = @constCast("locked"), + .locked = true, + })); + try std.testing.expect(!sweepSelectable(.{ + .path = @constCast("running"), + .branch = @constCast("running"), + .bound_running = true, + })); + try std.testing.expect(discardsFiles(.{ + .path = @constCast("dirty"), + .branch = @constCast("dirty"), + .dirty = true, + })); +} diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 0ec10af2..d377a674 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -117,8 +117,8 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| | Open Folder | Native picker from Welcome and Add Folder menu | Welcome and File menu commands use the Windows folder-only File Open dialog with filesystem/path validation. The live UIA gate invokes the empty-state action, verifies the titled native picker, and cancels it safely | Validated | -| Clone Repository sheet | Repository, location picker, derived folder, branch, depth, progress, inline failure, cancel | A purpose-built native dialog now provides HTTPS repository URL, destination browser, derived repository-folder hint, optional branch/depth, inline validation space, Clone/Cancel defaults, and standard keyboard traversal. Clone progress still appears in the application status rather than inside the sheet | Partial | -| Add Remote Repository sheet | Server/user/port/path, explanation, validation progress, inline selectable error | A purpose-built native SSH dialog now provides host, user, default port, absolute path, explanatory copy, inline validation space, Connect/Cancel defaults, and standard keyboard traversal. Connection validation remains blocking after submission | Partial | +| Clone Repository sheet | Repository, location picker, derived folder, branch, depth, progress, inline failure, cancel | Clone now runs behind a progress-capable native operation sheet with live output, cancellation, and terminal status; automated/UIA evidence is still pending | Partial | +| Add Remote Repository sheet | Server/user/port/path, explanation, validation progress, inline selectable error | SSH validation now runs on a worker while a validation sheet remains open and Connect is unavailable until completion; automated/UIA evidence and selectable inline error coverage are still pending | Partial | | Remote Connection info | Read-only selectable connection sheet | Remote project context menus expose a dedicated read-only connection-information dialog with the encoded remote project identity and management guidance. The live UIA gate opens the native sheet, verifies both pieces of content, and closes it | Validated | ## Settings and worktrees @@ -127,9 +127,9 @@ Statuses: |---|---|---|---| | Product Settings window | Backend, three permission pickers, model picker/auto toggle, activity, briefing, beta with explanatory copy | `WindowsProductSettings.zig` exposes native backend/model/Claude/Copilot/Codex selectors, routing/activity/briefing/beta controls, macOS-equivalent consequence copy, and Save/Cancel. Focused tests cover settings preservation and selector copy; `GRAPHCODE_UIA_GATE` mutation 15 opens the real window against an isolated settings file, verifies every required visible control/explanation, proves control-targeted Return saves while preserving unknown fields, and proves Escape cancels byte-for-byte | Validated | | Infrastructure diagnostics | If retained, separate advanced surface | Daemon pipe/support-directory overrides are now explicitly labeled “Advanced Connection Settings...” while the normal Settings command opens product settings | Validated | -| Project Settings sheet | Resolve policy radio rows, safety explanation, size/count thresholds, immediate save | A purpose-built native Project Settings sheet now presents Remove/Ask/Keep radio rows with consequences, the safe-tier explanation, positive GB/count notice thresholds, and Done/Cancel keyboard semantics. The expanded policy format remains backward-compatible with the two legacy booleans, saves directly on Done without a separate shortcut, and is covered by policy round-trip tests plus a real-executable capture; per-keystroke immediate persistence remains different | Partial | -| Worktree sweep sheet | Safe/look/in-use grouping, size summaries, default selections, reveal, inline destructive confirmation, recovery note | A dedicated native modal now opens from Worktrees after real inspection, labels Safe / Look Before Removing / In Use rows, preselects only fully safe rows, disables blocked rows, states the safety/reflog contract, and executes a revalidated safe batch removal. Presentation is capped at 20 rows and size totals, inline reveal, and dirty forced-removal confirmation remain incomplete | Partial | -| Worktree notice chip | Threshold-driven titlebar and lane notice | A clickable titlebar chip now reports total or reclaimable worktrees and opens the scoped inspection flow. The real populated fixture validates the titlebar notice; configured size/count thresholds and per-lane notice chips remain incomplete | Partial | +| Project Settings sheet | Resolve policy radio rows, safety explanation, size/count thresholds, immediate save | Valid threshold/radio edits now persist immediately, while empty/partial threshold input preserves the last valid persisted value; Done remains dismiss-only. Automated/UIA evidence is still pending | Partial | +| Worktree sweep sheet | Safe/look/in-use grouping, size summaries, default selections, reveal, inline destructive confirmation, recovery note | The sweep storage is expanded to 256 rows, real directory sizes and aggregate totals are shown, reveal is available in the sheet, and dirty selectable rows require a second destructive confirmation before forced removal; automated/UIA evidence is still pending | Partial | +| Worktree notice chip | Threshold-driven titlebar and lane notice | Titlebar worktree notices are now gated by configured count or aggregate-size thresholds; per-lane chips and automated/UIA boundary evidence remain pending | Partial | ## Updates and dialogs From 278f2944648379679a0be5ec9aeb00e415237047 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 15:32:29 -0700 Subject: [PATCH 2/5] Fix WorktreeStatus helper declarations Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45640a35-85a6-4f48-a737-4892cce9b34c --- graphcode-windows/src/WorktreeStatus.zig | 263 ++++++++++++----------- 1 file changed, 141 insertions(+), 122 deletions(-) diff --git a/graphcode-windows/src/WorktreeStatus.zig b/graphcode-windows/src/WorktreeStatus.zig index 3de71b33..4b714900 100644 --- a/graphcode-windows/src/WorktreeStatus.zig +++ b/graphcode-windows/src/WorktreeStatus.zig @@ -21,8 +21,16 @@ pub fn explorerParameters(allocator: std.mem.Allocator, path: []const u8) ![]u8 } pub const FailureReason = enum { - primary, locked, prunable, dirty, untracked, conflicted, - unpushed, not_landed, bound_running, safe, + primary, + locked, + prunable, + dirty, + untracked, + conflicted, + unpushed, + not_landed, + bound_running, + safe, }; pub const ResolveAction = enum { legacy, remove, ask, keep }; @@ -191,22 +199,22 @@ pub fn decision(entry: Entry) ReclaimDecision { { return .keep; } + return .reclaimable; +} - pub fn sweepSelectable(entry: Entry) bool { - return !entry.primary and !entry.locked and !entry.bound_running; - } +pub fn sweepSelectable(entry: Entry) bool { + return !entry.primary and !entry.locked and !entry.bound_running; +} - pub fn discardsFiles(entry: Entry) bool { - return !entry.prunable and (entry.dirty or entry.untracked or entry.conflicted); - } +pub fn discardsFiles(entry: Entry) bool { + return !entry.prunable and (entry.dirty or entry.untracked or entry.conflicted); +} - pub fn sizeText(allocator: std.mem.Allocator, bytes: u64) ![]u8 { - if (bytes < 1024) return std.fmt.allocPrint(allocator, "{d} B", .{bytes}); - if (bytes < 1024 * 1024) return std.fmt.allocPrint(allocator, "{d:.1} KB", .{@as(f64, @floatFromInt(bytes)) / 1024.0}); - if (bytes < 1024 * 1024 * 1024) return std.fmt.allocPrint(allocator, "{d:.1} MB", .{@as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0)}); - return std.fmt.allocPrint(allocator, "{d:.1} GB", .{@as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0 * 1024.0)}); - } - return .reclaimable; +pub fn sizeText(allocator: std.mem.Allocator, bytes: u64) ![]u8 { + if (bytes < 1024) return std.fmt.allocPrint(allocator, "{d} B", .{bytes}); + if (bytes < 1024 * 1024) return std.fmt.allocPrint(allocator, "{d:.1} KB", .{@as(f64, @floatFromInt(bytes)) / 1024.0}); + if (bytes < 1024 * 1024 * 1024) return std.fmt.allocPrint(allocator, "{d:.1} MB", .{@as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0)}); + return std.fmt.allocPrint(allocator, "{d:.1} GB", .{@as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0 * 1024.0)}); } pub fn canReclaim(entry: Entry, policy: Policy, confirmed: bool) bool { @@ -242,65 +250,65 @@ pub fn inspect( project_path: []const u8, bindings: []const Binding, ) !Inspection { - if (project_path.len == 0) return error.EmptyProjectPath; - const list = try runGit(allocator, &.{ - "git", "-C", project_path, "worktree", "list", "--porcelain", + if (project_path.len == 0) return error.EmptyProjectPath; + const list = try runGit(allocator, &.{ + "git", "-C", project_path, "worktree", "list", "--porcelain", + }); + defer allocator.free(list.output); + var entries = try parse(allocator, list.output); + errdefer deinit(allocator, &entries); + const default_branch = try discoverDefault(allocator, project_path, entries.items); + errdefer allocator.free(default_branch); + for (entries.items, 0..) |*entry, index| { + entry.primary = index == 0; + entry.size_bytes = directorySize(entry.path) catch 0; + for (bindings) |binding| { + if (std.mem.eql(u8, entry.path, binding.path)) { + entry.bound_running = true; + break; + } + } + if (entry.primary or entry.prunable) continue; + const status = try runGit(allocator, &.{ + "git", "-C", entry.path, "status", "--porcelain=v1", "--untracked-files=all", + }); + defer allocator.free(status.output); + var lines = std.mem.splitScalar(u8, status.output, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, "\r"); + if (line.len < 2) continue; + entry.dirty = true; + if (std.mem.startsWith(u8, line, "??")) entry.untracked = true; + if (line[0] == 'U' or line[1] == 'U' or + (line[0] == 'A' and line[1] == 'A') or + (line[0] == 'D' and line[1] == 'D')) entry.conflicted = true; + } + entry.pushed = succeedsGit(allocator, &.{ + "git", "-C", entry.path, "rev-parse", "--verify", "@{u}", + }) and zeroCommitsAhead(allocator, entry.path); + entry.landed = succeedsGit(allocator, &.{ + "git", "-C", project_path, "merge-base", "--is-ancestor", + entry.branch, default_branch, }); - defer allocator.free(list.output); - var entries = try parse(allocator, list.output); - errdefer deinit(allocator, &entries); - const default_branch = try discoverDefault(allocator, project_path, entries.items); - errdefer allocator.free(default_branch); - for (entries.items, 0..) |*entry, index| { - entry.primary = index == 0; - entry.size_bytes = directorySize(entry.path) catch 0; - for (bindings) |binding| { - if (std.mem.eql(u8, entry.path, binding.path)) { - entry.bound_running = true; - break; - } + } + return .{ + .entries = entries, + .default_branch = default_branch, + .project_path = try allocator.dupe(u8, project_path), + }; +} fn directorySize(path: []const u8) !u64 { - var dir = try std.fs.cwd().openDir(path, .{ .iterate = true }); - defer dir.close(); - var walker = try dir.walk(std.heap.page_allocator); - defer walker.deinit(); - var total: u64 = 0; - while (try walker.next()) |item| { - if (item.kind == .file) total += item.stat.size; - } - return total; -} - } - if (entry.primary or entry.prunable) continue; - const status = try runGit(allocator, &.{ - "git", "-C", entry.path, "status", "--porcelain=v1", "--untracked-files=all", - }); - defer allocator.free(status.output); - var lines = std.mem.splitScalar(u8, status.output, '\n'); - while (lines.next()) |raw| { - const line = std.mem.trim(u8, raw, "\r"); - if (line.len < 2) continue; - entry.dirty = true; - if (std.mem.startsWith(u8, line, "??")) entry.untracked = true; - if (line[0] == 'U' or line[1] == 'U' or - (line[0] == 'A' and line[1] == 'A') or - (line[0] == 'D' and line[1] == 'D')) entry.conflicted = true; - } - entry.pushed = succeedsGit(allocator, &.{ - "git", "-C", entry.path, "rev-parse", "--verify", "@{u}", - }) and zeroCommitsAhead(allocator, entry.path); - entry.landed = succeedsGit(allocator, &.{ - "git", "-C", project_path, "merge-base", "--is-ancestor", - entry.branch, default_branch, - }); - } - return .{ - .entries = entries, - .default_branch = default_branch, - .project_path = try allocator.dupe(u8, project_path), - }; + var dir = try std.fs.cwd().openDir(path, .{ .iterate = true }); + defer dir.close(); + var walker = try dir.walk(std.heap.page_allocator); + defer walker.deinit(); + var total: u64 = 0; + while (try walker.next()) |item| { + if (item.kind == .file) total += item.stat.size; } + return total; +} pub fn deinitInspection(allocator: std.mem.Allocator, inspection: *Inspection) void { deinit(allocator, &inspection.entries); @@ -309,16 +317,16 @@ pub fn deinitInspection(allocator: std.mem.Allocator, inspection: *Inspection) v } pub fn reclaim(allocator: std.mem.Allocator, entries: []const Entry) !usize { - var removed: usize = 0; - for (entries) |entry| { - if (decision(entry) != .reclaimable) continue; - _ = try runGit(allocator, &.{ - "git", "-C", entry.path, "worktree", "remove", entry.path, - }); - removed += 1; - } - return removed; + var removed: usize = 0; + for (entries) |entry| { + if (decision(entry) != .reclaimable) continue; + _ = try runGit(allocator, &.{ + "git", "-C", entry.path, "worktree", "remove", entry.path, + }); + removed += 1; } + return removed; +} pub fn reclaimSelected( allocator: std.mem.Allocator, @@ -405,29 +413,29 @@ pub fn validateSelectedMode( const GitResult = struct { output: []u8 }; fn succeedsGit(allocator: std.mem.Allocator, args: []const []const u8) bool { - const result = runGit(allocator, args) catch return false; - allocator.free(result.output); - return true; - } - - fn zeroCommitsAhead(allocator: std.mem.Allocator, path: []const u8) bool { - const result = runGit(allocator, &.{ "git", "-C", path, "rev-list", "--count", "@{upstream}..HEAD" }) catch return false; - defer allocator.free(result.output); - return std.mem.eql(u8, std.mem.trim(u8, result.output, " \r\n"), "0"); - } + const result = runGit(allocator, args) catch return false; + allocator.free(result.output); + return true; +} - fn landedOnDefault(allocator: std.mem.Allocator, project: []const u8, branch: []const u8, default_branch: []const u8) bool { - if (branch.len == 0 or default_branch.len == 0) return false; - const result = runGit(allocator, &.{ "git", "-C", project, "cherry", default_branch, branch }) catch return false; - defer allocator.free(result.output); - var lines = std.mem.splitScalar(u8, result.output, '\n'); - while (lines.next()) |line| { - if (std.mem.startsWith(u8, std.mem.trim(u8, line, " \r"), "+")) return false; - } +fn zeroCommitsAhead(allocator: std.mem.Allocator, path: []const u8) bool { + const result = runGit(allocator, &.{ "git", "-C", path, "rev-list", "--count", "@{upstream}..HEAD" }) catch return false; + defer allocator.free(result.output); + return std.mem.eql(u8, std.mem.trim(u8, result.output, " \r\n"), "0"); +} - return true; +fn landedOnDefault(allocator: std.mem.Allocator, project: []const u8, branch: []const u8, default_branch: []const u8) bool { + if (branch.len == 0 or default_branch.len == 0) return false; + const result = runGit(allocator, &.{ "git", "-C", project, "cherry", default_branch, branch }) catch return false; + defer allocator.free(result.output); + var lines = std.mem.splitScalar(u8, result.output, '\n'); + while (lines.next()) |line| { + if (std.mem.startsWith(u8, std.mem.trim(u8, line, " \r"), "+")) return false; } + return true; +} + fn discoverDefault(allocator: std.mem.Allocator, project: []const u8, entries: []const Entry) ![]u8 { const origin = runGit(allocator, &.{ "git", "-C", project, "symbolic-ref", "--short", "refs/remotes/origin/HEAD" }) catch null; if (origin) |result| { @@ -447,29 +455,29 @@ fn discoverDefault(allocator: std.mem.Allocator, project: []const u8, entries: [ } fn runGit(allocator: std.mem.Allocator, args: []const []const u8) !GitResult { - var child = std.process.Child.init(args, allocator); - child.stdout_behavior = .Pipe; - child.stderr_behavior = .Pipe; - try child.spawn(); - const output = try child.stdout.?.readToEndAlloc(allocator, 1024 * 1024); - const term = try child.wait(); - switch (term) { - .Exited => |code| if (code != 0) { - allocator.free(output); - return error.GitFailed; - }, - else => { - allocator.free(output); - return error.GitFailed; - }, - } - return .{ .output = output }; + var child = std.process.Child.init(args, allocator); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + try child.spawn(); + const output = try child.stdout.?.readToEndAlloc(allocator, 1024 * 1024); + const term = try child.wait(); + switch (term) { + .Exited => |code| if (code != 0) { + allocator.free(output); + return error.GitFailed; + }, + else => { + allocator.free(output); + return error.GitFailed; + }, + } + return .{ .output = output }; } pub const Action = enum { inspect, reclaim }; pub const CommandError = error{EmptyProjectPath}; -pub const ReclaimError = error{PolicyDisabled, ConfirmationRequired, UnsafeSelection}; +pub const ReclaimError = error{ PolicyDisabled, ConfirmationRequired, UnsafeSelection }; pub fn command( allocator: std.mem.Allocator, @@ -629,7 +637,7 @@ test "reclaim classification fails closed for every unsafe signal" { }; try std.testing.expectEqual(ReclaimDecision.reclaimable, decision(clean)); inline for ([_][]const u8{ - "primary", "locked", "dirty", "untracked", "conflicted", "unpushed", + "primary", "locked", "dirty", "untracked", "conflicted", "unpushed", "unlanded", "running binding", }) |label| { var candidate = clean; @@ -643,7 +651,6 @@ test "reclaim classification fails closed for every unsafe signal" { if (std.mem.eql(u8, label, "running binding")) candidate.bound_running = true; try std.testing.expectEqual(ReclaimDecision.keep, decision(candidate)); } - } test "explicit row selection is independent of graph binding safety" { @@ -701,16 +708,28 @@ test "selected batch validation rejects duplicate missing bound and unsafe rows }; try validateSelected(std.testing.allocator, &entries, &[_][]const u8{"safe"}, &.{}); try std.testing.expectError(error.UnsafeSelection, validateSelected( - std.testing.allocator, &entries, &[_][]const u8{"safe", "safe"}, &.{}, + std.testing.allocator, + &entries, + &[_][]const u8{ "safe", "safe" }, + &.{}, )); try std.testing.expectError(error.UnsafeSelection, validateSelected( - std.testing.allocator, &entries, &[_][]const u8{"missing"}, &.{}, + std.testing.allocator, + &entries, + &[_][]const u8{"missing"}, + &.{}, )); try std.testing.expectError(error.UnsafeSelection, validateSelected( - std.testing.allocator, &entries, &[_][]const u8{"safe"}, &.{.{ .path = "safe" }}, + std.testing.allocator, + &entries, + &[_][]const u8{"safe"}, + &.{.{ .path = "safe" }}, )); try std.testing.expectError(error.UnsafeSelection, validateSelected( - std.testing.allocator, &entries, &[_][]const u8{"dirty"}, &.{}, + std.testing.allocator, + &entries, + &[_][]const u8{"dirty"}, + &.{}, )); } From c162310604e7a45fb329694cd2f4c31d5d7a06ff Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 16:07:22 -0700 Subject: [PATCH 3/5] Fix NativeForms draft builder array/slice type mismatch Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45640a35-85a6-4f48-a737-4892cce9b34c --- graphcode-windows/src/NativeForms.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index 5a38edf5..be8f00c4 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -171,7 +171,7 @@ pub fn node( fn buildNodeDraft( allocator: std.mem.Allocator, - values: [20][]u8, + values: []const []u8, initial: Forms.NodeDraft, ) !Forms.NodeDraft { const goal_based = std.mem.eql(u8, values[1], "goalBased"); @@ -255,7 +255,7 @@ pub fn edgeWithEndpoints( return try buildEdgeDraft(allocator, state.values); } -fn buildEdgeDraft(allocator: std.mem.Allocator, values: [20][]u8) !Forms.EdgeDraft { +fn buildEdgeDraft(allocator: std.mem.Allocator, values: []const []u8) !Forms.EdgeDraft { const cycle_max = parseOptionalInt(values[7]) catch return error.InvalidNumericInput; const cycle_stop = parseOptionalInt(values[8]) catch return error.InvalidNumericInput; var result = Forms.EdgeDraft{ .from = &.{}, .to = &.{}, .kind = &.{}, .condition = &.{}, .transform_kind = &.{}, .transform_value = &.{}, .cycle_until = &.{}, .spawn_target_project_path = &.{} }; @@ -1420,7 +1420,7 @@ test "node draft builder preserves every hidden initial field" { .briefing_enabled = false, .activity_enabled = true, }; - var draft = try buildNodeDraft(std.testing.allocator, values, initial); + var draft = try buildNodeDraft(std.testing.allocator, &values, initial); defer draft.deinit(std.testing.allocator); try std.testing.expectEqualStrings(initial.worktree_repository, draft.worktree_repository); try std.testing.expectEqualStrings(initial.worktree_id, draft.worktree_id); @@ -1434,7 +1434,7 @@ test "node draft builder preserves every hidden initial field" { var hidden_values = values; hidden_values[8] = @constCast("not-a-number"); hidden_values[9] = @constCast("also-invalid"); - var hidden_draft = try buildNodeDraft(std.testing.allocator, hidden_values, initial); + var hidden_draft = try buildNodeDraft(std.testing.allocator, &hidden_values, initial); defer hidden_draft.deinit(std.testing.allocator); try std.testing.expectEqual(initial.poll_interval_seconds, hidden_draft.poll_interval_seconds); try std.testing.expectEqual(initial.stall_after_seconds, hidden_draft.stall_after_seconds); @@ -1474,7 +1474,7 @@ test "conditional graph fields and validation follow selected types" { edge_state.values[7] = @constCast("4"); edge_state.values[8] = @constCast("2"); edge_state.values[9] = @constCast("D:\\other-project"); - var edge_draft = try buildEdgeDraft(std.testing.allocator, edge_state.values); + var edge_draft = try buildEdgeDraft(std.testing.allocator, &edge_state.values); defer edge_draft.deinit(std.testing.allocator); try std.testing.expectEqualStrings("test -f done.flag", edge_draft.cycle_until); try std.testing.expectEqual(@as(?i64, 4), edge_draft.cycle_max_iterations); From aa151ce360823fd57d0a181c2bd9fb790a549bda Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 16:25:43 -0700 Subject: [PATCH 4/5] Fix CI regressions: App.zig status shadow and WindowsRepositoryDialogs.zig nested declarations Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45640a35-85a6-4f48-a737-4892cce9b34c --- graphcode-windows/src/App.zig | 4 +- .../src/WindowsRepositoryDialogs.zig | 79 ++++++++++--------- 2 files changed, 43 insertions(+), 40 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 2bdccbf9..5cebaa32 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1258,13 +1258,13 @@ pub const App = struct { return; }; defer operation.deinit(); - const status = RepositoryDialogs.showCloneProgress(self.window.hwnd, self.allocator, operation) catch { + const clone_status = RepositoryDialogs.showCloneProgress(self.window.hwnd, self.allocator, operation) catch { operation.cancel(); self.setIngressError("Clone progress sheet could not open"); self.setStatus("Clone progress sheet could not open"); return; }; - switch (status) { + switch (clone_status) { .finished => self.setStatus("Repository cloned"), .cancelled => self.setStatus("Clone cancelled"), else => { diff --git a/graphcode-windows/src/WindowsRepositoryDialogs.zig b/graphcode-windows/src/WindowsRepositoryDialogs.zig index 38f0b63b..116e99af 100644 --- a/graphcode-windows/src/WindowsRepositoryDialogs.zig +++ b/graphcode-windows/src/WindowsRepositoryDialogs.zig @@ -284,43 +284,6 @@ pub const CloneOperation = struct { return; }; - pub const RemoteValidationStatus = enum { validating, succeeded, failed }; - - pub const RemoteValidationOperation = struct { - allocator: std.mem.Allocator, - fields: RemoteFields, - thread: std.Thread, - done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - status: RemoteValidationStatus = .validating, - - pub fn start(allocator: std.mem.Allocator, fields: RemoteFields) !*RemoteValidationOperation { - const operation = try allocator.create(RemoteValidationOperation); - errdefer allocator.destroy(operation); - operation.* = .{ .allocator = allocator, .fields = fields, .thread = undefined }; - operation.thread = try std.Thread.spawn(.{}, worker, .{operation}); - return operation; - } - - pub fn poll(self: *RemoteValidationOperation) ?RemoteValidationStatus { - if (!self.done.load(.acquire)) return null; - return self.status; - } - - pub fn deinit(self: *RemoteValidationOperation) void { - self.thread.join(); - self.allocator.destroy(self); - } - - fn worker(self: *RemoteValidationOperation) void { - validateRemoteConnection(self.allocator, self.fields) catch { - self.status = .failed; - self.done.store(true, .release); - return; - }; - self.status = .succeeded; - self.done.store(true, .release); - } - }; var stderr_thread = std.Thread.spawn(.{}, drainPipe, .{ self.process, &self.process.child.stderr.?, true, &self.stderr_done }) catch { self.process.terminate(); stdout_thread.join(); @@ -340,6 +303,44 @@ pub const CloneOperation = struct { } }; +pub const RemoteValidationStatus = enum { validating, succeeded, failed }; + +pub const RemoteValidationOperation = struct { + allocator: std.mem.Allocator, + fields: RemoteFields, + thread: std.Thread, + done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + status: RemoteValidationStatus = .validating, + + pub fn start(allocator: std.mem.Allocator, fields: RemoteFields) !*RemoteValidationOperation { + const operation = try allocator.create(RemoteValidationOperation); + errdefer allocator.destroy(operation); + operation.* = .{ .allocator = allocator, .fields = fields, .thread = undefined }; + operation.thread = try std.Thread.spawn(.{}, worker, .{operation}); + return operation; + } + + pub fn poll(self: *RemoteValidationOperation) ?RemoteValidationStatus { + if (!self.done.load(.acquire)) return null; + return self.status; + } + + pub fn deinit(self: *RemoteValidationOperation) void { + self.thread.join(); + self.allocator.destroy(self); + } + + fn worker(self: *RemoteValidationOperation) void { + validateRemoteConnection(self.allocator, self.fields) catch { + self.status = .failed; + self.done.store(true, .release); + return; + }; + self.status = .succeeded; + self.done.store(true, .release); + } +}; + pub fn validateClone(fields: CloneFields) !void { if (std.mem.trim(u8, fields.url, " \t\r\n").len == 0) return error.MissingRepositoryURL; if (!std.mem.startsWith(u8, fields.url, "https://")) return error.HTTPSRequired; @@ -1100,7 +1101,9 @@ fn operationDialogProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: const snapshot = operation.snapshot(&progress, &stderr); if (snapshot.progress_len != 0) setOperationText(operation_dialog_state.label, progress[0..snapshot.progress_len]); if (snapshot.stderr_len != 0) setOperationText(operation_dialog_state.label, stderr[0..snapshot.stderr_len]); - if (operation.poll()) |status| if (status != .cloning) operation_dialog_state.closed = true; + if (operation.poll()) |status| { + if (status != .cloning) operation_dialog_state.closed = true; + } } else if (operation_dialog_state.remote) |operation| { if (operation.poll()) |status| { if (status != .validating) operation_dialog_state.closed = true; From 1ed41caeaae9f15dbe95701835bca8ea6bfe5752 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 18 Sep 2026 16:46:27 -0700 Subject: [PATCH 5/5] Fix additional CI regressions surfaced after prior syntax fixes - App.zig: parenthesize showRemoteValidation catch before negation (unary ! bound to the error union, not the unwrapped bool) - NativeForms.zig: pass &state.values (address-of) at the two production call sites for buildNodeDraft/buildEdgeDraft, matching the slice parameter type; fix reveal_id handler to use the existing WorktreeStatus.explorerParameters + explorer.exe lpFile/lpParameters ShellExecuteW pattern (already used by App.zig's revealProjectPath) instead of a nonexistent WorktreeStatus.revealCommand - WindowsRepositoryDialogs.zig: map showCloneProgress's OperationResult to CloneStatus explicitly; cast WS_* style flags to c.DWORD via @intCast to match the codebase convention - WorktreeStatus.zig: directorySize now reads size via item.dir.statFile(item.basename) since Walker.Entry has no .stat field on the pinned Zig toolchain Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45640a35-85a6-4f48-a737-4892cce9b34c --- graphcode-windows/src/App.zig | 4 ++-- graphcode-windows/src/NativeForms.zig | 12 ++++++------ graphcode-windows/src/WindowsRepositoryDialogs.zig | 11 ++++++++--- graphcode-windows/src/WorktreeStatus.zig | 4 +++- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 5cebaa32..f0d346f1 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1454,11 +1454,11 @@ pub const App = struct { self.setStatus(@errorName(err)); return; }; - if (!RepositoryDialogs.showRemoteValidation(self.window.hwnd, self.allocator, draft) catch { + if (!(RepositoryDialogs.showRemoteValidation(self.window.hwnd, self.allocator, draft) catch { self.setIngressError("SSH validation could not start"); self.setStatus("SSH validation could not start"); return; - }) { + })) { self.setIngressError("SSH connection validation failed"); self.setStatus("SSH connection validation failed"); return; diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index be8f00c4..27c8e69a 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -166,7 +166,7 @@ pub fn node( state.values[19] = try allocator.dupe(u8, initial.created_by); for (0..20) |index| state.initial_values[index] = try allocator.dupe(u8, state.values[index]); if (!(try show(state, "Create or edit node", &.{}))) return null; - return try buildNodeDraft(allocator, state.values, initial); + return try buildNodeDraft(allocator, &state.values, initial); } fn buildNodeDraft( @@ -252,7 +252,7 @@ pub fn edgeWithEndpoints( state.values[8] = try dupOptionalIntText(allocator, initial.cycle_stop_after_passes); state.values[9] = try allocator.dupe(u8, initial.spawn_target_project_path); if (!(try show(state, "Create or edit edge", &.{}))) return null; - return try buildEdgeDraft(allocator, state.values); + return try buildEdgeDraft(allocator, &state.values); } fn buildEdgeDraft(allocator: std.mem.Allocator, values: []const []u8) !Forms.EdgeDraft { @@ -811,11 +811,11 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) if (command == reveal_id and value.kind == .worktree_sweep) { for (0..value.field_count) |index| { if (!std.mem.eql(u8, value.values[index], "true")) continue; - const args = WorktreeStatus.revealCommand(value.allocator, value.sweep_paths[index]) catch break; - defer value.allocator.free(args); - const wide = utf8ToWideZ(value.allocator, args) catch break; + const parameters = WorktreeStatus.explorerParameters(value.allocator, value.sweep_paths[index]) catch break; + defer value.allocator.free(parameters); + const wide = utf8ToWideZ(value.allocator, parameters) catch break; defer value.allocator.free(wide); - _ = c.ShellExecuteW(safe_hwnd, std.unicode.utf8ToUtf16LeStringLiteral("open").ptr, wide.ptr, null, null, c.SW_SHOWNORMAL); + _ = c.ShellExecuteW(safe_hwnd, std.unicode.utf8ToUtf16LeStringLiteral("open").ptr, std.unicode.utf8ToUtf16LeStringLiteral("explorer.exe").ptr, wide.ptr, null, c.SW_SHOWNORMAL); break; } return 0; diff --git a/graphcode-windows/src/WindowsRepositoryDialogs.zig b/graphcode-windows/src/WindowsRepositoryDialogs.zig index 116e99af..7c24a045 100644 --- a/graphcode-windows/src/WindowsRepositoryDialogs.zig +++ b/graphcode-windows/src/WindowsRepositoryDialogs.zig @@ -1003,8 +1003,11 @@ var operation_dialog_state: OperationDialogState = undefined; pub fn showCloneProgress(parent: c.HWND, allocator: std.mem.Allocator, operation: *CloneOperation) !CloneStatus { operation_dialog_state = .{ .allocator = allocator, .parent = parent, .clone = operation }; const status = try showOperationDialog("Cloning repository", "Starting clone…"); - if (status == .cancelled) return .cancelled; - return status; + return switch (status) { + .finished => .finished, + .cancelled => .cancelled, + .failed => .failed, + }; } pub fn showRemoteValidation(parent: c.HWND, allocator: std.mem.Allocator, fields: RemoteFields) !bool { @@ -1137,7 +1140,9 @@ fn createOperationControl(hwnd: c.HWND, class: []const u8, text: []const u8, x: defer allocator.free(wide_class); const wide_text = wideZ(allocator, text) catch return null; defer allocator.free(wide_text); - const style: c.DWORD = c.WS_CHILD | c.WS_VISIBLE | if (std.mem.eql(u8, class, "BUTTON")) c.WS_TABSTOP else 0; + const style: c.DWORD = @as(c.DWORD, @intCast(c.WS_CHILD)) | + @as(c.DWORD, @intCast(c.WS_VISIBLE)) | + (if (std.mem.eql(u8, class, "BUTTON")) @as(c.DWORD, @intCast(c.WS_TABSTOP)) else 0); const control = c.CreateWindowExW(0, wide_class.ptr, wide_text.ptr, style, x, y, width, height, hwnd, controlId(id), c.GetModuleHandleW(null), null) orelse return null; _ = c.SendMessageW(control, c.WM_SETFONT, @intFromPtr(c.GetStockObject(c.DEFAULT_GUI_FONT)), 1); return control; diff --git a/graphcode-windows/src/WorktreeStatus.zig b/graphcode-windows/src/WorktreeStatus.zig index 4b714900..40b935bc 100644 --- a/graphcode-windows/src/WorktreeStatus.zig +++ b/graphcode-windows/src/WorktreeStatus.zig @@ -305,7 +305,9 @@ fn directorySize(path: []const u8) !u64 { defer walker.deinit(); var total: u64 = 0; while (try walker.next()) |item| { - if (item.kind == .file) total += item.stat.size; + if (item.kind != .file) continue; + const stat = item.dir.statFile(item.basename) catch continue; + total += stat.size; } return total; }