Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions GraphcodeKit/Sources/Domain/GhLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 }
Expand Down
10 changes: 10 additions & 0 deletions graphcode-windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
37 changes: 37 additions & 0 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading