Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,5 @@ sealed class MockAppSettingsFeature : IAppSettingsFeature
public bool KeepAlive { get; set; }
public bool CanvasEnabled { get; set; } = true;
public Dictionary<string, Cockpit.Features.SystemMessage.SystemMessageSectionSetting> SystemMessageSectionOverrides { get; set; } = [];
public string SessionListGroupBy { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
}
Original file line number Diff line number Diff line change
Expand Up @@ -683,4 +683,5 @@ sealed class MockAppSettings : IAppSettingsFeature
public bool KeepAlive { get; set; }
public bool CanvasEnabled { get; set; } = true;
public Dictionary<string, Cockpit.Features.SystemMessage.SystemMessageSectionSetting> SystemMessageSectionOverrides { get; set; } = [];
public string SessionListGroupBy { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
}
16 changes: 14 additions & 2 deletions src/Cockpit/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using Cockpit.Controls;
using Cockpit.Features.Auth;
using Cockpit.Features.Sdk;
using Cockpit.Features.Sessions;
using Cockpit.Features.Splash;
using Cockpit.Features.Theme;
using Microsoft.Extensions.Logging;

namespace Cockpit;

Expand All @@ -12,22 +15,31 @@ public partial class App : Application
readonly SplashFeature _splashFeature;
readonly SessionFeature _sessionFeature;
readonly ThemeStateFeature _themeStateFeature;
readonly CopilotClientFeature _copilotClientFeature;
readonly AuthFeature _authFeature;
readonly ILogger<MainPage> _mainPageLogger;

public App(
SplashFeature splashFeature,
SessionFeature sessionFeature,
ThemeStateFeature themeStateFeature)
ThemeStateFeature themeStateFeature,
CopilotClientFeature copilotClientFeature,
AuthFeature authFeature,
ILogger<MainPage> mainPageLogger)
{
InitializeComponent();

_splashFeature = splashFeature;
_sessionFeature = sessionFeature;
_themeStateFeature = themeStateFeature;
_copilotClientFeature = copilotClientFeature;
_authFeature = authFeature;
_mainPageLogger = mainPageLogger;
}

protected override Window CreateWindow(IActivationState? activationState)
{
_mainWindow = new Window(new MainPage(_splashFeature, _sessionFeature, _themeStateFeature))
_mainWindow = new Window(new MainPage(_splashFeature, _sessionFeature, _themeStateFeature, _copilotClientFeature, _authFeature, _mainPageLogger))
{
Title = "Cockpit",
TitleBar = new TitleBar
Expand Down
18 changes: 9 additions & 9 deletions src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@
readonly Dictionary<DiffHunkModel, (int Above, int Below)> _hunkExpansion = [];
bool _needsHighlight;

FileCategory _fileCategory;

Check warning on line 30 in src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Field 'GitDiffViewer._fileCategory' is never assigned to, and will always have its default value

Check warning on line 30 in src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Field 'GitDiffViewer._fileCategory' is never assigned to, and will always have its default value
string? _mediaDataUrl;
bool _mediaLoading;
bool _mediaTooBig;

string? _prevDiff;

Check warning on line 35 in src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Field 'GitDiffViewer._prevDiff' is never assigned to, and will always have its default value null

Check warning on line 35 in src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Field 'GitDiffViewer._prevDiff' is never assigned to, and will always have its default value null
string? _prevFilePath;

Check warning on line 36 in src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Field 'GitDiffViewer._prevFilePath' is never assigned to, and will always have its default value null

Check warning on line 36 in src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Field 'GitDiffViewer._prevFilePath' is never assigned to, and will always have its default value null
bool _prevSplitView;

Check warning on line 37 in src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Field 'GitDiffViewer._prevSplitView' is never assigned to, and will always have its default value false

Check warning on line 37 in src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Field 'GitDiffViewer._prevSplitView' is never assigned to, and will always have its default value false

readonly string _diffInlineId = $"diff-inline-{Guid.NewGuid():N}";
readonly string _diffSplitLeftId = $"diff-split-left-{Guid.NewGuid():N}";
Expand All @@ -42,17 +42,17 @@

string FileName => string.IsNullOrEmpty(FilePath) ? string.Empty : Path.GetFileName(FilePath);

static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
static readonly HashSet<string> imageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".webp", ".tiff", ".tif"
};

static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
static readonly HashSet<string> videoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mkv", ".m4v", ".ogv", ".ogg"
};

static readonly HashSet<string> BinaryExtensions = new(StringComparer.OrdinalIgnoreCase)
static readonly HashSet<string> binaryExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".dll", ".exe", ".pdb", ".obj", ".lib", ".bin", ".zip", ".rar", ".7z",
".tar", ".gz", ".bz2", ".xz", ".jar", ".war", ".ear", ".class",
Expand All @@ -69,9 +69,9 @@
}

string ext = Path.GetExtension(filePath);
if(ImageExtensions.Contains(ext)) { return FileCategory.Image; }
if(VideoExtensions.Contains(ext)) { return FileCategory.Video; }
if(BinaryExtensions.Contains(ext)) { return FileCategory.Binary; }
if(imageExtensions.Contains(ext)) { return FileCategory.Image; }
if(videoExtensions.Contains(ext)) { return FileCategory.Video; }
if(binaryExtensions.Contains(ext)) { return FileCategory.Binary; }
return FileCategory.Text;
}

Expand Down Expand Up @@ -387,7 +387,7 @@
return result;
}

string? _pendingFilePath;

Check warning on line 390 in src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Field 'GitDiffViewer._pendingFilePath' is never assigned to, and will always have its default value null

Check warning on line 390 in src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Field 'GitDiffViewer._pendingFilePath' is never assigned to, and will always have its default value null

protected override void OnParametersSet()
{
Expand Down Expand Up @@ -430,8 +430,8 @@
}
}

const long MaxImageBytes = 20 * 1024 * 1024;
const long MaxVideoBytes = 50 * 1024 * 1024;
const long maxImageBytes = 20 * 1024 * 1024;
const long maxVideoBytes = 50 * 1024 * 1024;

async Task LoadMediaAsync(string? filePath)
{
Expand All @@ -442,7 +442,7 @@
{
try
{
long maxBytes = _fileCategory == FileCategory.Image ? MaxImageBytes : MaxVideoBytes;
long maxBytes = _fileCategory == FileCategory.Image ? maxImageBytes : maxVideoBytes;
FileInfo info = new(filePath);
if(info.Length <= maxBytes)
{
Expand Down
154 changes: 154 additions & 0 deletions src/Cockpit/Features/Auth/AuthFeature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;

namespace Cockpit.Features.Auth;

/// <summary>
/// Handles the GitHub Copilot CLI login flow (OAuth device flow).
/// Supports both github.com and GitHub Enterprise hosts.
/// </summary>
public partial class AuthFeature
{
readonly ILogger<AuthFeature> _logger;

public record DeviceFlowInfo(string Url, string Code);

public AuthFeature(ILogger<AuthFeature> logger)
{
_logger = logger;
}

/// <summary>
/// Spawns <c>copilot login</c> and monitors stdout/stderr for the device flow URL and code.
/// Pass <paramref name="host"/> for enterprise accounts (e.g. <c>https://example.ghe.com</c>),
/// or <c>null</c> for github.com.
/// </summary>
public async Task<bool> RunLoginAsync(
string? host = null,
Action<DeviceFlowInfo>? onDeviceFlow = null,
CancellationToken cancellationToken = default)
{
List<string> args = ["login"];
if(!string.IsNullOrWhiteSpace(host) && !host.Equals("https://github.com", StringComparison.OrdinalIgnoreCase))
{
args.Add("--host");
args.Add(host);
}

_logger.LogInformation("Starting copilot login with args: {Args}", string.Join(" ", args));

ProcessStartInfo psi = new("copilot")
{
Arguments = string.Join(" ", args),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};

using Process? process = Process.Start(psi);
if(process is null)
{
_logger.LogError("Failed to start copilot login process");
return false;
}

string? deviceUrl = null;
string? deviceCode = null;
bool deviceFlowSent = false;

void ParseOutput(string data)
{
Match urlMatch = DeviceUrlRegex().Match(data);
Match codeMatch = DeviceCodeRegex().Match(data);

if(urlMatch.Success)
{
deviceUrl = urlMatch.Groups[1].Value;
}

if(codeMatch.Success)
{
deviceCode = codeMatch.Groups[1].Value;
}

if(deviceUrl is not null && deviceCode is not null && !deviceFlowSent)
{
deviceFlowSent = true;
_logger.LogInformation("Device flow received - URL: {Url}, Code: {Code}", deviceUrl, deviceCode);
onDeviceFlow?.Invoke(new DeviceFlowInfo(deviceUrl, deviceCode));
}
}

process.OutputDataReceived += (_, e) =>
{
if(e.Data is not null)
{
ParseOutput(e.Data);
}
};

process.ErrorDataReceived += (_, e) =>
{
if(e.Data is not null)
{
ParseOutput(e.Data);
}
};

process.BeginOutputReadLine();
process.BeginErrorReadLine();

await process.WaitForExitAsync(cancellationToken);

bool success = process.ExitCode == 0;
_logger.LogInformation("copilot login exited with code {ExitCode}", process.ExitCode);
return success;
}

/// <summary>
/// Reads the last-used enterprise host from <c>~/.copilot/config.json</c> for pre-filling the UI.
/// Returns <c>null</c> if not found or on any error.
/// </summary>
public static string? ReadHostFromConfig()
{
try
{
string configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".copilot", "config.json");

if(!File.Exists(configPath))
{
return null;
}

string json = File.ReadAllText(configPath);
using JsonDocument doc = JsonDocument.Parse(json, new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip
});

if(doc.RootElement.TryGetProperty("lastLoggedInUser", out JsonElement user) &&
user.TryGetProperty("host", out JsonElement host))
{
return host.GetString();
}
}
catch
{
// Fall through to default (github.com)
}

return null;
}

// Matches any host's device login URL (github.com or enterprise)
[GeneratedRegex(@"(https://\S+/login/device)")]
private static partial Regex DeviceUrlRegex();

[GeneratedRegex(@"code\s+([A-Z0-9]{4}-[A-Z0-9]{4})")]
private static partial Regex DeviceCodeRegex();
}
Loading
Loading