From 25c6263be27d0124f7da8778f58e9e4d3588b38b Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 22 Sep 2026 09:46:42 -0700 Subject: [PATCH 1/3] Add GitHub Codespaces ingress to the Windows shell The Windows shell could open a local folder, clone over HTTPS, and attach an SSH remote, but a codespace - the source the macOS welcome flow has always offered - had nowhere to go. This adds the fourth ingress with the same shape as the macOS sheet: discover, choose, validate, then open. Codespaces.zig is the client half and is pure logic, so it is testable without a window: it locates gh.exe, asks it for the account's codespaces as JSON, classifies the failures gh actually returns (missing CLI, unauthenticated, missing codespace scope, no network) into remediations a human can act on, redacts any token-shaped run out of gh output before it can reach a status line, and builds both the validation argv and the codespace:// project URI. Validation dials `gh codespace ssh` with BatchMode so it can never block on a prompt, and runs `git rev-parse` in the chosen path so an accepted identity is one whose sessions can actually start. WindowsCodespaceDialog.zig is the sheet. Its state lives in a Model that is exercised directly, so loading, empty, failure/retry, in-flight validation, cancel-while-dialing, and submit gating are all covered without driving Win32. The window itself is standard controls with dark WM_CTLCOLOR* painting, a STATIC label before each control so UIA names them, and IsDialogMessageW for tab and escape - no custom provider, so the existing accessibility harness sees a real control tree. No daemon protocol change was needed. RemoteProjectLocation already parses codespace:// and already dials through gh, so an accepted codespace opens via the same openProject call as every other source; GhLocator only lacked Windows paths for gh.exe, which is the one portable addition here. The recent-folders submenu is now located rather than indexed, because adding this menu item is exactly what would have silently retargeted that rebuild. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- GraphcodeKit/Sources/Domain/GhLocator.swift | 35 +- Tools/windows/Tests/WindowsShell.Tests.ps1 | 43 + graphcode-windows/README.md | 10 + graphcode-windows/src/App.zig | 37 + graphcode-windows/src/Codespaces.zig | 873 ++++++++++++++++++ graphcode-windows/src/GraphModel.zig | 21 +- graphcode-windows/src/InputRouter.zig | 5 + graphcode-windows/src/MainWindow.zig | 18 +- .../src/WindowsCodespaceDialog.zig | 824 +++++++++++++++++ .../WindowsCodespaceIngressTests.swift | 61 ++ 10 files changed, 1918 insertions(+), 9 deletions(-) create mode 100644 graphcode-windows/src/Codespaces.zig create mode 100644 graphcode-windows/src/WindowsCodespaceDialog.zig create mode 100644 windows-tests/WindowsCodespaceIngressTests.swift diff --git a/GraphcodeKit/Sources/Domain/GhLocator.swift b/GraphcodeKit/Sources/Domain/GhLocator.swift index 5973828f..a3afbd5a 100644 --- a/GraphcodeKit/Sources/Domain/GhLocator.swift +++ b/GraphcodeKit/Sources/Domain/GhLocator.swift @@ -7,18 +7,41 @@ import Foundation /// searches `PATH` anyway, so an absolute path is required wherever the invocation is /// exec'd directly. public enum GhLocator { - static let candidates = [ - "/opt/homebrew/bin/gh", - "/usr/local/bin/gh", - "/usr/bin/gh", - ] + #if os(Windows) + /// Windows has no launchd-minimal `PATH` problem, but `Process` still refuses to + /// search `PATH`, so the same absolute-path rule applies. These are where the + /// supported installers actually land `gh.exe`: the MSI in Program Files, and + /// WinGet's per-user shim and package root. + static var candidates: [String] { + var paths: [String] = [] + let environment = ProcessInfo.processInfo.environment + for variable in ["ProgramFiles", "ProgramFiles(x86)"] { + if let root = environment[variable], !root.isEmpty { + paths.append("\(root)\\GitHub CLI\\gh.exe") + } + } + if let localAppData = environment["LOCALAPPDATA"], !localAppData.isEmpty { + paths.append("\(localAppData)\\Microsoft\\WinGet\\Links\\gh.exe") + paths.append("\(localAppData)\\Programs\\GitHub CLI\\gh.exe") + } + paths.append("C:\\Program Files\\GitHub CLI\\gh.exe") + return paths + } + #else + static let candidates = [ + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", + "/usr/bin/gh", + ] + #endif /// The first installed candidate. Falls back to the Homebrew path when none is /// found, so an invocation built while `gh` is missing still names the place it /// would be — the error then reads "no such file" at a path worth installing to, /// and `isInstalled` is the up-front check the add-codespace flow uses. public static var executablePath: String { - candidates.first { FileManager.default.isExecutableFile(atPath: $0) } ?? candidates[0] + let candidates = Self.candidates + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } ?? candidates[0] } public static var isInstalled: Bool { diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 334eb542..eee96a8d 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -196,6 +196,8 @@ foreach ($path in @( "src\Accessibility.zig", "src\DesignTokens.zig", "src\Wire.zig", + "src\Codespaces.zig", + "src\WindowsCodespaceDialog.zig", "src\FrameBuffer.zig", "..\Tools\windows\Stub-Daemon.ps1", "fixtures\daemon-v2-hello.json", @@ -290,11 +292,52 @@ $appSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\App.zig") -Raw Assert-Contract ($appSource -match "GraphCanvas\.paint[\s\S]+workspace\.paintChrome\(hdc\)") ` "WM_PAINT must render both the GraphCode canvas and terminal workspace chrome" +$codespaceDialogSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\WindowsCodespaceDialog.zig") -Raw +$codespaceClientSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\Codespaces.zig") -Raw +$graphModelSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\GraphModel.zig") -Raw + +Assert-Contract ($mainWindowSource -match 'Add Codespace\.\.\.\\tCtrl\+Shift\+K') ` + "the Add Folder menu must offer codespace ingress next to the other repository sources" +Assert-Contract ($mainWindowSource -notmatch 'GetSubMenu\(add_folder, \d+\)') ` + "the recent folders submenu must be located, not indexed, so new ingress entries cannot retarget it" +Assert-Contract ($appSource -match 'codespace_repository => self\.addCodespaceRepository\(\)') ` + "the codespace command must reach the codespace ingress path" +Assert-Contract ($appSource -match 'Codespaces\.projectURI[\s\S]{0,400}sendOpenProject\(project_path\)') ` + "an accepted codespace must open as a codespace:// project through the existing daemon openProject call" +Assert-Contract ($graphModelSource -match 'startsWith\(u8, self\.path, "codespace://"\)') ` + "codespace projects must group with remote projects rather than as local filesystem paths" +Assert-Contract ($codespaceClientSource -match 'BatchMode=yes') ` + "codespace validation must never wait on an interactive ssh prompt" +Assert-Contract ($codespaceClientSource -match 'github_pat_' -and $codespaceClientSource -match 'fn sanitizeMessage') ` + "surfaced gh output must be redacted before it can reach a status line or log" +Assert-Contract ($codespaceClientSource -match 'gh auth refresh -h github\.com -s codespace') ` + "a missing codespace scope must tell the human the exact command that fixes it" +Assert-Contract ($codespaceDialogSource -match 'WM_CTLCOLORLISTBOX' -and $codespaceDialogSource -match 'WM_CTLCOLOREDIT') ` + "the codespace sheet must paint its list and fields dark like the rest of the shell" +Assert-Contract ($codespaceDialogSource -match 'IsDialogMessageW') ` + "the codespace sheet must remain keyboard navigable" + $zig = Resolve-TestZig Invoke-Native "Wire executable tests" { Push-Location $shellRoot try { & $zig test src\Wire.zig } finally { Pop-Location } } +Invoke-Native "Codespace client executable tests" { + Push-Location $shellRoot + try { & $zig test src\Codespaces.zig } finally { Pop-Location } +} +Invoke-Native "Codespace ingress dialog executable tests" { + $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent + $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") + if (-not $winghosttyRoot) { + $winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration" + } + $include = Join-Path $winghosttyRoot "include" + Push-Location $shellRoot + try { + & $zig test src\WindowsCodespaceDialog.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 "-I$include" + } finally { Pop-Location } +} Invoke-Native "Forms and navigation executable tests" { Push-Location $shellRoot try { & $zig test src\Forms.zig } finally { Pop-Location } diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 24dfbfa7..338dcad5 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -28,6 +28,16 @@ actions use the Windows `IFileOpenDialog` folder picker. The no-project state also presents accessible native buttons for opening a folder or the global overview; recent projects remain selectable in the sidebar. +Repository ingress covers four sources: a local folder, an HTTPS clone, an SSH +remote (`Ctrl+Shift+R`), and a GitHub Codespace (`Ctrl+Shift+K`). The codespace +sheet asks the GitHub CLI for the account's codespaces, validates the chosen +workspace path by dialing through `gh codespace ssh` before it closes, and then +opens the result as a `codespace://` project through the same daemon +`openProject` call every other source uses. It needs `gh` on the machine, an +authenticated account, and the `codespace` token scope — without the scope, +discovery reports the exact `gh auth refresh -h github.com -s codespace` command +that grants it. + Parity actions are reachable without App-specific view coupling: `Ctrl+P` opens the searchable jump/palette form, `Ctrl+Up`/`Ctrl+Down` navigate by stable project/node identity, `Ctrl+Tab` advances attention, and `Ctrl+Shift+R`, diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index aa660bb9..5614f784 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -24,6 +24,8 @@ const Tray = TrayModule.Tray; const DaemonSupervisor = @import("DaemonSupervisor.zig").Supervisor; const ProductSettings = @import("WindowsProductSettings.zig"); const RepositoryDialogs = @import("WindowsRepositoryDialogs.zig"); +const CodespaceDialog = @import("WindowsCodespaceDialog.zig"); +const Codespaces = @import("Codespaces.zig"); const Onboarding = @import("WindowsOnboarding.zig"); const WindowsUpdates = @import("WindowsUpdates.zig"); const UpdateOfferDialog = @import("UpdateOfferDialog.zig"); @@ -1608,6 +1610,39 @@ pub const App = struct { self.setStatus("SSH repository connected; reconnect requested"); } + fn addCodespaceRepository(self: *App) void { + self.clearIngressError(); + var paths = std.array_list.Managed([]const u8).init(self.allocator); + defer paths.deinit(); + for (self.model.graphs.items) |graph| { + if (graph.project.isLocalFilesystem()) { + paths.append(graph.project.path) catch break; + } + } + var accepted = CodespaceDialog.open(self.window.hwnd, self.allocator, paths.items) catch { + self.setIngressError("Unable to open the codespace dialog"); + self.setStatus("Unable to open the codespace dialog"); + return; + } orelse return; + defer accepted.deinit(self.allocator); + + const fields = Codespaces.Fields{ .name = accepted.name, .path = accepted.path }; + Codespaces.saveConfig(self.allocator, fields) catch { + self.setIngressError("Codespace validated but its configuration could not be saved"); + self.setStatus("Codespace validated but its configuration could not be saved"); + return; + }; + const project_path = Codespaces.projectURI(self.allocator, fields) catch { + self.setIngressError("Unable to encode the codespace repository"); + self.setStatus("Unable to encode the codespace repository"); + return; + }; + defer self.allocator.free(project_path); + _ = self.client.sendOpenProject(project_path); + self.client.reconnect(); + self.setStatus("Codespace connected; reconnect requested"); + } + fn jumpToNode(self: *App) void { if (self.model.graphs.items.len == 0) { self.setStatus("No graph is open"); @@ -2984,6 +3019,7 @@ pub const App = struct { .clone_repository => self.cloneRepository(), .cancel_clone => self.cancelClone(), .remote_repository => self.addRemoteRepository(), + .codespace_repository => self.addCodespaceRepository(), .onboarding => { const initial_backend = if (self.product_settings) |settings| settings.default_backend else "claudeCode"; const backend = Onboarding.show(self.window.hwnd, self.allocator, initial_backend) catch { @@ -4257,6 +4293,7 @@ fn onWindowMessage( .open_folder => app.openFolder(), .clone_repository => app.handleAction(.clone_repository), .remote_repository => app.handleAction(.remote_repository), + .codespace_repository => app.handleAction(.codespace_repository), .new_quick_chat => app.handleAction(.quick_chat), .open_global_overview => app.openGlobalOverview(), .worktrees => app.handleAction(.inspect_worktrees), diff --git a/graphcode-windows/src/Codespaces.zig b/graphcode-windows/src/Codespaces.zig new file mode 100644 index 00000000..7094cb0d --- /dev/null +++ b/graphcode-windows/src/Codespaces.zig @@ -0,0 +1,873 @@ +//! GitHub Codespaces ingress for the Windows shell — the parity twin of the macOS +//! `CodespaceClient`/`CodespaceFormView` pair. +//! +//! Everything rides the GitHub CLI rather than the REST API directly: `gh` owns the +//! credential store, the `codespace` scope check (whose error text carries its own +//! fix), and the SSH tunnel every later dial uses. Nothing in this module reads, +//! stores, or logs a token — the only thing persisted is the codespace name and the +//! repository path inside it, next to the SSH remote's own config. +//! +//! The project identity a validated codespace produces is `codespace://`, +//! the same URI GraphcodeKit's `RemoteProjectLocation` parses, so the daemon needs no +//! new operation: `openProject` with that path is the whole protocol surface. + +const std = @import("std"); + +/// One codespace as `gh codespace list --json` reports it — exactly the fields the +/// picker shows, so there is no scraping to drift. +pub const Codespace = struct { + name: []u8, + display_name: []u8, + repository: []u8, + state: []u8, + + pub fn deinit(self: *Codespace, allocator: std.mem.Allocator) void { + allocator.free(self.name); + allocator.free(self.display_name); + allocator.free(self.repository); + allocator.free(self.state); + self.* = undefined; + } + + /// What the picker row reads: the human's label, then the repository and gh's own + /// state word so a stopped codespace is still recognisable (connecting starts it). + pub fn rowLabel(self: Codespace, allocator: std.mem.Allocator) ![]u8 { + const title = if (self.display_name.len != 0) self.display_name else self.name; + return std.fmt.allocPrint(allocator, "{s} — {s} · {s}", .{ title, self.repository, self.state }); + } + + /// Where a codespace puts its clone by default. Prefill only — the dialog's path + /// field stays editable for devcontainers that mount elsewhere. + pub fn defaultWorkspacePath(self: Codespace, allocator: std.mem.Allocator) ![]u8 { + return defaultWorkspacePathFor(allocator, self.repository); + } +}; + +pub fn defaultWorkspacePathFor(allocator: std.mem.Allocator, repository: []const u8) ![]u8 { + const leaf = if (std.mem.lastIndexOfScalar(u8, repository, '/')) |slash| + repository[slash + 1 ..] + else + repository; + if (leaf.len == 0) return allocator.dupe(u8, "/workspaces"); + return std.fmt.allocPrint(allocator, "/workspaces/{s}", .{leaf}); +} + +/// An owned list, so the dialog can hold gh's answer across message-loop turns. +pub const CodespaceList = struct { + allocator: std.mem.Allocator, + items: []Codespace, + + pub fn deinit(self: *CodespaceList) void { + for (self.items) |*item| item.deinit(self.allocator); + self.allocator.free(self.items); + self.items = &.{}; + } +}; + +/// Why discovery couldn't answer. Each one has a single message whose remediation is +/// the actual next command — the missing-scope case is the one humans hit most, and +/// gh's own fix line is reproduced rather than paraphrased. +pub const Failure = enum { + gh_missing, + not_authenticated, + missing_codespace_scope, + network_unavailable, + unreadable_list, + list_failed, +}; + +pub fn failureMessage(failure: Failure) []const u8 { + return switch (failure) { + .gh_missing => "The GitHub CLI isn't installed — codespaces are reached through it. " ++ + "Install it with \"winget install GitHub.cli\", run \"gh auth login\", and try again.", + .not_authenticated => "The GitHub CLI isn't signed in. Run \"gh auth login\" and try again.", + .missing_codespace_scope => "Your GitHub CLI token is missing the codespace scope. " ++ + "Run \"gh auth refresh -h github.com -s codespace\" and try again.", + .network_unavailable => "GitHub could not be reached. Check the network connection and try again.", + .unreadable_list => "The codespace list from the GitHub CLI could not be read.", + .list_failed => "The GitHub CLI could not list your codespaces.", + }; +} + +/// gh's exit text, mapped to the failure that names its fix. Matching is on the +/// stable operator-facing phrases gh prints, lowercased so a capitalisation change +/// doesn't silently downgrade a scope problem to a generic failure. +pub fn classifyFailure(allocator: std.mem.Allocator, stderr: []const u8) Failure { + const lowered = std.ascii.allocLowerString(allocator, stderr) catch return .list_failed; + defer allocator.free(lowered); + if (std.mem.indexOf(u8, lowered, "\"codespace\" scope") != null or + std.mem.indexOf(u8, lowered, "'codespace' scope") != null or + std.mem.indexOf(u8, lowered, "gh auth refresh") != null) + return .missing_codespace_scope; + if (std.mem.indexOf(u8, lowered, "gh auth login") != null or + std.mem.indexOf(u8, lowered, "not logged in") != null or + std.mem.indexOf(u8, lowered, "authentication token") != null) + return .not_authenticated; + if (std.mem.indexOf(u8, lowered, "dial tcp") != null or + std.mem.indexOf(u8, lowered, "no such host") != null or + std.mem.indexOf(u8, lowered, "connection refused") != null or + std.mem.indexOf(u8, lowered, "i/o timeout") != null or + std.mem.indexOf(u8, lowered, "network is unreachable") != null) + return .network_unavailable; + return .list_failed; +} + +/// gh's own words, made safe to display: credential-shaped runs are removed before +/// the text can reach a label, a log, or a screenshot, control characters collapse to +/// spaces, and the result is bounded so a runaway stream can't own the dialog. +pub fn sanitizeMessage(allocator: std.mem.Allocator, raw: []const u8) ![]u8 { + var safe = std.array_list.Managed(u8).init(allocator); + defer safe.deinit(); + var index: usize = 0; + while (index < raw.len) { + if (secretRunLength(raw[index..])) |length| { + try safe.appendSlice(""); + index += length; + continue; + } + const byte = raw[index]; + index += 1; + if (byte < 0x20 or byte == 0x7f) { + if (safe.items.len != 0 and safe.items[safe.items.len - 1] != ' ') try safe.append(' '); + continue; + } + try safe.append(byte); + } + const trimmed = std.mem.trim(u8, safe.items, " "); + const bounded = trimmed[0..@min(trimmed.len, message_limit)]; + return allocator.dupe(u8, bounded); +} + +const message_limit = 400; + +/// The credential shapes GitHub issues. Matching the prefix and consuming the whole +/// token run keeps a partial redaction from leaving a usable tail behind. +const secret_prefixes = [_][]const u8{ "gho_", "ghp_", "ghu_", "ghs_", "ghr_", "github_pat_" }; + +fn secretRunLength(text: []const u8) ?usize { + for (secret_prefixes) |prefix| { + if (!std.mem.startsWith(u8, text, prefix)) continue; + var length = prefix.len; + while (length < text.len and (std.ascii.isAlphanumeric(text[length]) or text[length] == '_')) : (length += 1) {} + if (length > prefix.len) return length; + } + return null; +} + +/// What the add-codespace dialog is collecting: one pick, and the repository path +/// inside it. +pub const Fields = struct { + name: []const u8 = "", + path: []const u8 = "", +}; + +pub fn validate(fields: Fields) !void { + if (fields.name.len == 0) return error.MissingCodespaceName; + if (fields.path.len == 0) return error.MissingCodespacePath; + if (!std.mem.startsWith(u8, fields.path, "/")) return error.AbsolutePathRequired; + if (std.mem.startsWith(u8, fields.name, "-") or std.mem.startsWith(u8, fields.path, "-")) + return error.InvalidCodespaceName; + // The name reaches `gh -c ` as an argument, so it passes the same door check + // a hostname does: anything outside gh's own name alphabet is rejected rather than + // quoted, because a quoted `--flag` is still a flag to some argument parsers. + for (fields.name) |byte| { + if (!std.ascii.isAlphanumeric(byte) and std.mem.indexOfScalar(u8, "-_.", byte) == null) + return error.InvalidCodespaceName; + } + if (std.mem.indexOfAny(u8, fields.path, "\x00\r\n") != null) return error.InvalidCodespacePath; +} + +pub fn validationMessage(err: anyerror) []const u8 { + return switch (err) { + error.MissingCodespaceName => "Choose a codespace from the list.", + error.MissingCodespacePath => "Enter the repository path inside the codespace.", + error.AbsolutePathRequired => "Repository path must be absolute and begin with /.", + error.InvalidCodespaceName => "That codespace name contains an invalid value.", + error.InvalidCodespacePath => "Repository path contains an invalid value.", + else => "Check the codespace details and try again.", + }; +} + +/// `codespace://` — the identity a validated codespace travels as, and +/// exactly what `RemoteProjectLocation.parse` reads back. +pub fn projectURI(allocator: std.mem.Allocator, fields: Fields) ![]u8 { + try validate(fields); + var encoded = std.array_list.Managed(u8).init(allocator); + defer encoded.deinit(); + try encoded.appendSlice("codespace://"); + try encoded.appendSlice(fields.name); + for (fields.path) |byte| try appendURIByte(&encoded, byte, byte != '/'); + return encoded.toOwnedSlice(); +} + +fn appendURIByte(list: *std.array_list.Managed(u8), byte: u8, encode: bool) !void { + const safe = std.ascii.isAlphanumeric(byte) or std.mem.indexOfScalar(u8, "-._~", byte) != null; + if (safe or (!encode and byte == '/')) return list.append(byte); + const hex = "0123456789ABCDEF"; + try list.append('%'); + try list.append(hex[byte >> 4]); + try list.append(hex[byte & 15]); +} + +/// POSIX single-quote escaping — the remote side of a codespace dial is a Linux login +/// shell no matter what the local machine is. +pub fn shellQuote(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var size: usize = 2; + for (value) |byte| size += if (byte == '\'') 4 else 1; + var result = try allocator.alloc(u8, size); + var index: usize = 0; + result[index] = '\''; + index += 1; + for (value) |byte| { + if (byte == '\'') { + @memcpy(result[index .. index + 4], "'\\''"); + index += 4; + } else { + result[index] = byte; + index += 1; + } + } + result[index] = '\''; + return result; +} + +/// `gh codespace list` with an explicit limit: gh's default is 30 and the truncation +/// is silent, so a 31st codespace would simply never appear in the picker. +pub fn listArgs(allocator: std.mem.Allocator, gh_path: []const u8) ![][]u8 { + var args = std.array_list.Managed([]u8).init(allocator); + errdefer { + for (args.items) |arg| allocator.free(arg); + args.deinit(); + } + try args.append(try allocator.dupe(u8, gh_path)); + try args.append(try allocator.dupe(u8, "codespace")); + try args.append(try allocator.dupe(u8, "list")); + try args.append(try allocator.dupe(u8, "--limit")); + try args.append(try allocator.dupe(u8, "500")); + try args.append(try allocator.dupe(u8, "--json")); + try args.append(try allocator.dupe(u8, "name,displayName,repository,state")); + return args.toOwnedSlice(); +} + +/// The validation dial, and the shape every later session dial takes: everything +/// after `--` reaches gh's underlying ssh untouched, so the keepalive posture is the +/// same one the SSH remote form uses. `BatchMode=yes` because nothing here has a tty +/// to answer a prompt — gh's own auth or fail fast. +pub fn sshValidationArgs(allocator: std.mem.Allocator, gh_path: []const u8, fields: Fields) ![][]u8 { + try validate(fields); + var args = std.array_list.Managed([]u8).init(allocator); + errdefer { + for (args.items) |arg| allocator.free(arg); + args.deinit(); + } + try args.append(try allocator.dupe(u8, gh_path)); + try args.append(try allocator.dupe(u8, "codespace")); + try args.append(try allocator.dupe(u8, "ssh")); + try args.append(try allocator.dupe(u8, "-c")); + try args.append(try allocator.dupe(u8, fields.name)); + try args.append(try allocator.dupe(u8, "--")); + try args.append(try allocator.dupe(u8, "-o")); + try args.append(try allocator.dupe(u8, "BatchMode=yes")); + try args.append(try allocator.dupe(u8, "-o")); + try args.append(try allocator.dupe(u8, "ConnectTimeout=10")); + try args.append(try allocator.dupe(u8, "-o")); + try args.append(try allocator.dupe(u8, "ServerAliveInterval=5")); + try args.append(try allocator.dupe(u8, "-o")); + try args.append(try allocator.dupe(u8, "ServerAliveCountMax=3")); + const quoted_path = try shellQuote(allocator, fields.path); + defer allocator.free(quoted_path); + try args.append(try std.fmt.allocPrint(allocator, "git -C {s} rev-parse --show-toplevel", .{quoted_path})); + return args.toOwnedSlice(); +} + +pub fn freeArgs(allocator: std.mem.Allocator, args: [][]u8) void { + for (args) |arg| allocator.free(arg); + allocator.free(args); +} + +/// `owner/repo` from any of the ways a GitHub origin is written, and `null` for an +/// origin that isn't github.com — a repository no codespace link can be made for, +/// which is not an error. Anchored to the start: a bare substring match would take +/// `notgithub.com` too. +pub fn githubRepository(allocator: std.mem.Allocator, origin: []const u8) !?[]u8 { + const trimmed = std.mem.trim(u8, origin, " \t\r\n"); + const lowered = try std.ascii.allocLowerString(allocator, trimmed); + defer allocator.free(lowered); + const prefixes = [_][]const u8{ + "https://github.com/", "http://github.com/", + "ssh://git@github.com/", "git://github.com/", + "git@github.com:", + }; + const prefix = for (prefixes) |candidate| { + if (std.mem.startsWith(u8, lowered, candidate)) break candidate; + } else return null; + var slug = trimmed[prefix.len..]; + if (std.mem.endsWith(u8, slug, ".git")) slug = slug[0 .. slug.len - 4]; + slug = std.mem.trimRight(u8, slug, "/"); + const slash = std.mem.indexOfScalar(u8, slug, '/') orelse return null; + const owner = slug[0..slash]; + const name = slug[slash + 1 ..]; + if (!isSafeSlugComponent(owner) or !isSafeSlugComponent(name)) return null; + return try allocator.dupe(u8, slug); +} + +fn isSafeSlugComponent(value: []const u8) bool { + if (value.len == 0) return false; + if (value[0] == '-' or value[0] == '.') return false; + for (value) |byte| { + if (!std.ascii.isAlphanumeric(byte) and std.mem.indexOfScalar(u8, "-._", byte) == null) return false; + } + return true; +} + +/// The `owner/repo` behind each open local project, deduplicated and order-preserving +/// — what the empty state's "create one" links point at. +pub fn repositorySuggestions( + allocator: std.mem.Allocator, + project_paths: []const []const u8, +) ![][]u8 { + var found = std.array_list.Managed([]u8).init(allocator); + errdefer { + for (found.items) |item| allocator.free(item); + found.deinit(); + } + for (project_paths) |path| { + if (path.len == 0 or std.mem.indexOf(u8, path, "://") != null) continue; + const origin = originURL(allocator, path) catch continue; + defer allocator.free(origin); + const repository = githubRepository(allocator, origin) catch continue orelse continue; + var duplicate = false; + for (found.items) |item| { + if (std.mem.eql(u8, item, repository)) duplicate = true; + } + if (duplicate) { + allocator.free(repository); + continue; + } + try found.append(repository); + } + return found.toOwnedSlice(); +} + +fn originURL(allocator: std.mem.Allocator, project_path: []const u8) ![]u8 { + const argv = [_][]const u8{ "git", "-C", project_path, "remote", "get-url", "origin" }; + var captured = try capture(allocator, &argv); + defer captured.deinit(allocator); + if (captured.status != 0) return error.NoOrigin; + return allocator.dupe(u8, std.mem.trim(u8, captured.stdout, " \t\r\n")); +} + +/// The GitHub create page for a repository, or the generic picker when there is no +/// repository to be specific about. +pub const create_url = "https://github.com/codespaces/new"; + +pub fn createURLFor(allocator: std.mem.Allocator, repository: []const u8) ![]u8 { + if (repository.len == 0) return allocator.dupe(u8, create_url); + return std.fmt.allocPrint(allocator, "https://codespaces.new/{s}", .{repository}); +} + +/// Where `gh.exe` is on Windows. `Child` never searches `PATH` for us in every spawn +/// posture, and the shell may run from a launcher with a trimmed environment, so an +/// absolute path is resolved once and reused. `GRAPHCODE_GH` is the explicit override +/// tests and unusual installs use. +pub fn locateGh(allocator: std.mem.Allocator) ![]u8 { + if (std.process.getEnvVarOwned(allocator, "GRAPHCODE_GH")) |override| { + if (override.len != 0 and isExecutableFile(override)) return override; + allocator.free(override); + } else |_| {} + + for ([_]struct { env: []const u8, suffix: []const u8 }{ + .{ .env = "ProgramFiles", .suffix = "GitHub CLI\\gh.exe" }, + .{ .env = "ProgramFiles(x86)", .suffix = "GitHub CLI\\gh.exe" }, + .{ .env = "LOCALAPPDATA", .suffix = "Programs\\GitHub CLI\\gh.exe" }, + .{ .env = "LOCALAPPDATA", .suffix = "Microsoft\\WinGet\\Links\\gh.exe" }, + }) |candidate| { + const base = std.process.getEnvVarOwned(allocator, candidate.env) catch continue; + defer allocator.free(base); + const path = std.fs.path.join(allocator, &.{ base, candidate.suffix }) catch continue; + if (isExecutableFile(path)) return path; + allocator.free(path); + } + + if (searchPath(allocator)) |path| return path; + return error.GhNotInstalled; +} + +fn searchPath(allocator: std.mem.Allocator) ?[]u8 { + const path_value = std.process.getEnvVarOwned(allocator, "PATH") catch return null; + defer allocator.free(path_value); + var entries = std.mem.splitScalar(u8, path_value, ';'); + while (entries.next()) |entry| { + const directory = std.mem.trim(u8, entry, " \""); + if (directory.len == 0) continue; + const candidate = std.fs.path.join(allocator, &.{ directory, "gh.exe" }) catch continue; + if (isExecutableFile(candidate)) return candidate; + allocator.free(candidate); + } + return null; +} + +fn isExecutableFile(path: []const u8) bool { + var file = std.fs.cwd().openFile(path, .{}) catch return false; + defer file.close(); + const stat = file.stat() catch return false; + return stat.kind == .file; +} + +/// gh's `--json` answer, decoded into owned rows. Rows missing the fields the picker +/// needs are dropped rather than failing the whole list: one odd codespace must not +/// hide the rest. +pub fn parseList(allocator: std.mem.Allocator, json: []const u8) !CodespaceList { + var parsed = std.json.parseFromSlice(std.json.Value, allocator, json, .{}) catch + return error.UnreadableCodespaceList; + defer parsed.deinit(); + const array = switch (parsed.value) { + .array => |value| value, + .null => return CodespaceList{ .allocator = allocator, .items = &.{} }, + else => return error.UnreadableCodespaceList, + }; + var items = std.array_list.Managed(Codespace).init(allocator); + errdefer { + for (items.items) |*item| item.deinit(allocator); + items.deinit(); + } + for (array.items) |entry| { + const object = switch (entry) { + .object => |value| value, + else => continue, + }; + const name = stringField(object, "name") orelse continue; + if (name.len == 0) continue; + const repository = stringField(object, "repository") orelse ""; + const display_name = stringField(object, "displayName") orelse ""; + const state = stringField(object, "state") orelse ""; + var row = Codespace{ + .name = try allocator.dupe(u8, name), + .display_name = try allocator.dupe(u8, display_name), + .repository = try allocator.dupe(u8, repository), + .state = try allocator.dupe(u8, if (state.len != 0) state else "Unknown"), + }; + errdefer row.deinit(allocator); + try items.append(row); + } + return CodespaceList{ .allocator = allocator, .items = try items.toOwnedSlice() }; +} + +fn stringField(object: std.json.ObjectMap, key: []const u8) ?[]const u8 { + const value = object.get(key) orelse return null; + return switch (value) { + .string => |text| text, + else => null, + }; +} + +pub const Captured = struct { + status: u8, + stdout: []u8, + stderr: []u8, + + pub fn deinit(self: *Captured, allocator: std.mem.Allocator) void { + allocator.free(self.stdout); + allocator.free(self.stderr); + self.* = undefined; + } +}; + +const capture_limit = 1024 * 1024; + +/// Both pipes drained concurrently, then the exit awaited: a full pipe cannot wedge +/// the child, which is how a long `gh codespace list` deadlocked when only one was +/// read. +pub fn capture(allocator: std.mem.Allocator, argv: []const []const u8) !Captured { + var child = std.process.Child.init(argv, allocator); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + try child.spawn(); + var out_buffer = std.array_list.Managed(u8).init(allocator); + errdefer out_buffer.deinit(); + var err_buffer = std.array_list.Managed(u8).init(allocator); + errdefer err_buffer.deinit(); + var out_thread = try std.Thread.spawn(.{}, drain, .{ &child.stdout.?, &out_buffer }); + var err_thread = try std.Thread.spawn(.{}, drain, .{ &child.stderr.?, &err_buffer }); + out_thread.join(); + err_thread.join(); + const status: u8 = switch (try child.wait()) { + .Exited => |code| code, + else => 255, + }; + return .{ + .status = status, + .stdout = try out_buffer.toOwnedSlice(), + .stderr = try err_buffer.toOwnedSlice(), + }; +} + +fn drain(file: *std.fs.File, sink: *std.array_list.Managed(u8)) void { + var buffer: [4096]u8 = undefined; + while (true) { + const count = file.read(&buffer) catch return; + if (count == 0) return; + if (sink.items.len >= capture_limit) continue; + sink.appendSlice(buffer[0..count]) catch return; + } +} + +/// Discovery. The gh-missing case is answered before any spawn so the dialog can say +/// what to install rather than surfacing a "file not found" from the process layer. +pub fn listCodespaces(allocator: std.mem.Allocator) !CodespaceList { + const gh = locateGh(allocator) catch return error.GhNotInstalled; + defer allocator.free(gh); + const args = try listArgs(allocator, gh); + defer freeArgs(allocator, args); + var captured = capture(allocator, args) catch return error.CodespaceListFailed; + defer captured.deinit(allocator); + if (captured.status != 0) { + return switch (classifyFailure(allocator, captured.stderr)) { + .missing_codespace_scope => error.MissingCodespaceScope, + .not_authenticated => error.GhNotAuthenticated, + .network_unavailable => error.GitHubUnreachable, + else => error.CodespaceListFailed, + }; + } + return parseList(allocator, captured.stdout) catch error.UnreadableCodespaceList; +} + +pub fn listFailure(err: anyerror) Failure { + return switch (err) { + error.GhNotInstalled => .gh_missing, + error.GhNotAuthenticated => .not_authenticated, + error.MissingCodespaceScope => .missing_codespace_scope, + error.GitHubUnreachable => .network_unavailable, + error.UnreadableCodespaceList => .unreadable_list, + else => .list_failed, + }; +} + +/// Validation is the remote form's: a codespace that passes is one whose sessions can +/// start. A stopped codespace is started by this dial, which is why it is allowed to +/// take longer than an ordinary SSH check. +pub fn validateConnection(allocator: std.mem.Allocator, fields: Fields) !void { + try validate(fields); + const gh = locateGh(allocator) catch return error.GhNotInstalled; + defer allocator.free(gh); + const args = try sshValidationArgs(allocator, gh, fields); + defer freeArgs(allocator, args); + var captured = capture(allocator, args) catch return error.CodespaceValidationFailed; + defer captured.deinit(allocator); + if (captured.status != 0) return error.CodespaceValidationFailed; +} + +/// The codespace's identity, stored beside the SSH remote's own record. Name and path +/// only: gh keeps the credential, and nothing token-shaped is ever written here. +pub fn saveConfig(allocator: std.mem.Allocator, fields: Fields) !void { + try validate(fields); + const base = std.process.getEnvVarOwned(allocator, "LOCALAPPDATA") catch + try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(base); + const dir = try std.fs.path.join(allocator, &.{ base, "GraphCode" }); + defer allocator.free(dir); + try std.fs.cwd().makePath(dir); + const path = try std.fs.path.join(allocator, &.{ dir, "codespace.ini" }); + defer allocator.free(path); + var file = try std.fs.cwd().createFile(path, .{ .truncate = true }); + defer file.close(); + const data = try std.fmt.allocPrint(allocator, "name={s}\npath={s}\n", .{ fields.name, fields.path }); + defer allocator.free(data); + try file.writeAll(data); +} + +/// Discovery on its own thread, polled by the dialog's timer: the message loop stays +/// responsive, so Cancel and Try Again are live while gh is still thinking. +pub const ListStatus = enum { loading, loaded, failed }; + +pub const ListOperation = struct { + allocator: std.mem.Allocator, + thread: std.Thread, + done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + abandoned: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + status: ListStatus = .loading, + list: ?CodespaceList = null, + failure: Failure = .list_failed, + + pub fn start(allocator: std.mem.Allocator) !*ListOperation { + const operation = try allocator.create(ListOperation); + errdefer allocator.destroy(operation); + operation.* = .{ .allocator = allocator, .thread = undefined }; + operation.thread = try std.Thread.spawn(.{}, worker, .{operation}); + return operation; + } + + pub fn poll(self: *ListOperation) ?ListStatus { + if (!self.done.load(.acquire)) return null; + return self.status; + } + + /// The list the caller now owns; the operation keeps none of it. + pub fn takeList(self: *ListOperation) ?CodespaceList { + const list = self.list orelse return null; + self.list = null; + return list; + } + + pub fn deinit(self: *ListOperation) void { + self.thread.join(); + if (self.list) |*list| list.deinit(); + self.allocator.destroy(self); + } + + fn worker(self: *ListOperation) void { + if (listCodespaces(self.allocator)) |list| { + self.list = list; + self.status = .loaded; + } else |err| { + self.failure = listFailure(err); + self.status = .failed; + } + self.done.store(true, .release); + } +}; + +pub const ValidationStatus = enum { validating, succeeded, failed }; + +pub const ValidationOperation = struct { + allocator: std.mem.Allocator, + name: []u8, + path: []u8, + thread: std.Thread, + done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + status: ValidationStatus = .validating, + + pub fn start(allocator: std.mem.Allocator, fields: Fields) !*ValidationOperation { + try validate(fields); + const operation = try allocator.create(ValidationOperation); + errdefer allocator.destroy(operation); + const name = try allocator.dupe(u8, fields.name); + errdefer allocator.free(name); + const path = try allocator.dupe(u8, fields.path); + errdefer allocator.free(path); + operation.* = .{ .allocator = allocator, .name = name, .path = path, .thread = undefined }; + operation.thread = try std.Thread.spawn(.{}, worker, .{operation}); + return operation; + } + + pub fn poll(self: *ValidationOperation) ?ValidationStatus { + if (!self.done.load(.acquire)) return null; + return self.status; + } + + pub fn deinit(self: *ValidationOperation) void { + self.thread.join(); + self.allocator.free(self.name); + self.allocator.free(self.path); + self.allocator.destroy(self); + } + + fn worker(self: *ValidationOperation) void { + validateConnection(self.allocator, .{ .name = self.name, .path = self.path }) catch { + self.status = .failed; + self.done.store(true, .release); + return; + }; + self.status = .succeeded; + self.done.store(true, .release); + } +}; + +test "codespace list decodes gh json and keeps gh's own state words" { + const json = + \\[{"name":"dev-widget-x5jq4w","displayName":"widget dev","repository":"octo/widget","state":"Available"}, + \\ {"name":"dev-widget-stopped","displayName":"","repository":"octo/widget","state":"Shutdown"}] + ; + var list = try parseList(std.testing.allocator, json); + defer list.deinit(); + try std.testing.expectEqual(@as(usize, 2), list.items.len); + try std.testing.expectEqualStrings("dev-widget-x5jq4w", list.items[0].name); + try std.testing.expectEqualStrings("Shutdown", list.items[1].state); + + const label = try list.items[0].rowLabel(std.testing.allocator); + defer std.testing.allocator.free(label); + try std.testing.expectEqualStrings("widget dev — octo/widget · Available", label); + + const fallback = try list.items[1].rowLabel(std.testing.allocator); + defer std.testing.allocator.free(fallback); + try std.testing.expectEqualStrings("dev-widget-stopped — octo/widget · Shutdown", fallback); +} + +test "codespace list tolerates an empty answer and rejects a non-list one" { + var empty = try parseList(std.testing.allocator, "[]"); + defer empty.deinit(); + try std.testing.expectEqual(@as(usize, 0), empty.items.len); + + var partial = try parseList(std.testing.allocator, "[{\"repository\":\"octo/widget\"},{\"name\":\"kept\"}]"); + defer partial.deinit(); + try std.testing.expectEqual(@as(usize, 1), partial.items.len); + try std.testing.expectEqualStrings("kept", partial.items[0].name); + try std.testing.expectEqualStrings("Unknown", partial.items[0].state); + + try std.testing.expectError(error.UnreadableCodespaceList, parseList(std.testing.allocator, "not json")); +} + +test "default workspace path prefills the repository leaf" { + const path = try defaultWorkspacePathFor(std.testing.allocator, "octo/widget"); + defer std.testing.allocator.free(path); + try std.testing.expectEqualStrings("/workspaces/widget", path); + + const bare = try defaultWorkspacePathFor(std.testing.allocator, "widget"); + defer std.testing.allocator.free(bare); + try std.testing.expectEqualStrings("/workspaces/widget", bare); +} + +test "gh scope failure carries its own remediation" { + const scope_error = + "error getting codespaces: HTTP 403: Must have admin rights to Repository.\n" ++ + "This API operation needs the \"codespace\" scope. To request it, run: " ++ + "gh auth refresh -h github.com -s codespace\n"; + try std.testing.expectEqual( + Failure.missing_codespace_scope, + classifyFailure(std.testing.allocator, scope_error), + ); + try std.testing.expect(std.mem.indexOf( + u8, + failureMessage(.missing_codespace_scope), + "gh auth refresh -h github.com -s codespace", + ) != null); + try std.testing.expectEqual( + Failure.not_authenticated, + classifyFailure(std.testing.allocator, "To get started with GitHub CLI, please run: gh auth login"), + ); + try std.testing.expectEqual( + Failure.network_unavailable, + classifyFailure(std.testing.allocator, "Get \"https://api.github.com\": dial tcp: lookup api.github.com"), + ); + try std.testing.expectEqual( + Failure.list_failed, + classifyFailure(std.testing.allocator, "something else entirely"), + ); + try std.testing.expectEqual(Failure.gh_missing, listFailure(error.GhNotInstalled)); + try std.testing.expectEqual(Failure.missing_codespace_scope, listFailure(error.MissingCodespaceScope)); +} + +test "surfaced gh output never carries a credential or control characters" { + const raw = "denied for gho_0123456789abcdefABCDEF token\r\nand github_pat_11ABCDE_secretpart too"; + const safe = try sanitizeMessage(std.testing.allocator, raw); + defer std.testing.allocator.free(safe); + try std.testing.expect(std.mem.indexOf(u8, safe, "gho_") == null); + try std.testing.expect(std.mem.indexOf(u8, safe, "github_pat_") == null); + try std.testing.expect(std.mem.indexOf(u8, safe, "secretpart") == null); + try std.testing.expect(std.mem.indexOf(u8, safe, "") != null); + try std.testing.expect(std.mem.indexOfAny(u8, safe, "\r\n") == null); + + var long = std.array_list.Managed(u8).init(std.testing.allocator); + defer long.deinit(); + try long.appendNTimes('x', 900); + const bounded = try sanitizeMessage(std.testing.allocator, long.items); + defer std.testing.allocator.free(bounded); + try std.testing.expectEqual(@as(usize, message_limit), bounded.len); +} + +test "codespace identities reject injection-shaped names and relative paths" { + try std.testing.expectError(error.InvalidCodespaceName, validate(.{ .name = "-oProxyCommand=x", .path = "/workspaces/widget" })); + try std.testing.expectError(error.InvalidCodespaceName, validate(.{ .name = "dev widget", .path = "/workspaces/widget" })); + try std.testing.expectError(error.AbsolutePathRequired, validate(.{ .name = "dev-widget", .path = "workspaces/widget" })); + try std.testing.expectError(error.InvalidCodespacePath, validate(.{ .name = "dev-widget", .path = "/workspaces/wid\nget" })); + try std.testing.expectError(error.MissingCodespaceName, validate(.{ .name = "", .path = "/workspaces/widget" })); + try validate(.{ .name = "dev-widget-x5jq4w", .path = "/workspaces/widget" }); +} + +test "codespace project URI percent-encodes the path and keeps the name verbatim" { + const uri = try projectURI(std.testing.allocator, .{ + .name = "dev-widget-x5jq4w", + .path = "/workspaces/repo name/#q?x%雪", + }); + defer std.testing.allocator.free(uri); + try std.testing.expectEqualStrings( + "codespace://dev-widget-x5jq4w/workspaces/repo%20name/%23q%3Fx%25%E9%9B%AA", + uri, + ); +} + +test "codespace validation argv dials through gh with one quoted remote command" { + const args = try sshValidationArgs(std.testing.allocator, "C:\\gh\\gh.exe", .{ + .name = "dev-widget-x5jq4w", + .path = "/workspaces/граф", + }); + defer freeArgs(std.testing.allocator, args); + try std.testing.expectEqualStrings("C:\\gh\\gh.exe", args[0]); + try std.testing.expectEqualStrings("codespace", args[1]); + try std.testing.expectEqualStrings("ssh", args[2]); + try std.testing.expectEqualStrings("-c", args[3]); + try std.testing.expectEqualStrings("dev-widget-x5jq4w", args[4]); + try std.testing.expectEqualStrings("--", args[5]); + try std.testing.expectEqualStrings("BatchMode=yes", args[7]); + try std.testing.expectEqualStrings( + "git -C '/workspaces/граф' rev-parse --show-toplevel", + args[args.len - 1], + ); +} + +test "codespace validation argv quotes shell metacharacters in the path" { + const args = try sshValidationArgs(std.testing.allocator, "gh.exe", .{ + .name = "dev-widget", + .path = "/workspaces/a;$(touch p)'q", + }); + defer freeArgs(std.testing.allocator, args); + try std.testing.expectEqualStrings( + "git -C '/workspaces/a;$(touch p)'\\''q' rev-parse --show-toplevel", + args[args.len - 1], + ); +} + +test "codespace list argv asks gh for every codespace and only the picker's fields" { + const args = try listArgs(std.testing.allocator, "gh.exe"); + defer freeArgs(std.testing.allocator, args); + try std.testing.expectEqualStrings("--limit", args[3]); + try std.testing.expectEqualStrings("500", args[4]); + try std.testing.expectEqualStrings("name,displayName,repository,state", args[6]); +} + +test "github origins resolve to owner/repo and non-GitHub origins do not" { + for ([_][]const u8{ + "https://github.com/octo/widget.git", + "git@github.com:octo/widget.git", + "ssh://git@github.com/octo/widget", + "HTTPS://GitHub.com/octo/widget/", + }) |origin| { + const repository = (try githubRepository(std.testing.allocator, origin)) orelse + return error.TestUnexpectedResult; + defer std.testing.allocator.free(repository); + try std.testing.expectEqualStrings("octo/widget", repository); + } + try std.testing.expectEqual( + @as(?[]u8, null), + try githubRepository(std.testing.allocator, "https://notgithub.com/octo/widget.git"), + ); + try std.testing.expectEqual( + @as(?[]u8, null), + try githubRepository(std.testing.allocator, "https://github.com/octo"), + ); + try std.testing.expectEqual( + @as(?[]u8, null), + try githubRepository(std.testing.allocator, "https://github.com/-octo/wid;get"), + ); +} + +test "create links prefer the repository and fall back to the GitHub picker" { + const specific = try createURLFor(std.testing.allocator, "octo/widget"); + defer std.testing.allocator.free(specific); + try std.testing.expectEqualStrings("https://codespaces.new/octo/widget", specific); + + const generic = try createURLFor(std.testing.allocator, ""); + defer std.testing.allocator.free(generic); + try std.testing.expectEqualStrings("https://github.com/codespaces/new", generic); +} + +test "validation messages name the field a human has to change" { + try std.testing.expectEqualStrings( + "Choose a codespace from the list.", + validationMessage(error.MissingCodespaceName), + ); + try std.testing.expectEqualStrings( + "Repository path must be absolute and begin with /.", + validationMessage(error.AbsolutePathRequired), + ); +} diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index 3d90b12e..1f6cb592 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -61,8 +61,17 @@ pub const Project = struct { path: []u8, name: []u8, + /// Both remote schemes count: a Codespace is `codespace://` rather + /// than `ssh://`, because gh's tunnel — not a host ssh can dial — owns the + /// connection. Everything above the dial treats the two identically, which is why + /// the sidebar, canvas, and terminal chrome all branch on this one predicate. pub fn isRemote(self: Project) bool { - return std.mem.startsWith(u8, self.path, "ssh://"); + return std.mem.startsWith(u8, self.path, "ssh://") or + std.mem.startsWith(u8, self.path, "codespace://"); + } + + pub fn isCodespace(self: Project) bool { + return std.mem.startsWith(u8, self.path, "codespace://"); } pub fn isGlobal(self: Project) bool { @@ -1519,6 +1528,16 @@ test "project identity derives remote and global from Codable paths" { try std.testing.expect(!global.isRemote()); try std.testing.expect(!local.isRemote()); try std.testing.expect(!local.isGlobal()); + + const codespace = Project{ + .path = @constCast("codespace://dev-widget-x5jq4w/workspaces/widget"), + .name = @constCast("widget"), + }; + try std.testing.expect(codespace.isRemote()); + try std.testing.expect(codespace.isCodespace()); + try std.testing.expect(!codespace.isGlobal()); + try std.testing.expect(!codespace.isLocalFilesystem()); + try std.testing.expect(!remote.isCodespace()); } test "multi-project fixture retains both summaries and selection identity" { diff --git a/graphcode-windows/src/InputRouter.zig b/graphcode-windows/src/InputRouter.zig index ff9bae07..30aa330b 100644 --- a/graphcode-windows/src/InputRouter.zig +++ b/graphcode-windows/src/InputRouter.zig @@ -24,6 +24,7 @@ pub const Action = enum { clone_repository, cancel_clone, remote_repository, + codespace_repository, onboarding, cycle_attention, inspect_worktrees, @@ -60,6 +61,7 @@ pub fn keyAction(key: usize, ctrl: bool, shift: bool) Action { if (ctrl and shift and key == 'C') return .clone_repository; if (ctrl and shift and key == 'X') return .cancel_clone; if (ctrl and shift and key == 'R') return .remote_repository; + if (ctrl and shift and key == 'K') return .codespace_repository; if (ctrl and key == 'R' and !shift) return .reconnect; if (ctrl and key == 'N') return .create_node; if (ctrl and key == 'O') return .open_folder; @@ -131,6 +133,7 @@ pub fn commandText(allocator: std.mem.Allocator, action: Action) ![]u8 { .clone_repository => allocator.dupe(u8, "Clone HTTPS repository"), .cancel_clone => allocator.dupe(u8, "Cancel clone"), .remote_repository => allocator.dupe(u8, "Add SSH repository"), + .codespace_repository => allocator.dupe(u8, "Add GitHub Codespace"), .onboarding => allocator.dupe(u8, "GraphCode onboarding"), .cycle_attention => allocator.dupe(u8, "Review next loop needing you"), .inspect_worktrees => allocator.dupe(u8, "Inspect worktrees"), @@ -203,6 +206,8 @@ test "canvas destructive and rename keyboard equivalents are explicit" { test "modifier-specific actions win over base shortcuts" { try std.testing.expectEqual(Action.product_settings, keyAction(',', true, true)); try std.testing.expectEqual(Action.remote_repository, keyAction('R', true, true)); + try std.testing.expectEqual(Action.codespace_repository, keyAction('K', true, true)); + try std.testing.expectEqual(Action.none, keyAction('K', true, false)); try std.testing.expectEqual(Action.clone_repository, keyAction('C', true, true)); try std.testing.expectEqual(Action.reconnect, keyAction('R', true, false)); try std.testing.expectEqual(Action.edit_worktree_policy, keyAction('P', true, true)); diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index 674c0b7a..a2fbbe3b 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -20,6 +20,7 @@ pub const Command = enum(u16) { clone_repository = 4106, remote_repository = 4107, new_quick_chat = 4108, + codespace_repository = 4109, jump_loop = 4201, review_attention = 4202, next_loop = 4203, @@ -185,6 +186,7 @@ pub fn installMenu(hwnd: c.HWND) !void { append(add_folder, "Open Folder...\tCtrl+O", @intFromEnum(Command.open_folder)); append(add_folder, "Clone Repository...\tCtrl+Shift+C", @intFromEnum(Command.clone_repository)); append(add_folder, "Add Remote Repository...\tCtrl+Shift+R", @intFromEnum(Command.remote_repository)); + append(add_folder, "Add Codespace...\tCtrl+Shift+K", @intFromEnum(Command.codespace_repository)); separator(add_folder); appendPopup(add_folder, "Recent Folders", recent_folders); appendPopup(file, "Add Folder", add_folder); @@ -294,8 +296,10 @@ fn updateRecentFolderMenu(hwnd: c.HWND, recent_folders: []const RecentFolderItem if (file == null) return; const add_folder = c.GetSubMenu(file, 0); if (add_folder == null) return; - const recent = c.GetSubMenu(add_folder, 4); - if (recent == null) return; + // Located rather than indexed: the Add Folder popup grows an entry whenever a new + // ingress lands, and a hard-coded position silently retargeted this rebuild at the + // wrong item the last time it did. + const recent = findSubMenu(add_folder) orelse return; var count = c.GetMenuItemCount(recent); while (count > 0) : (count -= 1) { _ = c.DeleteMenu(recent, @intCast(count - 1), c.MF_BYPOSITION); @@ -309,6 +313,16 @@ fn updateRecentFolderMenu(hwnd: c.HWND, recent_folders: []const RecentFolderItem } } +fn findSubMenu(menu: c.HMENU) c.HMENU { + const count = c.GetMenuItemCount(menu); + var index: i32 = 0; + while (index < count) : (index += 1) { + const child = c.GetSubMenu(menu, index); + if (child != null) return child; + } + return null; +} + fn setEnabled(hwnd: c.HWND, command: Command, enabled: bool) void { const flags: c.UINT = @intCast(@as(i32, c.MF_BYCOMMAND) | if (enabled) @as(i32, c.MF_ENABLED) else @as(i32, c.MF_GRAYED)); diff --git a/graphcode-windows/src/WindowsCodespaceDialog.zig b/graphcode-windows/src/WindowsCodespaceDialog.zig new file mode 100644 index 00000000..1f0f4e81 --- /dev/null +++ b/graphcode-windows/src/WindowsCodespaceDialog.zig @@ -0,0 +1,824 @@ +//! The add-codespace sheet — a GitHub Codespace as a remote project, with +//! `gh codespace ssh` as the dial. The parity twin of the macOS `CodespaceFormView` +//! and the codespace half of `WelcomeFeature`. +//! +//! The picker is `gh codespace list`'s answer, so the sheet only ever offers +//! codespaces that actually exist; when there are none it points at GitHub's create +//! page instead — for the repositories already open in GraphCode when it can, +//! generically otherwise. Discovery and validation both run off the message loop, so +//! Cancel and Try Again stay live while gh is thinking. +//! +//! Chrome is the dark, keyboard-first Win32 posture the other GraphCode sheets use: +//! standard EDIT/LISTBOX/BUTTON controls (so UI Automation sees a real control tree +//! with no custom provider), a STATIC label immediately before each control to name +//! it, tab stops in reading order, Enter on the default button, and Escape to cancel. + +const std = @import("std"); +const Win32 = @import("Win32.zig"); +const c = Win32.c; +const Tokens = @import("DesignTokens.zig"); +const AppFont = @import("AppFont.zig"); +const Codespaces = @import("Codespaces.zig"); + +pub const intro_text = + "Add a GitHub Codespace as a remote project. Loops run in the codespace; this PC " ++ + "steers them. Needs the GitHub CLI signed in with the codespace scope, and zmx " ++ + "installed in the codespace. A stopped codespace is started by the connection."; + +pub const loading_text = "Asking the GitHub CLI for your codespaces…"; +pub const validating_text = "Connecting to the codespace and checking the repository path…"; +pub const abandoning_text = "Waiting for the codespace connection to finish…"; +pub const empty_text = + "No codespaces yet. Create one on GitHub, then come back here to add it."; + +/// What the sheet is showing right now. `failed` and `empty` are distinct on purpose: +/// one is a problem with a fix, the other is an account that simply has no codespaces +/// and needs a create link rather than a retry. +pub const Phase = enum { loading, ready, empty, failed, validating, abandoning }; + +/// The sheet's state, separated from its window so the flow — discovery, selection, +/// path prefill, submission — is exercised without a message loop. +pub const Model = struct { + allocator: std.mem.Allocator, + phase: Phase = .loading, + list: ?Codespaces.CodespaceList = null, + /// Why discovery failed, in the words the human needs; `null` unless `failed`. + list_failure: ?[]u8 = null, + /// The last submission failure, cleared by any edit so a stale message cannot + /// outlive the input that caused it. + inline_failure: ?[]u8 = null, + selection: ?usize = null, + path: []u8 = &.{}, + /// `owner/repo` for the open local projects, so an empty list can offer "create + /// one for the repository you're already working in". + suggestions: [][]u8 = &.{}, + + pub fn init(allocator: std.mem.Allocator) Model { + return .{ .allocator = allocator }; + } + + pub fn deinit(self: *Model) void { + if (self.list) |*list| list.deinit(); + self.list = null; + self.clearListFailure(); + self.clearInlineFailure(); + if (self.path.len != 0) self.allocator.free(self.path); + self.path = &.{}; + for (self.suggestions) |suggestion| self.allocator.free(suggestion); + if (self.suggestions.len != 0) self.allocator.free(self.suggestions); + self.suggestions = &.{}; + } + + fn clearListFailure(self: *Model) void { + if (self.list_failure) |message| self.allocator.free(message); + self.list_failure = null; + } + + pub fn clearInlineFailure(self: *Model) void { + if (self.inline_failure) |message| self.allocator.free(message); + self.inline_failure = null; + } + + pub fn setInlineFailure(self: *Model, message: []const u8) void { + const copy = self.allocator.dupe(u8, message) catch return; + self.clearInlineFailure(); + self.inline_failure = copy; + } + + pub fn setSuggestions(self: *Model, suggestions: [][]u8) void { + for (self.suggestions) |suggestion| self.allocator.free(suggestion); + if (self.suggestions.len != 0) self.allocator.free(self.suggestions); + self.suggestions = suggestions; + } + + pub fn applyLoaded(self: *Model, list: Codespaces.CodespaceList) void { + if (self.list) |*existing| existing.deinit(); + self.list = list; + self.clearListFailure(); + self.phase = if (list.items.len == 0) .empty else .ready; + if (list.items.len != 0) self.select(0); + } + + pub fn applyFailure(self: *Model, failure: Codespaces.Failure) void { + if (self.list) |*existing| existing.deinit(); + self.list = null; + self.selection = null; + const message = self.allocator.dupe(u8, Codespaces.failureMessage(failure)) catch null; + self.clearListFailure(); + self.list_failure = message; + self.phase = .failed; + } + + /// Back to loading, which is what Try Again means: the old answer is dropped so a + /// stale list can't be mistaken for the retry's result. + pub fn beginRetry(self: *Model) void { + if (self.list) |*existing| existing.deinit(); + self.list = null; + self.selection = null; + self.clearListFailure(); + self.clearInlineFailure(); + self.phase = .loading; + } + + /// Prefill only when the human hasn't typed a path of their own — switching picks + /// refreshes a default, never overwrites an edit made for another codespace of the + /// same repository. + pub fn select(self: *Model, index: usize) void { + const list = self.list orelse return; + if (index >= list.items.len) return; + self.selection = index; + self.clearInlineFailure(); + if (!self.pathIsDefault()) return; + const prefill = list.items[index].defaultWorkspacePath(self.allocator) catch return; + if (self.path.len != 0) self.allocator.free(self.path); + self.path = prefill; + } + + fn pathIsDefault(self: *Model) bool { + if (self.path.len == 0) return true; + const list = self.list orelse return false; + for (list.items) |item| { + const candidate = item.defaultWorkspacePath(self.allocator) catch continue; + defer self.allocator.free(candidate); + if (std.mem.eql(u8, candidate, self.path)) return true; + } + return false; + } + + pub fn setPath(self: *Model, value: []const u8) void { + const copy = self.allocator.dupe(u8, value) catch return; + if (self.path.len != 0) self.allocator.free(self.path); + self.path = copy; + self.clearInlineFailure(); + } + + pub fn selected(self: *const Model) ?Codespaces.Codespace { + const list = self.list orelse return null; + const index = self.selection orelse return null; + if (index >= list.items.len) return null; + return list.items[index]; + } + + pub fn fields(self: *const Model) ?Codespaces.Fields { + const codespace = self.selected() orelse return null; + const trimmed = std.mem.trim(u8, self.path, " \t\r\n"); + const candidate = Codespaces.Fields{ .name = codespace.name, .path = trimmed }; + Codespaces.validate(candidate) catch return null; + return candidate; + } + + /// The Add button's enablement: a validatable identity, and nothing already in + /// flight. + pub fn canSubmit(self: *const Model) bool { + return self.phase == .ready and self.fields() != null; + } + + /// The repository the create link should point at: the selected codespace's when + /// there is one, otherwise the first open local GitHub project. + pub fn createRepository(self: *const Model) []const u8 { + if (self.selected()) |codespace| return codespace.repository; + if (self.suggestions.len != 0) return self.suggestions[0]; + return ""; + } + + /// What the sheet's status line says in every phase, so the message a human reads + /// is a property of the state rather than of whichever branch last ran. + pub fn statusText(self: *const Model) []const u8 { + return switch (self.phase) { + .loading => loading_text, + .empty => empty_text, + .failed => self.list_failure orelse Codespaces.failureMessage(.list_failed), + .validating => validating_text, + .abandoning => abandoning_text, + .ready => "", + }; + } +}; + +const dialog_class = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeCodespaceIngressDialog"); +const title_text = "Add Codespace"; +const id_list = 4120; +const id_path = 4121; +const id_retry = 4122; +const id_create = 4123; +const id_accept = 1; +const id_cancel = 2; +const timer_id: usize = 11; +const em_setcuebanner = 0x1501; + +const DialogState = struct { + allocator: std.mem.Allocator, + parent: c.HWND, + model: *Model, + list_operation: ?*Codespaces.ListOperation = null, + validation: ?*Codespaces.ValidationOperation = null, + list_box: c.HWND = null, + path_edit: c.HWND = null, + status_label: c.HWND = null, + error_label: c.HWND = null, + retry_button: c.HWND = null, + create_button: c.HWND = null, + accept_button: c.HWND = null, + updating_path: bool = false, + accepted: bool = false, + closed: bool = false, +}; + +var dialog_active = false; +var dialog_state: DialogState = undefined; +var dark_field_brush: c.HBRUSH = null; + +/// The validated codespace the sheet produced. Owned by the caller; `null` when the +/// human cancelled. +pub const Accepted = struct { + name: []u8, + path: []u8, + + pub fn deinit(self: *Accepted, allocator: std.mem.Allocator) void { + allocator.free(self.name); + allocator.free(self.path); + self.* = undefined; + } +}; + +/// Runs the sheet to completion. Discovery starts with the window, so the list is +/// already arriving while the human reads the intro; validation runs before the sheet +/// closes, which is what makes the returned identity one whose sessions can start. +pub fn open( + parent: c.HWND, + allocator: std.mem.Allocator, + local_project_paths: []const []const u8, +) !?Accepted { + if (dialog_active) return error.CodespaceDialogAlreadyOpen; + try registerClass(); + + var model = Model.init(allocator); + defer model.deinit(); + if (Codespaces.repositorySuggestions(allocator, local_project_paths)) |suggestions| { + model.setSuggestions(suggestions); + } else |_| {} + + dialog_state = .{ .allocator = allocator, .parent = parent, .model = &model }; + dialog_active = true; + defer dialog_active = false; + defer if (dialog_state.list_operation) |operation| operation.deinit(); + defer if (dialog_state.validation) |operation| operation.deinit(); + + dialog_state.list_operation = Codespaces.ListOperation.start(allocator) catch null; + if (dialog_state.list_operation == null) model.applyFailure(.list_failed); + + const wide_title = try wideZ(allocator, title_text); + defer allocator.free(wide_title); + const style = c.WS_OVERLAPPED | c.WS_CAPTION | c.WS_SYSMENU; + const ex_style = c.WS_EX_DLGMODALFRAME | c.WS_EX_CONTROLPARENT; + var frame = c.RECT{ .left = 0, .top = 0, .right = 640, .bottom = 540 }; + _ = c.AdjustWindowRectEx(&frame, style, 0, ex_style); + const width = frame.right - frame.left; + const height = frame.bottom - frame.top; + var owner: c.RECT = undefined; + _ = c.GetWindowRect(parent, &owner); + const x = owner.left + @divTrunc((owner.right - owner.left) - width, 2); + const y = owner.top + @divTrunc((owner.bottom - owner.top) - height, 2); + const hwnd = c.CreateWindowExW( + ex_style, + dialog_class.ptr, + wide_title.ptr, + style, + x, + y, + width, + height, + parent, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.CodespaceDialogCreationFailed; + + _ = c.SetTimer(hwnd, timer_id, 100, null); + _ = c.EnableWindow(parent, 0); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.SetForegroundWindow(hwnd); + _ = c.SetFocus(dialog_state.list_box); + + var message: c.MSG = undefined; + while (!dialog_state.closed) { + const code = c.GetMessageW(&message, null, 0, 0); + if (code <= 0) { + dialog_state.closed = true; + break; + } + if (message.message == c.WM_KEYDOWN and message.wParam == c.VK_ESCAPE) { + requestCancel(); + continue; + } + if (c.IsDialogMessageW(hwnd, &message) != 0) continue; + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + + _ = c.KillTimer(hwnd, timer_id); + _ = c.DestroyWindow(hwnd); + _ = c.EnableWindow(parent, 1); + _ = c.SetActiveWindow(parent); + if (!dialog_state.accepted) return null; + const accepted = model.fields() orelse return null; + const name = try allocator.dupe(u8, accepted.name); + errdefer allocator.free(name); + const path = try allocator.dupe(u8, accepted.path); + return Accepted{ .name = name, .path = path }; +} + +fn registerClass() !void { + var klass: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + klass.lpfnWndProc = @ptrCast(&dialogProc); + klass.hInstance = c.GetModuleHandleW(null); + klass.lpszClassName = dialog_class.ptr; + klass.hCursor = c.LoadCursorW(null, Win32.resourceIdentifier(32512)); + klass.hbrBackground = null; + if (c.RegisterClassW(&klass) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.CodespaceDialogClassRegistrationFailed; +} + +fn dialogProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { + if (!dialog_active) return c.DefWindowProcW(hwnd, message, wparam, lparam); + switch (message) { + c.WM_CREATE => { + createControls(hwnd); + refreshPresentation(); + return 0; + }, + c.WM_ERASEBKGND => return eraseBackground(hwnd, wparam), + c.WM_CTLCOLORSTATIC => return colorStatic(controlHandleFrom(lparam), wparam), + c.WM_CTLCOLOREDIT, c.WM_CTLCOLORLISTBOX => return colorField(wparam), + c.WM_TIMER => { + if (wparam == timer_id) pollOperations(); + return 0; + }, + c.WM_COMMAND => { + const command: u16 = @truncate(wparam); + const notification: u16 = @truncate(wparam >> 16); + switch (command) { + id_accept => submit(), + id_cancel => requestCancel(), + id_retry => retry(), + id_create => openCreatePage(hwnd), + id_list => { + if (notification == c.LBN_SELCHANGE) selectionChanged(); + if (notification == c.LBN_DBLCLK) submit(); + }, + id_path => { + if (notification == c.EN_CHANGE and !dialog_state.updating_path) pathChanged(); + }, + else => {}, + } + return 0; + }, + c.WM_CLOSE => { + requestCancel(); + return 0; + }, + else => {}, + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); +} + +fn createControls(hwnd: c.HWND) void { + const state = &dialog_state; + _ = createStatic(hwnd, intro_text, 24, 20, 592, 58, 0); + // Every interactive control is preceded in z-order by the STATIC that names it, + // which is how UI Automation and Narrator derive a name for a bare Win32 control. + _ = createStatic(hwnd, "Codespace", 24, 90, 592, 20, 0); + state.list_box = createControl( + hwnd, + c.WS_EX_CLIENTEDGE, + "LISTBOX", + "", + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.WS_VSCROLL | c.LBS_NOTIFY | c.LBS_NOINTEGRALHEIGHT, + 24, + 114, + 592, + 180, + id_list, + ); + state.status_label = createStatic(hwnd, loading_text, 24, 302, 592, 54, 0); + _ = createStatic(hwnd, "Repository path inside the codespace", 24, 362, 592, 20, 0); + state.path_edit = createControl( + hwnd, + c.WS_EX_CLIENTEDGE, + "EDIT", + "", + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.ES_AUTOHSCROLL, + 24, + 386, + 592, + 28, + id_path, + ); + const cue = wideZ(state.allocator, "/workspaces/repository — absolute") catch null; + if (cue) |value| { + defer state.allocator.free(value); + _ = c.SendMessageW(state.path_edit, em_setcuebanner, 1, @bitCast(@intFromPtr(value.ptr))); + } + state.error_label = createStatic(hwnd, "", 24, 424, 592, 40, 0); + state.create_button = createButton(hwnd, "Create on GitHub…", id_create, 24, 476, 168, 30, false); + state.retry_button = createButton(hwnd, "Try Again", id_retry, 200, 476, 108, 30, false); + _ = createButton(hwnd, "Cancel", id_cancel, 430, 476, 88, 30, false); + state.accept_button = createButton(hwnd, "Add", id_accept, 528, 476, 88, 30, true); +} + +fn pollOperations() void { + const state = &dialog_state; + if (state.list_operation) |operation| { + if (operation.poll()) |status| { + switch (status) { + .loaded => if (operation.takeList()) |list| state.model.applyLoaded(list) else state.model.applyFailure(.unreadable_list), + .failed => state.model.applyFailure(operation.failure), + .loading => return, + } + operation.deinit(); + state.list_operation = null; + refreshPresentation(); + } + return; + } + if (state.validation) |operation| { + const status = operation.poll() orelse return; + operation.deinit(); + state.validation = null; + const abandoned = state.model.phase == .abandoning; + switch (status) { + .succeeded => { + if (abandoned) { + state.closed = true; + return; + } + state.accepted = true; + state.closed = true; + return; + }, + else => { + if (abandoned) { + state.closed = true; + return; + } + state.model.phase = .ready; + state.model.setInlineFailure( + "The codespace could not be reached, or that path isn't a Git repository inside it. " ++ + "Check the path, then try again.", + ); + refreshPresentation(); + }, + } + } +} + +fn selectionChanged() void { + const selected = c.SendMessageW(dialog_state.list_box, c.LB_GETCURSEL, 0, 0); + if (selected < 0) return; + dialog_state.model.select(@intCast(selected)); + syncPathEdit(); + refreshPresentation(); +} + +fn pathChanged() void { + const allocator = dialog_state.allocator; + const value = readControlText(allocator, dialog_state.path_edit) catch return; + defer allocator.free(value); + dialog_state.model.setPath(value); + refreshPresentation(); +} + +fn retry() void { + if (dialog_state.list_operation != null) return; + dialog_state.model.beginRetry(); + dialog_state.list_operation = Codespaces.ListOperation.start(dialog_state.allocator) catch null; + if (dialog_state.list_operation == null) dialog_state.model.applyFailure(.list_failed); + refreshPresentation(); +} + +/// Cancel is honest about an in-flight dial: the sheet stops taking input and waits +/// for gh to return rather than tearing a live child process out from under itself. +fn requestCancel() void { + if (dialog_state.validation != null) { + dialog_state.model.phase = .abandoning; + refreshPresentation(); + return; + } + dialog_state.closed = true; +} + +fn submit() void { + const state = &dialog_state; + if (state.model.phase != .ready) return; + const fields = state.model.fields() orelse { + const codespace = state.model.selected(); + const message = if (codespace == null) + Codespaces.validationMessage(error.MissingCodespaceName) + else blk: { + const trimmed = std.mem.trim(u8, state.model.path, " \t\r\n"); + Codespaces.validate(.{ .name = codespace.?.name, .path = trimmed }) catch |err| + break :blk Codespaces.validationMessage(err); + break :blk Codespaces.validationMessage(error.MissingCodespacePath); + }; + state.model.setInlineFailure(message); + refreshPresentation(); + return; + }; + state.model.clearInlineFailure(); + state.validation = Codespaces.ValidationOperation.start(state.allocator, fields) catch null; + if (state.validation == null) { + state.model.setInlineFailure("The codespace connection could not be started."); + refreshPresentation(); + return; + } + state.model.phase = .validating; + refreshPresentation(); +} + +fn openCreatePage(hwnd: c.HWND) void { + const allocator = dialog_state.allocator; + const url = Codespaces.createURLFor(allocator, dialog_state.model.createRepository()) catch return; + defer allocator.free(url); + const wide = wideZ(allocator, url) catch return; + defer allocator.free(wide); + _ = c.ShellExecuteW( + hwnd, + std.unicode.utf8ToUtf16LeStringLiteral("open").ptr, + wide.ptr, + null, + null, + c.SW_SHOWNORMAL, + ); +} + +fn syncPathEdit() void { + dialog_state.updating_path = true; + setControlText(dialog_state.path_edit, dialog_state.model.path); + dialog_state.updating_path = false; +} + +/// One place decides what every control shows, from the model — the reason a failed +/// retry can't leave a stale list behind or an enabled Add without a selection. +fn refreshPresentation() void { + const state = &dialog_state; + const model = state.model; + if (model.phase == .ready or model.phase == .empty) refillList(); + setControlText(state.status_label, model.statusText()); + _ = c.ShowWindow(state.status_label, if (model.statusText().len == 0) c.SW_HIDE else c.SW_SHOW); + setControlText(state.error_label, model.inline_failure orelse ""); + const busy = model.phase == .validating or model.phase == .abandoning or model.phase == .loading; + _ = c.EnableWindow(state.list_box, @intFromBool(model.phase == .ready)); + _ = c.EnableWindow(state.path_edit, @intFromBool(model.phase == .ready)); + _ = c.EnableWindow(state.accept_button, @intFromBool(model.canSubmit())); + _ = c.EnableWindow(state.retry_button, @intFromBool(!busy)); + _ = c.EnableWindow(state.create_button, @intFromBool(!busy)); +} + +fn refillList() void { + const state = &dialog_state; + _ = c.SendMessageW(state.list_box, c.LB_RESETCONTENT, 0, 0); + const list = state.model.list orelse return; + for (list.items) |item| { + const label = item.rowLabel(state.allocator) catch continue; + defer state.allocator.free(label); + const wide = wideZ(state.allocator, label) catch continue; + defer state.allocator.free(wide); + _ = c.SendMessageW(state.list_box, c.LB_ADDSTRING, 0, @bitCast(@intFromPtr(wide.ptr))); + } + if (state.model.selection) |index| _ = c.SendMessageW(state.list_box, c.LB_SETCURSEL, index, 0); + syncPathEdit(); +} + +fn eraseBackground(hwnd: c.HWND, wparam: c.WPARAM) c.LRESULT { + const hdc = deviceContextFrom(wparam); + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + const brush = c.CreateSolidBrush(Tokens.dialog_panel); + if (brush != null) { + _ = c.FillRect(hdc, &client, brush); + _ = c.DeleteObject(brush); + } + return 1; +} + +fn colorStatic(control: c.HWND, wparam: c.WPARAM) c.LRESULT { + const hdc = deviceContextFrom(wparam); + _ = c.SetTextColor(hdc, if (control == dialog_state.error_label) + Tokens.dialog_error_text + else + Tokens.dialog_body_text); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + return @intCast(@intFromPtr(c.GetStockObject(c.NULL_BRUSH))); +} + +fn colorField(wparam: c.WPARAM) c.LRESULT { + const hdc = deviceContextFrom(wparam); + _ = c.SetTextColor(hdc, Tokens.dialog_title_text); + _ = c.SetBkColor(hdc, Tokens.dialog_field_background); + _ = c.SetBkMode(hdc, c.OPAQUE); + if (dark_field_brush == null) dark_field_brush = c.CreateSolidBrush(Tokens.dialog_field_background); + return @intCast(@intFromPtr(dark_field_brush)); +} + +fn deviceContextFrom(wparam: c.WPARAM) c.HDC { + return Win32.opaquePointerFromInt(c.HDC, wparam); +} + +fn controlHandleFrom(lparam: c.LPARAM) c.HWND { + return Win32.messagePointer(c.HWND, lparam); +} + +fn createStatic(hwnd: c.HWND, text: []const u8, x: i32, y: i32, width: i32, height: i32, id: usize) c.HWND { + return createControl(hwnd, 0, "STATIC", text, c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, x, y, width, height, id); +} + +fn createButton(hwnd: c.HWND, text: []const u8, id: usize, x: i32, y: i32, width: i32, height: i32, default: bool) c.HWND { + return createControl( + hwnd, + 0, + "BUTTON", + text, + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | @as(c.LONG, if (default) 1 else 0), + x, + y, + width, + height, + id, + ); +} + +fn createControl( + hwnd: c.HWND, + ex_style: c.DWORD, + class: []const u8, + text: []const u8, + style: c.LONG, + x: i32, + y: i32, + width: i32, + height: i32, + id: usize, +) c.HWND { + const allocator = 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 control = c.CreateWindowExW( + ex_style, + wide_class.ptr, + wide_text.ptr, + @bitCast(style), + x, + y, + width, + height, + hwnd, + controlId(id), + c.GetModuleHandleW(null), + null, + ) orelse return null; + AppFont.apply(control, AppFont.control_size, false); + return control; +} + +fn controlId(id: usize) c.HMENU { + if (id == 0) return null; + return Win32.opaquePointerFromInt(c.HMENU, id); +} + +fn readControlText(allocator: std.mem.Allocator, control: c.HWND) ![]u8 { + const length: usize = @intCast(c.GetWindowTextLengthW(control)); + const wide = try allocator.alloc(u16, length + 1); + defer allocator.free(wide); + const copied = c.GetWindowTextW(control, wide.ptr, @intCast(wide.len)); + return std.unicode.utf16LeToUtf8Alloc(allocator, wide[0..@intCast(copied)]); +} + +fn setControlText(control: c.HWND, value: []const u8) void { + if (control == null) return; + const wide = wideZ(dialog_state.allocator, value) catch return; + defer dialog_state.allocator.free(wide); + _ = c.SetWindowTextW(control, wide.ptr); +} + +fn wideZ(allocator: std.mem.Allocator, value: []const u8) ![]u16 { + const raw = try std.unicode.utf8ToUtf16LeAlloc(allocator, value); + defer allocator.free(raw); + const result = try allocator.alloc(u16, raw.len + 1); + @memcpy(result[0..raw.len], raw); + result[raw.len] = 0; + return result; +} + +fn testList(allocator: std.mem.Allocator, json: []const u8) !Codespaces.CodespaceList { + return Codespaces.parseList(allocator, json); +} + +test "the sheet starts in loading and says what it is waiting for" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + try std.testing.expectEqual(Phase.loading, model.phase); + try std.testing.expectEqualStrings(loading_text, model.statusText()); + try std.testing.expect(!model.canSubmit()); +} + +test "a loaded list selects the first codespace and prefills its workspace path" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + model.applyLoaded(try testList(std.testing.allocator, + \\[{"name":"dev-widget","displayName":"widget","repository":"octo/widget","state":"Available"}, + \\ {"name":"dev-gadget","displayName":"gadget","repository":"octo/gadget","state":"Shutdown"}] + )); + try std.testing.expectEqual(Phase.ready, model.phase); + try std.testing.expectEqualStrings("dev-widget", model.selected().?.name); + try std.testing.expectEqualStrings("/workspaces/widget", model.path); + try std.testing.expect(model.canSubmit()); + try std.testing.expectEqualStrings("", model.statusText()); + + // Switching picks refreshes an untouched default… + model.select(1); + try std.testing.expectEqualStrings("/workspaces/gadget", model.path); + + // …and never overwrites a path the human typed. + model.setPath("/srv/custom"); + model.select(0); + try std.testing.expectEqualStrings("/srv/custom", model.path); +} + +test "an empty account is offered a create link rather than a retry" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const suggestions = try std.testing.allocator.alloc([]u8, 1); + suggestions[0] = try std.testing.allocator.dupe(u8, "octo/widget"); + model.setSuggestions(suggestions); + model.applyLoaded(try testList(std.testing.allocator, "[]")); + try std.testing.expectEqual(Phase.empty, model.phase); + try std.testing.expect(!model.canSubmit()); + try std.testing.expectEqualStrings(empty_text, model.statusText()); + try std.testing.expectEqualStrings("octo/widget", model.createRepository()); + + const url = try Codespaces.createURLFor(std.testing.allocator, model.createRepository()); + defer std.testing.allocator.free(url); + try std.testing.expectEqualStrings("https://codespaces.new/octo/widget", url); +} + +test "a discovery failure shows its fix and retry returns to loading" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + model.applyLoaded(try testList(std.testing.allocator, + \\[{"name":"dev-widget","displayName":"widget","repository":"octo/widget","state":"Available"}] + )); + model.applyFailure(.missing_codespace_scope); + try std.testing.expectEqual(Phase.failed, model.phase); + try std.testing.expect(model.list == null); + try std.testing.expectEqual(@as(?Codespaces.Codespace, null), model.selected()); + try std.testing.expect(std.mem.indexOf(u8, model.statusText(), "gh auth refresh") != null); + try std.testing.expect(!model.canSubmit()); + + model.beginRetry(); + try std.testing.expectEqual(Phase.loading, model.phase); + try std.testing.expectEqualStrings(loading_text, model.statusText()); +} + +test "submission is blocked while a dial is in flight and while the path is unusable" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + model.applyLoaded(try testList(std.testing.allocator, + \\[{"name":"dev-widget","displayName":"widget","repository":"octo/widget","state":"Available"}] + )); + try std.testing.expect(model.canSubmit()); + + model.phase = .validating; + try std.testing.expect(!model.canSubmit()); + try std.testing.expectEqualStrings(validating_text, model.statusText()); + model.phase = .abandoning; + try std.testing.expectEqualStrings(abandoning_text, model.statusText()); + + model.phase = .ready; + model.setPath("workspaces/widget"); + try std.testing.expectEqual(@as(?Codespaces.Fields, null), model.fields()); + try std.testing.expect(!model.canSubmit()); + + model.setPath(" /workspaces/widget "); + try std.testing.expectEqualStrings("/workspaces/widget", model.fields().?.path); + try std.testing.expect(model.canSubmit()); +} + +test "an inline failure survives until the next edit" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + model.applyLoaded(try testList(std.testing.allocator, + \\[{"name":"dev-widget","displayName":"widget","repository":"octo/widget","state":"Available"}] + )); + model.setInlineFailure("The codespace could not be reached."); + try std.testing.expectEqualStrings("The codespace could not be reached.", model.inline_failure.?); + model.setPath("/workspaces/other"); + try std.testing.expectEqual(@as(?[]u8, null), model.inline_failure); +} + +test "sheet copy states the trade and the codespace requirements" { + try std.testing.expect(std.mem.indexOf(u8, intro_text, "codespace scope") != null); + try std.testing.expect(std.mem.indexOf(u8, intro_text, "zmx") != null); + try std.testing.expect(std.mem.indexOf(u8, intro_text, "stopped codespace is started") != null); +} diff --git a/windows-tests/WindowsCodespaceIngressTests.swift b/windows-tests/WindowsCodespaceIngressTests.swift new file mode 100644 index 00000000..df8e203a --- /dev/null +++ b/windows-tests/WindowsCodespaceIngressTests.swift @@ -0,0 +1,61 @@ +import Foundation +import XCTest + +@testable import GraphcodeKit + +/// The daemon half of Windows codespace ingress. The Windows shell builds a +/// `codespace://` project path and hands it to `openProject`; everything below the +/// protocol has to already understand that path and be able to find `gh.exe` without +/// a `PATH` search, or the project opens and every session in it fails to dial. +final class WindowsCodespaceIngressTests: XCTestCase { + func testCodespaceProjectPathRoundTripsThroughTheDaemonsParser() throws { + let projectPath = "codespace://fluffy-space-giggle-abc123/workspaces/widget" + let location = try XCTUnwrap(RemoteProjectLocation.parse(projectPath: projectPath)) + + XCTAssertTrue(location.isCodespace) + XCTAssertEqual(location.host, "fluffy-space-giggle-abc123") + XCTAssertEqual(location.remotePath, "/workspaces/widget") + XCTAssertEqual(location.projectPath, projectPath) + } + + func testCodespacePathsWithSpacesSurviveTheEncoding() throws { + let location = RemoteProjectLocation( + host: "curly-halibut-9f8f8", + remotePath: "/workspaces/my project", + isCodespace: true + ) + let reparsed = try XCTUnwrap(RemoteProjectLocation.parse(projectPath: location.projectPath)) + XCTAssertEqual(reparsed.remotePath, "/workspaces/my project") + XCTAssertTrue(reparsed.isCodespace) + } + + func testCodespaceInvocationDialsThroughTheGitHubCLIRatherThanSSH() { + let location = RemoteProjectLocation( + host: "curly-halibut-9f8f8", + remotePath: "/workspaces/widget", + isCodespace: true + ) + let invocation = location.sshInvocation(remoteCommand: "echo ready") + + XCTAssertEqual(invocation.first, GhLocator.executablePath) + XCTAssertEqual(Array(invocation.dropFirst().prefix(4)), [ + "codespace", "ssh", "-c", "curly-halibut-9f8f8", + ]) + XCTAssertTrue(invocation.contains("--")) + } + + func testGhLocatorNamesAnAbsolutePathOnEveryPlatform() { + let path = GhLocator.executablePath + XCTAssertFalse(path.isEmpty) + #if os(Windows) + // `Process` never searches `PATH`, so a bare `gh.exe` would fail to exec; and + // the fallback has to be a real install location so the error names somewhere + // worth installing to. + XCTAssertTrue(path.lowercased().hasSuffix("gh.exe"), path) + XCTAssertTrue(path.contains(":\\"), path) + XCTAssertTrue(GhLocator.candidates.count >= 2) + #else + XCTAssertTrue(path.hasPrefix("/"), path) + #endif + } +} From 01ee67d90cca266ca66bb538ac7fb7f4b50ce40e Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 22 Sep 2026 09:54:32 -0700 Subject: [PATCH 2/3] Spell out that the redaction fixture is not a credential The sanitizer test needs token-shaped input to prove it strips it, but a realistic-looking literal is the kind of thing a scanner flags and a reviewer has to stop and verify. Only the prefix and the shape matter to the redactor, so the fixture now says what it is. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/Codespaces.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/graphcode-windows/src/Codespaces.zig b/graphcode-windows/src/Codespaces.zig index 7094cb0d..6468ff00 100644 --- a/graphcode-windows/src/Codespaces.zig +++ b/graphcode-windows/src/Codespaces.zig @@ -748,12 +748,14 @@ test "gh scope failure carries its own remediation" { } test "surfaced gh output never carries a credential or control characters" { - const raw = "denied for gho_0123456789abcdefABCDEF token\r\nand github_pat_11ABCDE_secretpart too"; + // Synthetic literals, not credentials: only the prefix and the shape matter to + // the redactor, so the fixture spells out that it is an example. + const raw = "denied for gho_EXAMPLENOTAREALTOKEN0000 token\r\nand github_pat_11EXAMPLE_notarealsecret too"; const safe = try sanitizeMessage(std.testing.allocator, raw); defer std.testing.allocator.free(safe); try std.testing.expect(std.mem.indexOf(u8, safe, "gho_") == null); try std.testing.expect(std.mem.indexOf(u8, safe, "github_pat_") == null); - try std.testing.expect(std.mem.indexOf(u8, safe, "secretpart") == null); + try std.testing.expect(std.mem.indexOf(u8, safe, "notarealsecret") == null); try std.testing.expect(std.mem.indexOf(u8, safe, "") != null); try std.testing.expect(std.mem.indexOfAny(u8, safe, "\r\n") == null); From 3cd832806973d5b4b5adce05f90e94c6b6238987 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 22 Sep 2026 10:35:43 -0700 Subject: [PATCH 3/3] Record Codespaces ingress in the parity ledger as Partial The row says exactly what the deterministic suite proves and exactly what it does not: a live connect was never exercised, because the available token has no codespace scope, so only the 403 path met real gh. Calling that Validated would make the ledger a worse signal than no row at all. Also notes that the Add Folder submenu is now located rather than indexed. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- investigation/ui-parity-matrix.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 9f27bfb5..74e76e06 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -52,7 +52,7 @@ Statuses: | Project context menu | Move, worktrees, settings, Explorer, remote info, close, remove, delete loops/project | Project rows retain the existing lifecycle actions plus Move and the confirmed Windows Recycle Bin path for local folders. Windows keeps Move as the native Explorer `/select` handoff rather than an in-app relocation flow; the deterministic UIA gate now verifies the live shell-execute target and selected folder path. | Validated | | Loop context menu | Open, composite actions, rename, stop, delete | Sidebar and canvas loop rows share stable-ID Open, Rename, Stop, and Delete actions. Composite cards expose Open Group, Pilot Once, and Arm Schedule; the drilled-in canvas addresses mutations through the parent composite. Final live menu and accessibility evidence remains incomplete | Partial | | Recent projects | Reachable from Add Folder menu | Recent and currently-open projects are now exposed as distinct sidebar rows, with unopened recents remaining under the LOCAL/REMOTE sections while open workspaces use separate `open-project` identities. The deterministic UIA gate verifies the split presentation and section behavior; recent-menu reachability is covered by the native menu tests, but a live submenu walkthrough remains outstanding. | Partial | -| Add Folder menu | Open Folder, Clone, Add Remote, recents | File now groups Open Folder, Clone Repository, and Add Remote Repository under Add Folder and adds a dedicated Recent Folders submenu with its own command range. Focused MainWindow coverage validates the native menu structure and recent-folder command wiring; a live submenu walkthrough remains outstanding. | Partial | +| Add Folder menu | Open Folder, Clone, Add Remote, recents | File now groups Open Folder, Clone Repository, Add Remote Repository, and Add Codespace under Add Folder and adds a dedicated Recent Folders submenu with its own command range; that submenu is now located rather than positionally indexed, so a new ingress entry can no longer silently retarget the rebuild. Focused MainWindow coverage validates the native menu structure and recent-folder command wiring; a live submenu walkthrough remains outstanding. | Partial | | Sidebar update banner | Available version and click-to-install action | A persistent footer banner now shows the retained offered version and reopens the native update offer when clicked. A deterministic live fixture captured the banner and verified the click raises `GraphCode Update Available`; the offer still hands installation off to the verified release page | Partial | | Sidebar error footer | Persistent, scoped project-ingress error | Folder, clone, remote, and daemon-open failures now persist in a dedicated red sidebar footer independently of transient status. Successful project ingress clears it, wrapped layout preserves long messages, and the deterministic UIA gate verifies the dedicated footer identity plus multi-line bounds below the update offer. | Validated | | Needs-you section | Navigable list with reason/project and Stop action | Up to four entries now expose selection, explicit reason copy, stable UIA identities, click/UIA navigation, and a dedicated Stop action. Focused routing coverage plus the deterministic UIA gate verify Stop targets the populated entry's real project path and loop ID. | Validated | @@ -120,6 +120,7 @@ Statuses: | 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. `WindowsRepositoryDialogs.zig`'s dialog and operation window classes now paint the dark theme instead of the previous `GetSysColorBrush(COLOR_WINDOW)` light background: `hbrBackground = null` plus new `WM_ERASEBKGND` (fills `Tokens.dialog_panel`), `WM_CTLCOLORSTATIC` (light `dialog_body_text`, red `dialog_error_text` for the error label), and `WM_CTLCOLOREDIT` (dark `dialog_field_background` fields with light text) handlers, reusing the same constants as `WindowsProductSettings.zig`. Automated/UIA evidence of the rendered result 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. This sheet shares the same `WindowsRepositoryDialogs.zig` dialog window class as Clone, so it now also renders on the dark panel/text theme described above rather than default Win32 gray. 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 | +| Add Codespace sheet | Authenticated codespace discovery, selection, workspace path, validation progress, empty/error/retry/cancel, opens the remote project | `Codespaces.zig` + `WindowsCodespaceDialog.zig` add the fourth ingress: `gh codespace list --json` discovery, tolerant parsing, failure classification (missing CLI, unauthenticated, missing `codespace` scope, offline) with per-case remediation, `BatchMode` `gh codespace ssh` validation running `git rev-parse` in the chosen path, and a dark Win32 sheet whose `Model` covers loading, empty-with-create-link, failure+retry, in-flight validation, cancel-while-dialing, path prefill, and submit gating. An accepted codespace opens as a `codespace://` project through the existing daemon `openProject` call, so no protocol change was needed. **Proven deterministically in CI** (`windows-shell` run https://github.com/scgopi/GraphCode/actions/runs/35757217586): Codespaces 13/13, dialog 20/20, suite 96/96, scaffold contract PASS, plus new harness contracts for the menu entry, ingress dispatch, `codespace://` identity, `BatchMode`, gh-output redaction, dark painting, and keyboard navigation. **Not proven: a live end-to-end connect.** The available token lacks the `codespace` scope, so only the 403/remediation failure path was exercised against real `gh`; discovery-success, selection from a real list, and a validated dial to a running codespace still need live evidence from a `codespace`-scoped account. No UIA gate coverage of the rendered sheet yet | Partial | ## Settings and worktrees