diff --git a/Tests/Cockpit.UnitTests/Features/TextToSpeech/FakeTextToSpeech.cs b/Tests/Cockpit.UnitTests/Features/TextToSpeech/FakeTextToSpeech.cs index d50810fa..8e666912 100644 --- a/Tests/Cockpit.UnitTests/Features/TextToSpeech/FakeTextToSpeech.cs +++ b/Tests/Cockpit.UnitTests/Features/TextToSpeech/FakeTextToSpeech.cs @@ -79,4 +79,5 @@ sealed class MockAppSettingsFeature : IAppSettingsFeature public bool KeepAlive { get; set; } public bool CanvasEnabled { get; set; } = true; public Dictionary SystemMessageSectionOverrides { get; set; } = []; + public string SessionListGroupBy { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } } \ No newline at end of file diff --git a/Tests/Cockpit.UnitTests/Features/UIState/UIStateFeatureTests.cs b/Tests/Cockpit.UnitTests/Features/UIState/UIStateFeatureTests.cs index b07f33ec..e8040cbb 100644 --- a/Tests/Cockpit.UnitTests/Features/UIState/UIStateFeatureTests.cs +++ b/Tests/Cockpit.UnitTests/Features/UIState/UIStateFeatureTests.cs @@ -683,4 +683,5 @@ sealed class MockAppSettings : IAppSettingsFeature public bool KeepAlive { get; set; } public bool CanvasEnabled { get; set; } = true; public Dictionary SystemMessageSectionOverrides { get; set; } = []; + public string SessionListGroupBy { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } } diff --git a/src/Cockpit/App.xaml.cs b/src/Cockpit/App.xaml.cs index 36037508..d72361f8 100644 --- a/src/Cockpit/App.xaml.cs +++ b/src/Cockpit/App.xaml.cs @@ -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; @@ -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 _mainPageLogger; public App( SplashFeature splashFeature, SessionFeature sessionFeature, - ThemeStateFeature themeStateFeature) + ThemeStateFeature themeStateFeature, + CopilotClientFeature copilotClientFeature, + AuthFeature authFeature, + ILogger 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 diff --git a/src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs b/src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs index b8c384ff..f70a1755 100644 --- a/src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs +++ b/src/Cockpit/Components/Controls/GitDiff/GitDiffViewer.razor.cs @@ -42,17 +42,17 @@ public GitDiffViewer(IJSRuntime jsRuntime) string FileName => string.IsNullOrEmpty(FilePath) ? string.Empty : Path.GetFileName(FilePath); - static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) + static readonly HashSet imageExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".webp", ".tiff", ".tif" }; - static readonly HashSet VideoExtensions = new(StringComparer.OrdinalIgnoreCase) + static readonly HashSet videoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mkv", ".m4v", ".ogv", ".ogg" }; - static readonly HashSet BinaryExtensions = new(StringComparer.OrdinalIgnoreCase) + static readonly HashSet binaryExtensions = new(StringComparer.OrdinalIgnoreCase) { ".dll", ".exe", ".pdb", ".obj", ".lib", ".bin", ".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz", ".jar", ".war", ".ear", ".class", @@ -69,9 +69,9 @@ static FileCategory GetFileCategory(string? filePath) } 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; } @@ -430,8 +430,8 @@ protected override void OnParametersSet() } } - 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) { @@ -442,7 +442,7 @@ async Task LoadMediaAsync(string? filePath) { try { - long maxBytes = _fileCategory == FileCategory.Image ? MaxImageBytes : MaxVideoBytes; + long maxBytes = _fileCategory == FileCategory.Image ? maxImageBytes : maxVideoBytes; FileInfo info = new(filePath); if(info.Length <= maxBytes) { diff --git a/src/Cockpit/Features/Auth/AuthFeature.cs b/src/Cockpit/Features/Auth/AuthFeature.cs new file mode 100644 index 00000000..4700c7ab --- /dev/null +++ b/src/Cockpit/Features/Auth/AuthFeature.cs @@ -0,0 +1,154 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Cockpit.Features.Auth; + +/// +/// Handles the GitHub Copilot CLI login flow (OAuth device flow). +/// Supports both github.com and GitHub Enterprise hosts. +/// +public partial class AuthFeature +{ + readonly ILogger _logger; + + public record DeviceFlowInfo(string Url, string Code); + + public AuthFeature(ILogger logger) + { + _logger = logger; + } + + /// + /// Spawns copilot login and monitors stdout/stderr for the device flow URL and code. + /// Pass for enterprise accounts (e.g. https://example.ghe.com), + /// or null for github.com. + /// + public async Task RunLoginAsync( + string? host = null, + Action? onDeviceFlow = null, + CancellationToken cancellationToken = default) + { + List 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; + } + + /// + /// Reads the last-used enterprise host from ~/.copilot/config.json for pre-filling the UI. + /// Returns null if not found or on any error. + /// + 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(); +} diff --git a/src/Cockpit/MainPage.xaml b/src/Cockpit/MainPage.xaml index e284ae9c..43e43053 100644 --- a/src/Cockpit/MainPage.xaml +++ b/src/Cockpit/MainPage.xaml @@ -17,7 +17,8 @@ ZIndex="1000"> + Spacing="0" + WidthRequest="420"> - + + + + + + + + + + + + + + + + +