diff --git a/.github/README.md b/.github/README.md
index 4e8c019..9763d2c 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -12,9 +12,9 @@
Mission control for AI-assisted development on Windows: GitHub issues, isolated git worktrees and embedded Claude Code terminals in one app.
- Report Bug
+ Report Bug
ยท
- Request Feature
+ Request Feature
@@ -35,6 +35,7 @@
Requirements
Build and run
Test
+ Release
Contributing
@@ -54,7 +55,7 @@ Kontext lets you work on all your repositories' issues at once and leave Claude
* **One worktree per issue.** Selecting an issue clones the repository when needed, creates a `features/{number}-{title}` branch in its own worktree, and opens an empty Claude Code session there. Nothing is asked of you, and nothing is shared between issues.
* **Know when Claude needs you.** Claude Code hooks report whether a session is working or waiting for input; the circle in the sidebar changes accordingly.
* **Resume where you left off.** Sessions are remembered per issue and continued with `claude --resume` after a restart.
-* **Open it anywhere.** Open a worktree in File Explorer, Windows Terminal or Visual Studio from the issue's header.
+* **Open it anywhere.** Open a worktree in File Explorer, Windows Terminal or its solution file (`.slnx` or `.sln`, in whatever app is registered for it) from the issue's header.
* **Clean up when done.** For a closed issue, Clear session removes the worktree and its branch.
* **Modern Architecture:** Built with SOLID principles and design patterns for maximum maintainability: a modular monolith with compiler-enforced module boundaries.
* **Native Windows look.** WinUI 3 with Mica, light and dark themes, and a terminal with zoom and mouse support.
@@ -81,7 +82,11 @@ Before beginning, ensure that your development environment is properly configure
* [.NET 10 SDK](https://dotnet.microsoft.com/download)
* [Git for Windows](https://git-scm.com/download/win) on your `PATH`
* The [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI (`claude`) on your `PATH`
-* A GitHub personal access token with the `repo` scope; Kontext asks for it on first start and stores it in Windows Credential Locker
+* A GitHub account; you sign in with it on first start (GitHub device flow) and Kontext stores the resulting token in Windows Credential Locker
+
+### Configuration
+
+Sign-in needs a GitHub OAuth App with Device Flow enabled. For local development, copy `src/Kontext/appsettings.local.example.json` to `src/Kontext/appsettings.local.json` (git-ignored) and set `GitHub:ClientId` to the app's client id (the client id is not a secret). CI writes the repository variable `KONTEXT_GITHUB_CLIENT_ID` into `appsettings.json` before building. Organisations that restrict OAuth apps must approve the app before their repositories and issues become available.
### Build and run
@@ -98,7 +103,28 @@ Stop a running instance before building again, because it locks the build output
dotnet test Kontext.slnx -c Release
```
-Warnings are treated as errors. The tests include integration tests that run real `git` against a temporary repository.
+Warnings are treated as errors. Tests are split by kind, one project per kind and module:
+
+* `Kontext.*.UnitTests` are fast and in-memory: no filesystem, processes or network.
+* `Kontext.*.IntegrationTests` touch the real world: the filesystem, a loopback HTTP server, or real `git` against a temporary repository.
+* `Kontext.ArchitectureTests` and `Kontext.CompositionTests` check the module boundaries and the dependency injection wiring.
+
+To run only the unit tests:
+
+```powershell
+Get-ChildItem tests -Recurse -Filter *.UnitTests.csproj | ForEach-Object { dotnet test $_.FullName -c Release }
+```
+
+### Release
+
+Publishing a GitHub release tagged like `v1.2.3` runs the tests, then builds a signed, self-contained x64 MSIX and attaches it to the release together with `Kontext.cer`, the public certificate. The tag sets the package version. ARM64 is not built because the embedded terminal control only ships x64 binaries.
+
+One-time setup:
+
+* Signing uses the `CERTIFICATE_BASE64` (the `.pfx`, base64 encoded) and `CERTIFICATE_PASSWORD` secrets. They are organization secrets of `buning-software`, shared with the other Buning Software apps, and this repository has to be allowed to use them (organization Settings, Secrets and variables, Actions). The certificate subject must equal `Publisher` in `src/Kontext/Package.appxmanifest` (`CN=BuningSoftware`). To create a new certificate, for example when it expires, use `generate_certificate.ps1` in the [Surveil repository](https://github.com/buning-software/surveil), update both organization secrets, and never commit the `.pfx`.
+* Set the repository variable `KONTEXT_GITHUB_CLIENT_ID`. An installed MSIX is read-only, so the client id has to be built in; the release fails without it.
+
+To install a release, first trust `Kontext.cer` once (open it, choose Install Certificate, Local Machine, Trusted People), then open the `.msix`. Kontext still keeps its data under `%LOCALAPPDATA%\Kontext`: the package turns off file redirection so your clones and worktrees stay visible to Explorer, terminals and IDEs.
## Contributing
@@ -128,13 +154,13 @@ Distributed under the GNU Affero General Public License v3.0 License. See `LICEN
-[contributors-shield]: https://img.shields.io/github/contributors/jellebuning/kontext.svg?style=for-the-badge
-[contributors-url]: https://github.com/jellebuning/kontext/graphs/contributors
-[forks-shield]: https://img.shields.io/github/forks/jellebuning/kontext.svg?style=for-the-badge
-[forks-url]: https://github.com/jellebuning/kontext/network/members
-[stars-shield]: https://img.shields.io/github/stars/jellebuning/kontext.svg?style=for-the-badge
-[stars-url]: https://github.com/jellebuning/kontext/stargazers
-[issues-shield]: https://img.shields.io/github/issues/jellebuning/kontext.svg?style=for-the-badge
-[issues-url]: https://github.com/jellebuning/kontext/issues
-[license-shield]: https://img.shields.io/github/license/jellebuning/kontext.svg?style=for-the-badge
-[license-url]: https://github.com/jellebuning/kontext/blob/main/LICENSE
+[contributors-shield]: https://img.shields.io/github/contributors/buning-software/kontext.svg?style=for-the-badge
+[contributors-url]: https://github.com/buning-software/kontext/graphs/contributors
+[forks-shield]: https://img.shields.io/github/forks/buning-software/kontext.svg?style=for-the-badge
+[forks-url]: https://github.com/buning-software/kontext/network/members
+[stars-shield]: https://img.shields.io/github/stars/buning-software/kontext.svg?style=for-the-badge
+[stars-url]: https://github.com/buning-software/kontext/stargazers
+[issues-shield]: https://img.shields.io/github/issues/buning-software/kontext.svg?style=for-the-badge
+[issues-url]: https://github.com/buning-software/kontext/issues
+[license-shield]: https://img.shields.io/github/license/buning-software/kontext.svg?style=for-the-badge
+[license-url]: https://github.com/buning-software/kontext/blob/main/LICENSE
diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml
index 23b414c..8d186b0 100644
--- a/.github/workflows/build-and-test.yaml
+++ b/.github/workflows/build-and-test.yaml
@@ -31,6 +31,17 @@ jobs:
- name: Install dependencies
run: dotnet restore
+ - name: Set GitHub OAuth client id
+ if: ${{ vars.KONTEXT_GITHUB_CLIENT_ID != '' }}
+ shell: pwsh
+ env:
+ CLIENT_ID: ${{ vars.KONTEXT_GITHUB_CLIENT_ID }}
+ run: |
+ $path = 'src/Kontext/appsettings.json'
+ $settings = Get-Content $path -Raw | ConvertFrom-Json
+ $settings.GitHub.ClientId = $env:CLIENT_ID
+ $settings | ConvertTo-Json -Depth 5 | Set-Content $path
+
- name: Build
run: dotnet build --no-restore --configuration Release
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..b326a76
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,143 @@
+name: Release
+
+on:
+ release:
+ types: [published]
+
+permissions:
+ contents: write
+
+jobs:
+ test:
+ uses: ./.github/workflows/build-and-test.yaml
+
+ release:
+ needs: test
+ runs-on: windows-latest
+ strategy:
+ matrix:
+ # ARM64 is not built yet: EasyWindowsTerminalControl.WinUI only ships x64 binaries.
+ platform: [x64]
+ include:
+ - platform: x64
+ rid: win-x64
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Extract version from tag
+ id: version
+ shell: pwsh
+ env:
+ TAG: ${{ github.ref_name }}
+ run: |
+ $displayVersion = $env:TAG.TrimStart('v', 'V')
+ if ($displayVersion -notmatch '^\d+\.\d+\.\d+$') {
+ Write-Error "Release tag '$env:TAG' must look like v1.2.3 (got '$displayVersion' after stripping the prefix)."
+ exit 1
+ }
+ $manifestVersion = $displayVersion + '.0'
+ "DISPLAY_VERSION=$displayVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
+ "VERSION=$manifestVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
+
+ - name: Update Package.appxmanifest version
+ shell: pwsh
+ env:
+ VERSION: ${{ steps.version.outputs.VERSION }}
+ run: |
+ # Only the package identity carries the app version; MinVersion and MaxVersionTested must stay as they are.
+ $manifest = "src\Kontext\Package.appxmanifest"
+ $pattern = '(]*?\sVersion=")\d+\.\d+\.\d+\.\d+(")'
+ $content = Get-Content $manifest -Raw
+ if ($content -notmatch $pattern) {
+ Write-Error "Could not find the version in $manifest."
+ exit 1
+ }
+ [regex]::Replace($content, $pattern, "`${1}$env:VERSION`${2}") | Set-Content $manifest -NoNewline
+
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Load nuget cache
+ uses: actions/cache@v6
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Set GitHub OAuth client id
+ shell: pwsh
+ env:
+ CLIENT_ID: ${{ vars.KONTEXT_GITHUB_CLIENT_ID }}
+ run: |
+ # An installed MSIX is read-only, so users cannot add appsettings.local.json: the id must be built in.
+ if ([string]::IsNullOrWhiteSpace($env:CLIENT_ID)) {
+ Write-Error "Set the KONTEXT_GITHUB_CLIENT_ID repository variable; without it the released app cannot sign in."
+ exit 1
+ }
+ $path = 'src/Kontext/appsettings.json'
+ $settings = Get-Content $path -Raw | ConvertFrom-Json
+ $settings.GitHub.ClientId = $env:CLIENT_ID
+ $settings | ConvertTo-Json -Depth 5 | Set-Content $path
+
+ - name: Decode signing certificate
+ shell: pwsh
+ env:
+ CERTIFICATE_BASE64: ${{ secrets.CERTIFICATE_BASE64 }}
+ run: |
+ $bytes = [Convert]::FromBase64String($env:CERTIFICATE_BASE64)
+ [IO.File]::WriteAllBytes("${{ github.workspace }}\cert.pfx", $bytes)
+
+ - name: Build and Package MSIX
+ shell: pwsh
+ env:
+ CERT_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
+ run: |
+ dotnet publish src\Kontext\Kontext.csproj `
+ -c Release `
+ -r ${{ matrix.rid }} `
+ -p:Platform=${{ matrix.platform }} `
+ -p:WindowsPackageType=MSIX `
+ -p:GenerateAppxPackageOnBuild=true `
+ -p:AppxPackageDir="${{ github.workspace }}\AppxPackages\" `
+ -p:AppxPackageSigningEnabled=true `
+ -p:PackageCertificateKeyFile="${{ github.workspace }}\cert.pfx" `
+ -p:PackageCertificatePassword=$env:CERT_PASSWORD `
+ -p:AppxBundle=Never
+
+ - name: Export public certificate
+ if: matrix.platform == 'x64'
+ shell: pwsh
+ env:
+ CERT_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
+ run: |
+ $securePassword = ConvertTo-SecureString -String $env:CERT_PASSWORD -AsPlainText -Force
+ $cert = Get-PfxCertificate -FilePath "${{ github.workspace }}\cert.pfx" -Password $securePassword
+ Export-Certificate -Cert $cert -FilePath "${{ github.workspace }}\Kontext.cer" | Out-Null
+
+ - name: Remove signing certificate
+ if: always()
+ shell: pwsh
+ run: Remove-Item -Path "${{ github.workspace }}\cert.pfx" -Force -ErrorAction SilentlyContinue
+
+ - name: Rename MSIX with platform suffix
+ shell: pwsh
+ run: |
+ $package = Get-ChildItem -Path "${{ github.workspace }}\AppxPackages" -Filter "*.msix" -Recurse |
+ Select-Object -First 1
+ $newName = Join-Path $package.DirectoryName "Kontext_${{ steps.version.outputs.DISPLAY_VERSION }}_${{ matrix.platform }}.msix"
+ Rename-Item -Path $package.FullName -NewName $newName
+
+ - name: Upload to GitHub Release
+ uses: softprops/action-gh-release@v3
+ with:
+ files: |
+ AppxPackages/**/*.msix
+ *.cer
diff --git a/.gitignore b/.gitignore
index 7531c1d..0651ac1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,4 +59,17 @@ nunit-*.xml
*.env.staging
*.idea
-.vs/
\ No newline at end of file
+.vs/
+# Local developer settings (GitHub OAuth client id)
+appsettings.local.json
+
+# MSIX app packages
+**/AppPackages/
+AppxPackages/
+*.msix
+*.msixbundle
+*.appx
+*.appxbundle
+
+# Code signing certificates
+*.pfx
diff --git a/Kontext.slnx b/Kontext.slnx
index 046b1ea..cf5e297 100644
--- a/Kontext.slnx
+++ b/Kontext.slnx
@@ -14,14 +14,17 @@
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/src/Kontext.Claude/Kontext.Claude.csproj b/src/Kontext.Claude/Kontext.Claude.csproj
index a4106cb..8b652d7 100644
--- a/src/Kontext.Claude/Kontext.Claude.csproj
+++ b/src/Kontext.Claude/Kontext.Claude.csproj
@@ -15,7 +15,8 @@
-
+
+
diff --git a/src/Kontext.Contracts/IEnterpriseServerPrompt.cs b/src/Kontext.Contracts/IEnterpriseServerPrompt.cs
new file mode 100644
index 0000000..0ed0ed5
--- /dev/null
+++ b/src/Kontext.Contracts/IEnterpriseServerPrompt.cs
@@ -0,0 +1,8 @@
+using Kontext.Domain;
+
+namespace Kontext.Contracts;
+
+public interface IEnterpriseServerPrompt
+{
+ Task AskAsync();
+}
diff --git a/src/Kontext.Contracts/IGitHubDeviceLogin.cs b/src/Kontext.Contracts/IGitHubDeviceLogin.cs
new file mode 100644
index 0000000..5637b50
--- /dev/null
+++ b/src/Kontext.Contracts/IGitHubDeviceLogin.cs
@@ -0,0 +1,10 @@
+using Kontext.Domain;
+
+namespace Kontext.Contracts;
+
+public interface IGitHubDeviceLogin
+{
+ Task StartAsync(GitHubServer? server = null, CancellationToken cancellationToken = default);
+
+ Task WaitForAccessTokenAsync(DeviceCodeChallenge challenge, CancellationToken cancellationToken = default);
+}
diff --git a/src/Kontext.Contracts/IGitHubService.cs b/src/Kontext.Contracts/IGitHubService.cs
index 8f2cd91..aef754f 100644
--- a/src/Kontext.Contracts/IGitHubService.cs
+++ b/src/Kontext.Contracts/IGitHubService.cs
@@ -5,11 +5,9 @@ namespace Kontext.Contracts;
public interface IGitHubService
{
- GitHubUser? CurrentUser { get; }
+ Task AuthenticateAsync(string host, string accessToken, CancellationToken cancellationToken = default);
- Task AuthenticateAsync(string accessToken, CancellationToken cancellationToken = default);
-
- void SignOut();
+ void SignOut(string host);
Task> GetIssuesAsync(RepositoryRef? repository = null, CancellationToken cancellationToken = default);
diff --git a/src/Kontext.Contracts/IGitHubTokenStore.cs b/src/Kontext.Contracts/IGitHubTokenStore.cs
index c8b9cde..de23912 100644
--- a/src/Kontext.Contracts/IGitHubTokenStore.cs
+++ b/src/Kontext.Contracts/IGitHubTokenStore.cs
@@ -2,9 +2,11 @@ namespace Kontext.Contracts;
public interface IGitHubTokenStore
{
- string? Load();
+ string? Load(string host);
- void Save(string token);
+ void Save(string host, string token);
- void Clear();
+ void Clear(string host);
+
+ string? FindEnterpriseHost();
}
diff --git a/src/Kontext.Domain/DeviceCodeChallenge.cs b/src/Kontext.Domain/DeviceCodeChallenge.cs
new file mode 100644
index 0000000..c0897ba
--- /dev/null
+++ b/src/Kontext.Domain/DeviceCodeChallenge.cs
@@ -0,0 +1,12 @@
+using Kontext.Kernel;
+
+namespace Kontext.Domain;
+
+public sealed record DeviceCodeChallenge(
+ string DeviceCode,
+ string UserCode,
+ Uri VerificationUri,
+ DateTimeOffset ExpiresAt,
+ TimeSpan PollInterval,
+ string Host = GitHubHosts.Default,
+ string? ClientId = null);
diff --git a/src/Kontext.Domain/EnterpriseCredentials.cs b/src/Kontext.Domain/EnterpriseCredentials.cs
new file mode 100644
index 0000000..22ec654
--- /dev/null
+++ b/src/Kontext.Domain/EnterpriseCredentials.cs
@@ -0,0 +1,3 @@
+namespace Kontext.Domain;
+
+public sealed record EnterpriseCredentials(GitHubServer Server, string Token);
diff --git a/src/Kontext.Domain/GitHubServer.cs b/src/Kontext.Domain/GitHubServer.cs
new file mode 100644
index 0000000..51854a0
--- /dev/null
+++ b/src/Kontext.Domain/GitHubServer.cs
@@ -0,0 +1,57 @@
+using Kontext.Kernel;
+
+namespace Kontext.Domain;
+
+public sealed record GitHubServer(string Host, string? ClientId = null)
+{
+ public static GitHubServer GitHub { get; } = new(GitHubHosts.Default);
+
+ public bool IsEnterprise => !GitHubHosts.IsDefault(Host);
+
+ public Uri WebUri => new($"https://{Host}/");
+
+ public Uri ApiUri => GitHubHosts.IsDefault(Host)
+ ? new Uri("https://api.github.com/")
+ : Host.EndsWith(".ghe.com", StringComparison.OrdinalIgnoreCase)
+ ? new Uri($"https://api.{Host}/")
+ : new Uri($"https://{Host}/api/v3/");
+
+ public static bool TryCreate(string? address, out GitHubServer? server, out string? error)
+ {
+ server = null;
+
+ if (string.IsNullOrWhiteSpace(address))
+ {
+ error = "Enter the address of your GitHub Enterprise server.";
+ return false;
+ }
+
+ var text = address.Trim();
+ if (!text.Contains("://", StringComparison.Ordinal))
+ {
+ text = "https://" + text;
+ }
+
+ if (!Uri.TryCreate(text, UriKind.Absolute, out var uri)
+ || uri.Scheme != Uri.UriSchemeHttps
+ || string.IsNullOrEmpty(uri.Host)
+ || !string.IsNullOrEmpty(uri.UserInfo)
+ || !uri.Host.Contains('.', StringComparison.Ordinal)
+ || uri.AbsolutePath.Trim('/').Length > 0)
+ {
+ error = "That is not a valid server address. Use something like github.yourcompany.com.";
+ return false;
+ }
+
+ var host = uri.Authority.ToLowerInvariant();
+ if (GitHubHosts.IsDefault(host) || host == "www.github.com")
+ {
+ error = "That is regular GitHub. Use \"Sign in with GitHub\" for github.com.";
+ return false;
+ }
+
+ server = new GitHubServer(host);
+ error = null;
+ return true;
+ }
+}
diff --git a/src/Kontext.Domain/GitHubUser.cs b/src/Kontext.Domain/GitHubUser.cs
index a95d0c3..6789008 100644
--- a/src/Kontext.Domain/GitHubUser.cs
+++ b/src/Kontext.Domain/GitHubUser.cs
@@ -1,3 +1,5 @@
+using Kontext.Kernel;
+
namespace Kontext.Domain;
-public sealed record GitHubUser(string Login, string? Name, Uri ProfileUrl);
+public sealed record GitHubUser(string Login, string? Name, Uri ProfileUrl, string Host = GitHubHosts.Default);
diff --git a/src/Kontext.Domain/WorkspaceTool.cs b/src/Kontext.Domain/WorkspaceTool.cs
index 6ba80dd..ba223b5 100644
--- a/src/Kontext.Domain/WorkspaceTool.cs
+++ b/src/Kontext.Domain/WorkspaceTool.cs
@@ -6,5 +6,5 @@ public enum WorkspaceTool
Terminal,
- VisualStudio,
+ Solution,
}
diff --git a/src/Kontext.ExternalTools/IVisualStudioLocator.cs b/src/Kontext.ExternalTools/IVisualStudioLocator.cs
deleted file mode 100644
index b772ead..0000000
--- a/src/Kontext.ExternalTools/IVisualStudioLocator.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Kontext.ExternalTools;
-
-internal interface IVisualStudioLocator
-{
- string? FindDevenv();
-}
diff --git a/src/Kontext.ExternalTools/Kontext.ExternalTools.csproj b/src/Kontext.ExternalTools/Kontext.ExternalTools.csproj
index e72078d..31ffcda 100644
--- a/src/Kontext.ExternalTools/Kontext.ExternalTools.csproj
+++ b/src/Kontext.ExternalTools/Kontext.ExternalTools.csproj
@@ -14,7 +14,7 @@
-
+
diff --git a/src/Kontext.ExternalTools/ServiceCollectionExtensions.cs b/src/Kontext.ExternalTools/ServiceCollectionExtensions.cs
index 3899a15..a75ad25 100644
--- a/src/Kontext.ExternalTools/ServiceCollectionExtensions.cs
+++ b/src/Kontext.ExternalTools/ServiceCollectionExtensions.cs
@@ -11,10 +11,9 @@ public static IServiceCollection AddKontextExternalTools(this IServiceCollection
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
return services;
}
diff --git a/src/Kontext.ExternalTools/SolutionTool.cs b/src/Kontext.ExternalTools/SolutionTool.cs
new file mode 100644
index 0000000..3f301ee
--- /dev/null
+++ b/src/Kontext.ExternalTools/SolutionTool.cs
@@ -0,0 +1,18 @@
+using Kontext.Domain;
+
+namespace Kontext.ExternalTools;
+
+internal sealed class SolutionTool(IFolderFiles files, IProcessStarter processes) : IWorkspaceTool
+{
+ private static readonly string[] SolutionPatterns = ["*.slnx", "*.sln"];
+
+ public WorkspaceTool Tool => WorkspaceTool.Solution;
+
+ public void Open(string folder)
+ {
+ var solution = SolutionPatterns.SelectMany(pattern => files.Find(folder, pattern)).FirstOrDefault()
+ ?? throw new ExternalToolException("No solution file (.slnx or .sln) was found in this folder.");
+
+ processes.Start(new ProcessLaunch(solution, [], UseShellExecute: true));
+ }
+}
diff --git a/src/Kontext.ExternalTools/VisualStudioTool.cs b/src/Kontext.ExternalTools/VisualStudioTool.cs
deleted file mode 100644
index 1e6b25c..0000000
--- a/src/Kontext.ExternalTools/VisualStudioTool.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using Kontext.Domain;
-
-namespace Kontext.ExternalTools;
-
-internal sealed class VisualStudioTool(IFolderFiles files, IVisualStudioLocator locator, IProcessStarter processes) : IWorkspaceTool
-{
- private static readonly string[] SolutionPatterns = ["*.slnx", "*.sln"];
-
- public WorkspaceTool Tool => WorkspaceTool.VisualStudio;
-
- public void Open(string folder)
- {
- var solution = SolutionPatterns.SelectMany(pattern => files.Find(folder, pattern)).FirstOrDefault();
- if (solution is not null)
- {
- processes.Start(new ProcessLaunch(solution, [], UseShellExecute: true));
- return;
- }
-
- var devenv = locator.FindDevenv()
- ?? throw new ExternalToolException("Visual Studio was not found on this computer.");
-
- processes.Start(new ProcessLaunch(devenv, [folder]));
- }
-}
diff --git a/src/Kontext.ExternalTools/VswhereVisualStudioLocator.cs b/src/Kontext.ExternalTools/VswhereVisualStudioLocator.cs
deleted file mode 100644
index 3aa5f41..0000000
--- a/src/Kontext.ExternalTools/VswhereVisualStudioLocator.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using System.Diagnostics;
-
-namespace Kontext.ExternalTools;
-
-internal sealed class VswhereVisualStudioLocator : IVisualStudioLocator
-{
- private static readonly string[] Arguments = ["-latest", "-prerelease", "-property", "productPath"];
-
- private static readonly string VswherePath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
- "Microsoft Visual Studio",
- "Installer",
- "vswhere.exe");
-
- public string? FindDevenv()
- {
- if (!File.Exists(VswherePath))
- {
- return null;
- }
-
- var startInfo = new ProcessStartInfo(VswherePath)
- {
- RedirectStandardOutput = true,
- UseShellExecute = false,
- CreateNoWindow = true,
- };
-
- foreach (var argument in Arguments)
- {
- startInfo.ArgumentList.Add(argument);
- }
-
- using var process = Process.Start(startInfo);
- if (process is null)
- {
- return null;
- }
-
- var path = process.StandardOutput.ReadToEnd().Trim();
- process.WaitForExit();
-
- return path.Length > 0 && File.Exists(path) ? path : null;
- }
-}
diff --git a/src/Kontext.GitAutomation/GitHubTokenAuthentication.cs b/src/Kontext.GitAutomation/GitHubTokenAuthentication.cs
index 9d5897f..d5481cf 100644
--- a/src/Kontext.GitAutomation/GitHubTokenAuthentication.cs
+++ b/src/Kontext.GitAutomation/GitHubTokenAuthentication.cs
@@ -1,5 +1,6 @@
using System.Text;
using Kontext.Contracts;
+using Kontext.Kernel;
namespace Kontext.GitAutomation;
@@ -7,22 +8,38 @@ internal sealed class GitHubTokenAuthentication(IGitHubTokenStore tokens) : IGit
{
public IReadOnlyDictionary EnvironmentVariables()
{
- var token = tokens.Load();
- if (string.IsNullOrWhiteSpace(token))
+ var headers = SignedInHosts()
+ .Select(host => (Host: host, Token: tokens.Load(host)))
+ .Where(account => !string.IsNullOrWhiteSpace(account.Token))
+ .ToList();
+
+ if (headers.Count == 0)
{
return new Dictionary();
}
- var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{token}"));
+ var environment = new Dictionary { ["GIT_CONFIG_COUNT"] = (headers.Count + 1).ToString(System.Globalization.CultureInfo.InvariantCulture) };
- return new Dictionary
+ for (var i = 0; i < headers.Count; i++)
{
- ["GIT_CONFIG_COUNT"] = "2",
- ["GIT_CONFIG_KEY_0"] = "http.https://github.com/.extraheader",
- ["GIT_CONFIG_VALUE_0"] = $"AUTHORIZATION: basic {basic}",
+ var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{headers[i].Token}"));
+ environment[$"GIT_CONFIG_KEY_{i}"] = $"http.https://{headers[i].Host}/.extraheader";
+ environment[$"GIT_CONFIG_VALUE_{i}"] = $"AUTHORIZATION: basic {basic}";
+ }
+
+ environment[$"GIT_CONFIG_KEY_{headers.Count}"] = "credential.helper";
+ environment[$"GIT_CONFIG_VALUE_{headers.Count}"] = string.Empty;
+
+ return environment;
+ }
- ["GIT_CONFIG_KEY_1"] = "credential.helper",
- ["GIT_CONFIG_VALUE_1"] = string.Empty,
- };
+ private IEnumerable SignedInHosts()
+ {
+ yield return GitHubHosts.Default;
+
+ if (tokens.FindEnterpriseHost() is { } enterprise)
+ {
+ yield return enterprise;
+ }
}
}
diff --git a/src/Kontext.GitAutomation/GitRemoteUrlParser.cs b/src/Kontext.GitAutomation/GitRemoteUrlParser.cs
index 9b86f3c..7ee8835 100644
--- a/src/Kontext.GitAutomation/GitRemoteUrlParser.cs
+++ b/src/Kontext.GitAutomation/GitRemoteUrlParser.cs
@@ -3,22 +3,26 @@
namespace Kontext.GitAutomation;
-internal static partial class GitRemoteUrlParser
+internal static class GitRemoteUrlParser
{
- public static RepositoryRef? Parse(string remoteUrl)
+ public static RepositoryRef? Parse(string remoteUrl, string host = GitHubHosts.Default)
{
- var match = UrlForm().Match(remoteUrl.Trim());
+ var escaped = Regex.Escape(host);
+ var text = remoteUrl.Trim();
+
+ var match = Regex.Match(
+ text,
+ $@"^(?:https?|git|ssh)://(?:[^@/]+@)?(?:www\.)?{escaped}(?::\d+)?/(?[^/]+)/(?[^/]+?)(?:\.git)?/?$",
+ RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+
if (!match.Success)
{
- match = ScpForm().Match(remoteUrl.Trim());
+ match = Regex.Match(
+ text,
+ $@"^(?:[^@/]+@)?{escaped}:(?[^/]+)/(?[^/]+?)(?:\.git)?/?$",
+ RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
- return match.Success ? new RepositoryRef(match.Groups["owner"].Value, match.Groups["name"].Value) : null;
+ return match.Success ? new RepositoryRef(match.Groups["owner"].Value, match.Groups["name"].Value, Host: host) : null;
}
-
- [GeneratedRegex(@"^(?:https?|git|ssh)://(?:[^@/]+@)?(?:www\.)?github\.com(?::\d+)?/(?[^/]+)/(?[^/]+?)(?:\.git)?/?$", RegexOptions.IgnoreCase)]
- private static partial Regex UrlForm();
-
- [GeneratedRegex(@"^(?:[^@/]+@)?github\.com:(?[^/]+)/(?[^/]+?)(?:\.git)?/?$", RegexOptions.IgnoreCase)]
- private static partial Regex ScpForm();
}
diff --git a/src/Kontext.GitAutomation/GitRepositoryInspector.cs b/src/Kontext.GitAutomation/GitRepositoryInspector.cs
index 3984518..d8c8ab6 100644
--- a/src/Kontext.GitAutomation/GitRepositoryInspector.cs
+++ b/src/Kontext.GitAutomation/GitRepositoryInspector.cs
@@ -10,7 +10,7 @@ internal sealed class GitRepositoryInspector(IGitProcess git) : IGitRepositoryIn
return result.Succeeded && result.Output.Length > 0 ? Path.GetFullPath(result.Output) : null;
}
- public async Task> GetGitHubRemotesAsync(string repositoryRoot, CancellationToken cancellationToken = default)
+ public async Task> GetGitHubRemotesAsync(string repositoryRoot, string host = GitHubHosts.Default, CancellationToken cancellationToken = default)
{
var result = await git.RunAsync(repositoryRoot, ["config", "--get-regexp", @"^remote\..*\.url$"], cancellationToken);
if (!result.Succeeded)
@@ -21,7 +21,7 @@ public async Task> GetGitHubRemotesAsync(string rep
return result.StandardOutput
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(line => line[(line.IndexOf(' ', StringComparison.Ordinal) + 1)..])
- .Select(GitRemoteUrlParser.Parse)
+ .Select(url => GitRemoteUrlParser.Parse(url, host))
.OfType()
.Distinct()
.ToList();
diff --git a/src/Kontext.GitAutomation/IGitRepositoryInspector.cs b/src/Kontext.GitAutomation/IGitRepositoryInspector.cs
index 556ec98..026a325 100644
--- a/src/Kontext.GitAutomation/IGitRepositoryInspector.cs
+++ b/src/Kontext.GitAutomation/IGitRepositoryInspector.cs
@@ -6,5 +6,5 @@ public interface IGitRepositoryInspector
{
Task FindRepositoryRootAsync(string folder, CancellationToken cancellationToken = default);
- Task> GetGitHubRemotesAsync(string repositoryRoot, CancellationToken cancellationToken = default);
+ Task> GetGitHubRemotesAsync(string repositoryRoot, string host = GitHubHosts.Default, CancellationToken cancellationToken = default);
}
diff --git a/src/Kontext.GitAutomation/JsonRepositoryCloneStore.cs b/src/Kontext.GitAutomation/JsonRepositoryCloneStore.cs
index 7f05a6e..389e0a7 100644
--- a/src/Kontext.GitAutomation/JsonRepositoryCloneStore.cs
+++ b/src/Kontext.GitAutomation/JsonRepositoryCloneStore.cs
@@ -17,7 +17,7 @@ internal sealed class JsonRepositoryCloneStore(GitAutomationOptions options) : I
lock (_gate)
{
- return Load().GetValueOrDefault(repository.FullName);
+ return Load().GetValueOrDefault(repository.DisplayName);
}
}
@@ -27,8 +27,8 @@ public IReadOnlyList List()
{
return Load().Keys
.Select(name => name.Split('/'))
- .Where(parts => parts is [{ Length: > 0 }, { Length: > 0 }])
- .Select(parts => new RepositoryRef(parts[0], parts[1]))
+ .Select(ToRepository)
+ .OfType()
.ToList();
}
}
@@ -41,13 +41,21 @@ public void Save(RepositoryRef repository, string localPath)
lock (_gate)
{
var clones = Load();
- clones[repository.FullName] = localPath;
+ clones[repository.DisplayName] = localPath;
Directory.CreateDirectory(options.StateDirectory);
File.WriteAllText(_filePath, JsonSerializer.Serialize(clones, JsonOptions));
}
}
+ // Keys are "owner/name" for github.com (the original format) and "host/owner/name" for any other server.
+ private static RepositoryRef? ToRepository(string[] parts) => parts switch
+ {
+ [{ Length: > 0 } owner, { Length: > 0 } name] => new RepositoryRef(owner, name),
+ [{ Length: > 0 } host, { Length: > 0 } owner, { Length: > 0 } name] => new RepositoryRef(owner, name, Host: host),
+ _ => null,
+ };
+
private Dictionary Load()
{
var empty = new Dictionary(StringComparer.OrdinalIgnoreCase);
diff --git a/src/Kontext.GitAutomation/Kontext.GitAutomation.csproj b/src/Kontext.GitAutomation/Kontext.GitAutomation.csproj
index eb37bb2..d3a67c8 100644
--- a/src/Kontext.GitAutomation/Kontext.GitAutomation.csproj
+++ b/src/Kontext.GitAutomation/Kontext.GitAutomation.csproj
@@ -15,7 +15,8 @@
-
+
+
diff --git a/src/Kontext.GitAutomation/PathSegment.cs b/src/Kontext.GitAutomation/PathSegment.cs
new file mode 100644
index 0000000..4977cd4
--- /dev/null
+++ b/src/Kontext.GitAutomation/PathSegment.cs
@@ -0,0 +1,10 @@
+namespace Kontext.GitAutomation;
+
+internal static class PathSegment
+{
+ public static string Safe(string segment)
+ {
+ var invalid = Path.GetInvalidFileNameChars();
+ return string.Concat(segment.Select(character => invalid.Contains(character) ? '_' : character));
+ }
+}
diff --git a/src/Kontext.GitAutomation/RepositoryCloneResolver.cs b/src/Kontext.GitAutomation/RepositoryCloneResolver.cs
index d1167c0..6a7d964 100644
--- a/src/Kontext.GitAutomation/RepositoryCloneResolver.cs
+++ b/src/Kontext.GitAutomation/RepositoryCloneResolver.cs
@@ -27,10 +27,10 @@ public async Task ResolveAsync(RepositoryRef repository, Cancella
var root = await inspector.FindRepositoryRootAsync(cloned, cancellationToken)
?? throw new GitAutomationException($"'{cloned}' is not inside a git repository.");
- var remotes = await inspector.GetGitHubRemotesAsync(root, cancellationToken);
+ var remotes = await inspector.GetGitHubRemotesAsync(root, repository.Host, cancellationToken);
if (!remotes.Any(remote => string.Equals(remote.FullName, repository.FullName, StringComparison.OrdinalIgnoreCase)))
{
- var found = remotes.Count == 0 ? "has no GitHub remote" : $"is a clone of {string.Join(", ", remotes.Select(remote => remote.FullName))}";
+ var found = remotes.Count == 0 ? $"has no {repository.Host} remote" : $"is a clone of {string.Join(", ", remotes.Select(remote => remote.FullName))}";
throw new GitAutomationException($"'{root}' {found}, not {repository.FullName}.");
}
diff --git a/src/Kontext.GitAutomation/RepositoryCloner.cs b/src/Kontext.GitAutomation/RepositoryCloner.cs
index b984054..61eee00 100644
--- a/src/Kontext.GitAutomation/RepositoryCloner.cs
+++ b/src/Kontext.GitAutomation/RepositoryCloner.cs
@@ -11,7 +11,7 @@ public async Task CloneAsync(RepositoryRef repository, CancellationToken
{
ArgumentNullException.ThrowIfNull(repository);
- var target = Path.Combine(options.ClonesRoot, repository.Owner, repository.Name);
+ var target = TargetFor(repository);
var gate = _gates.GetOrAdd(target, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
@@ -30,7 +30,7 @@ public async Task CloneAsync(RepositoryRef repository, CancellationToken
var parent = Path.GetDirectoryName(target)!;
Directory.CreateDirectory(parent);
- await git.RunCheckedAsync(parent, ["clone", $"https://github.com/{repository.Owner}/{repository.Name}.git", target], cancellationToken);
+ await git.RunCheckedAsync(parent, ["clone", $"https://{repository.Host}/{repository.Owner}/{repository.Name}.git", target], cancellationToken);
return target;
}
finally
@@ -38,4 +38,15 @@ public async Task CloneAsync(RepositoryRef repository, CancellationToken
gate.Release();
}
}
+
+ private string TargetFor(RepositoryRef repository)
+ {
+ var owned = Path.Combine(options.ClonesRoot, repository.Owner, repository.Name);
+
+ // github.com keeps its original layout; every other server gets a folder named after it,
+ // which can never collide with a github.com owner because those cannot contain dots.
+ return GitHubHosts.IsDefault(repository.Host)
+ ? owned
+ : Path.Combine(options.ClonesRoot, PathSegment.Safe(repository.Host), repository.Owner, repository.Name);
+ }
}
diff --git a/src/Kontext.GitAutomation/WorktreePathProvider.cs b/src/Kontext.GitAutomation/WorktreePathProvider.cs
index 57c657a..8fbaf93 100644
--- a/src/Kontext.GitAutomation/WorktreePathProvider.cs
+++ b/src/Kontext.GitAutomation/WorktreePathProvider.cs
@@ -11,12 +11,10 @@ public string GetPath(RepositoryRef repository, string branchName)
var folder = branchName[(branchName.LastIndexOf('/') + 1)..];
- return Path.Combine(options.WorktreesRoot, Safe(repository.Owner), Safe(repository.Name), Safe(folder));
- }
+ var root = GitHubHosts.IsDefault(repository.Host)
+ ? options.WorktreesRoot
+ : Path.Combine(options.WorktreesRoot, PathSegment.Safe(repository.Host));
- private static string Safe(string segment)
- {
- var invalid = Path.GetInvalidFileNameChars();
- return string.Concat(segment.Select(character => invalid.Contains(character) ? '_' : character));
+ return Path.Combine(root, PathSegment.Safe(repository.Owner), PathSegment.Safe(repository.Name), PathSegment.Safe(folder));
}
}
diff --git a/src/Kontext.GitHub/DeviceFlowResponses.cs b/src/Kontext.GitHub/DeviceFlowResponses.cs
new file mode 100644
index 0000000..908876b
--- /dev/null
+++ b/src/Kontext.GitHub/DeviceFlowResponses.cs
@@ -0,0 +1,90 @@
+using System.Text.Json;
+using Kontext.Domain;
+
+namespace Kontext.GitHub;
+
+internal static class DeviceFlowResponses
+{
+ private const int DefaultIntervalSeconds = 5;
+
+ public static DeviceCodeChallenge ParseDeviceCode(string json, DateTimeOffset now)
+ {
+ using var document = Parse(json);
+ var root = document.RootElement;
+
+ if (Text(root, "error") is { } error)
+ {
+ throw new GitHubException(Describe(error, Text(root, "error_description")));
+ }
+
+ var deviceCode = Text(root, "device_code");
+ var userCode = Text(root, "user_code");
+ var verificationUri = Text(root, "verification_uri");
+ if (string.IsNullOrWhiteSpace(deviceCode)
+ || string.IsNullOrWhiteSpace(userCode)
+ || !Uri.TryCreate(verificationUri, UriKind.Absolute, out var uri)
+ || !root.TryGetProperty("expires_in", out var expiresIn)
+ || !expiresIn.TryGetInt32(out var expiresInSeconds))
+ {
+ throw Unexpected();
+ }
+
+ var interval = root.TryGetProperty("interval", out var intervalValue) && intervalValue.TryGetInt32(out var seconds) && seconds > 0
+ ? seconds
+ : DefaultIntervalSeconds;
+
+ return new DeviceCodeChallenge(deviceCode, userCode, uri, now.AddSeconds(expiresInSeconds), TimeSpan.FromSeconds(interval));
+ }
+
+ public static DeviceTokenResult ParseTokenPoll(string json)
+ {
+ using var document = Parse(json);
+ var root = document.RootElement;
+
+ if (Text(root, "access_token") is { Length: > 0 } token)
+ {
+ return new DeviceTokenResult(DeviceTokenStatus.Granted, Token: token);
+ }
+
+ var error = Text(root, "error");
+ var description = Text(root, "error_description");
+ return error switch
+ {
+ "authorization_pending" => new DeviceTokenResult(DeviceTokenStatus.Pending),
+ "slow_down" => new DeviceTokenResult(
+ DeviceTokenStatus.SlowDown,
+ IntervalSeconds: root.TryGetProperty("interval", out var interval) && interval.TryGetInt32(out var seconds) ? seconds : null),
+ "expired_token" => new DeviceTokenResult(DeviceTokenStatus.Expired),
+ "access_denied" => new DeviceTokenResult(DeviceTokenStatus.Denied),
+ null => throw Unexpected(),
+ _ => new DeviceTokenResult(DeviceTokenStatus.Failed, Message: Describe(error, description)),
+ };
+ }
+
+ private static string Describe(string error, string? description) =>
+ string.IsNullOrWhiteSpace(description) ? $"GitHub sign-in failed ({error})." : $"GitHub sign-in failed: {description}";
+
+ private static JsonDocument Parse(string json)
+ {
+ try
+ {
+ var document = JsonDocument.Parse(json);
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ document.Dispose();
+ throw Unexpected();
+ }
+
+ return document;
+ }
+ catch (JsonException ex)
+ {
+ throw new GitHubException("GitHub sent a response Kontext could not read.", ex);
+ }
+ }
+
+ private static string? Text(JsonElement root, string name) =>
+ root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
+
+ private static GitHubException Unexpected() => new("GitHub sent a response Kontext could not read.");
+}
diff --git a/src/Kontext.GitHub/DeviceTokenResult.cs b/src/Kontext.GitHub/DeviceTokenResult.cs
new file mode 100644
index 0000000..0189621
--- /dev/null
+++ b/src/Kontext.GitHub/DeviceTokenResult.cs
@@ -0,0 +1,17 @@
+namespace Kontext.GitHub;
+
+internal enum DeviceTokenStatus
+{
+ Granted,
+ Pending,
+ SlowDown,
+ Expired,
+ Denied,
+ Failed,
+}
+
+internal sealed record DeviceTokenResult(
+ DeviceTokenStatus Status,
+ string? Token = null,
+ int? IntervalSeconds = null,
+ string? Message = null);
diff --git a/src/Kontext.GitHub/GitHubDeviceLogin.cs b/src/Kontext.GitHub/GitHubDeviceLogin.cs
new file mode 100644
index 0000000..65c79a5
--- /dev/null
+++ b/src/Kontext.GitHub/GitHubDeviceLogin.cs
@@ -0,0 +1,79 @@
+using Kontext.Contracts;
+using Kontext.Domain;
+using Kontext.Kernel;
+
+namespace Kontext.GitHub;
+
+internal sealed class GitHubDeviceLogin(GitHubOptions options, GitHubFormPoster poster, TimeProvider time) : IGitHubDeviceLogin
+{
+ private const string DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code";
+ private const string NotConfigured = "GitHub sign-in is not configured. Set GitHub:ClientId to the client id of a GitHub OAuth App with device flow enabled, in appsettings.local.json next to Kontext.exe, then restart Kontext.";
+
+ private static readonly TimeSpan SlowDownStep = TimeSpan.FromSeconds(5);
+
+ public async Task StartAsync(GitHubServer? server = null, CancellationToken cancellationToken = default)
+ {
+ var host = server?.Host ?? GitHubHosts.Default;
+ var clientId = RequireClientId(server?.ClientId);
+ var body = await poster.PostAsync(DeviceCodeUrl(host), new Dictionary { ["client_id"] = clientId, ["scope"] = "repo" }, cancellationToken);
+
+ var challenge = DeviceFlowResponses.ParseDeviceCode(body, time.GetUtcNow());
+ if (challenge.VerificationUri.Scheme != Uri.UriSchemeHttps
+ || !string.Equals(challenge.VerificationUri.Authority, host, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new GitHubException($"{Label(host)} sent an unexpected verification address, so the sign-in was stopped.");
+ }
+
+ return challenge with { Host = host, ClientId = clientId };
+ }
+
+ public async Task WaitForAccessTokenAsync(DeviceCodeChallenge challenge, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(challenge);
+ var clientId = RequireClientId(challenge.ClientId);
+ var accessTokenUrl = AccessTokenUrl(challenge.Host);
+
+ var interval = challenge.PollInterval;
+ while (true)
+ {
+ await Task.Delay(interval, time, cancellationToken);
+ if (time.GetUtcNow() >= challenge.ExpiresAt)
+ {
+ throw Expired();
+ }
+
+ var form = new Dictionary { ["client_id"] = clientId, ["device_code"] = challenge.DeviceCode, ["grant_type"] = DeviceGrantType };
+ var result = DeviceFlowResponses.ParseTokenPoll(await poster.PostAsync(accessTokenUrl, form, cancellationToken));
+ switch (result.Status)
+ {
+ case DeviceTokenStatus.Granted:
+ return result.Token!;
+ case DeviceTokenStatus.Pending:
+ break;
+ case DeviceTokenStatus.SlowDown:
+ interval = result.IntervalSeconds is > 0 and var seconds ? TimeSpan.FromSeconds(seconds) : interval + SlowDownStep;
+ break;
+ case DeviceTokenStatus.Expired:
+ throw Expired();
+ case DeviceTokenStatus.Denied:
+ throw new AuthenticationFailedException($"Sign-in was cancelled on {Label(challenge.Host)}.");
+ default:
+ throw new GitHubException($"{result.Message} Check the client id and that device flow is enabled for the GitHub OAuth App.");
+ }
+ }
+ }
+
+ private static string Label(string host) => GitHubHosts.IsDefault(host) ? "GitHub" : host;
+
+ private static string DeviceCodeUrl(string host) => $"https://{host}/login/device/code";
+
+ private static string AccessTokenUrl(string host) => $"https://{host}/login/oauth/access_token";
+
+ private static AuthenticationFailedException Expired() => new("The sign-in code expired. Please try again.");
+
+ private string RequireClientId(string? explicitClientId)
+ {
+ var clientId = string.IsNullOrWhiteSpace(explicitClientId) ? options.ClientId : explicitClientId;
+ return string.IsNullOrWhiteSpace(clientId) ? throw new GitHubException(NotConfigured) : clientId;
+ }
+}
diff --git a/src/Kontext.GitHub/GitHubFormPoster.cs b/src/Kontext.GitHub/GitHubFormPoster.cs
new file mode 100644
index 0000000..527c971
--- /dev/null
+++ b/src/Kontext.GitHub/GitHubFormPoster.cs
@@ -0,0 +1,29 @@
+using System.Net.Http.Headers;
+
+namespace Kontext.GitHub;
+
+internal sealed class GitHubFormPoster(HttpClient http)
+{
+ public async Task PostAsync(string url, IReadOnlyDictionary form, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(form) };
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ request.Headers.UserAgent.Add(new ProductInfoHeaderValue("Kontext", "1.0"));
+
+ using var response = await http.SendAsync(request, cancellationToken);
+ var body = await response.Content.ReadAsStringAsync(cancellationToken);
+ if (!response.IsSuccessStatusCode && !body.TrimStart().StartsWith('{'))
+ {
+ throw new GitHubException($"GitHub returned an error (HTTP {(int)response.StatusCode}) while signing in.");
+ }
+
+ return body;
+ }
+ catch (HttpRequestException ex)
+ {
+ throw new GitHubException("Could not reach GitHub. Check your internet connection.", ex);
+ }
+ }
+}
diff --git a/src/Kontext.GitHub/GitHubOptions.cs b/src/Kontext.GitHub/GitHubOptions.cs
new file mode 100644
index 0000000..d68faaa
--- /dev/null
+++ b/src/Kontext.GitHub/GitHubOptions.cs
@@ -0,0 +1,3 @@
+namespace Kontext.GitHub;
+
+public sealed record GitHubOptions(string? ClientId);
diff --git a/src/Kontext.GitHub/GitHubService.cs b/src/Kontext.GitHub/GitHubService.cs
index 55f20f5..fe71f7a 100644
--- a/src/Kontext.GitHub/GitHubService.cs
+++ b/src/Kontext.GitHub/GitHubService.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using Kontext.Contracts;
using Kontext.Domain;
using Kontext.Kernel;
@@ -13,35 +14,77 @@ internal sealed class GitHubService : IGitHubService
private static readonly ProductHeaderValue ProductHeader = new("Kontext");
- private GitHubClient _client = new(ProductHeader);
+ private readonly ConcurrentDictionary _clients = new(StringComparer.OrdinalIgnoreCase);
- public GitHubUser? CurrentUser { get; private set; }
-
- public async Task AuthenticateAsync(string accessToken, CancellationToken cancellationToken = default)
+ public async Task AuthenticateAsync(string host, string accessToken, CancellationToken cancellationToken = default)
{
+ ArgumentException.ThrowIfNullOrWhiteSpace(host);
ArgumentException.ThrowIfNullOrWhiteSpace(accessToken);
- var candidate = new GitHubClient(ProductHeader) { Credentials = new Credentials(accessToken) };
+ var candidate = CreateClient(host, accessToken);
var user = await OctokitCall.RunAsync(() => candidate.User.Current(), cancellationToken);
- _client = candidate;
- CurrentUser = new GitHubUser(user.Login, user.Name, new Uri(user.HtmlUrl));
- return CurrentUser;
+ _clients[host] = candidate;
+ return new GitHubUser(user.Login, user.Name, new Uri(user.HtmlUrl), host);
}
- public void SignOut()
+ public void SignOut(string host) => _clients.TryRemove(host, out _);
+
+ public async Task> GetIssuesAsync(RepositoryRef? repository = null, CancellationToken cancellationToken = default)
{
- _client = new GitHubClient(ProductHeader);
- CurrentUser = null;
+ if (repository is not null)
+ {
+ return await GetRepositoryIssuesAsync(ClientFor(repository.Host), repository, cancellationToken);
+ }
+
+ var signedIn = _clients.ToArray();
+ if (signedIn.Length == 0)
+ {
+ return [];
+ }
+
+ var attempts = await Task.WhenAll(signedIn.Select(entry => TryGetAllIssuesAsync(entry.Value, entry.Key, cancellationToken)));
+
+ // One unreachable server (a company server that needs a VPN, say) must not hide the issues
+ // of the other account, so only fail when every signed-in account failed.
+ if (attempts.All(attempt => attempt.Failure is not null))
+ {
+ throw attempts[0].Failure!;
+ }
+
+ return attempts.SelectMany(attempt => attempt.Issues).OrderByDescending(issue => issue.UpdatedAt).ToList();
}
- public async Task> GetIssuesAsync(RepositoryRef? repository = null, CancellationToken cancellationToken = default)
+ public async Task GetIssueAsync(RepositoryRef repository, int number, CancellationToken cancellationToken = default)
{
- var client = _client;
+ ArgumentNullException.ThrowIfNull(repository);
+
+ var client = ClientFor(repository.Host);
+ var issue = await OctokitCall.RunAsync(() => client.Issue.Get(repository.Owner, repository.Name, number), cancellationToken);
- var issues = repository is null
- ? await OctokitCall.RunAsync(
+ return IssueMapper.Map(issue, repository, repository.Host)
+ ?? throw new GitHubException($"{repository.FullName}#{number} is a pull request, not an issue.");
+ }
+
+ private static GitHubClient CreateClient(string host, string accessToken)
+ {
+ var client = GitHubHosts.IsDefault(host)
+ ? new GitHubClient(ProductHeader)
+ : new GitHubClient(ProductHeader, new GitHubServer(host).ApiUri);
+
+ client.Credentials = new Credentials(accessToken);
+ return client;
+ }
+
+ private GitHubClient ClientFor(string host) =>
+ _clients.TryGetValue(host, out var client) ? client : throw new GitHubException($"You are not signed in to {host}.");
+
+ private static async Task<(IReadOnlyList Issues, KontextException? Failure)> TryGetAllIssuesAsync(GitHubClient client, string host, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var issues = await OctokitCall.RunAsync(
() => client.Issue.GetAllForCurrent(
new IssueRequest
{
@@ -51,34 +94,37 @@ public async Task> GetIssuesAsync(RepositoryRef? repo
SortDirection = SortDirection.Descending,
},
new ApiOptions { PageSize = PageSize, PageCount = AllRepositoriesPageCount }),
- cancellationToken)
- : await OctokitCall.RunAsync(
- () => client.Issue.GetAllForRepository(
- repository.Owner,
- repository.Name,
- new RepositoryIssueRequest
- {
- State = ItemStateFilter.Open,
- SortProperty = IssueSort.Updated,
- SortDirection = SortDirection.Descending,
- },
- new ApiOptions { PageSize = PageSize }),
cancellationToken);
- return issues
- .Select(issue => IssueMapper.Map(issue, repository))
- .OfType()
- .ToList();
+ return (Map(issues, null, host), null);
+ }
+ catch (KontextException ex)
+ {
+ return ([], ex);
+ }
}
- public async Task GetIssueAsync(RepositoryRef repository, int number, CancellationToken cancellationToken = default)
+ private static async Task> GetRepositoryIssuesAsync(GitHubClient client, RepositoryRef repository, CancellationToken cancellationToken)
{
- ArgumentNullException.ThrowIfNull(repository);
-
- var client = _client;
- var issue = await OctokitCall.RunAsync(() => client.Issue.Get(repository.Owner, repository.Name, number), cancellationToken);
-
- return IssueMapper.Map(issue, repository)
- ?? throw new GitHubException($"{repository.FullName}#{number} is a pull request, not an issue.");
+ var issues = await OctokitCall.RunAsync(
+ () => client.Issue.GetAllForRepository(
+ repository.Owner,
+ repository.Name,
+ new RepositoryIssueRequest
+ {
+ State = ItemStateFilter.Open,
+ SortProperty = IssueSort.Updated,
+ SortDirection = SortDirection.Descending,
+ },
+ new ApiOptions { PageSize = PageSize }),
+ cancellationToken);
+
+ return Map(issues, repository, repository.Host);
}
+
+ private static List Map(IEnumerable issues, RepositoryRef? repository, string host) =>
+ issues
+ .Select(issue => IssueMapper.Map(issue, repository, host))
+ .OfType()
+ .ToList();
}
diff --git a/src/Kontext.GitHub/IssueMapper.cs b/src/Kontext.GitHub/IssueMapper.cs
index 337d3a3..fce5bde 100644
--- a/src/Kontext.GitHub/IssueMapper.cs
+++ b/src/Kontext.GitHub/IssueMapper.cs
@@ -6,7 +6,7 @@ namespace Kontext.GitHub;
internal static class IssueMapper
{
- public static GitHubIssue? Map(Issue issue, RepositoryRef? requestedRepository)
+ public static GitHubIssue? Map(Issue issue, RepositoryRef? requestedRepository, string host = GitHubHosts.Default)
{
if (issue.PullRequest is not null)
{
@@ -14,8 +14,8 @@ internal static class IssueMapper
}
var repository = issue.Repository is { } source
- ? new RepositoryRef(source.Owner.Login, source.Name)
- : requestedRepository ?? RepositoryFromIssueUrl(issue.HtmlUrl);
+ ? new RepositoryRef(source.Owner.Login, source.Name, Host: host)
+ : requestedRepository ?? RepositoryFromIssueUrl(issue.HtmlUrl, host);
return new GitHubIssue(
repository,
@@ -29,9 +29,9 @@ internal static class IssueMapper
issue.UpdatedAt ?? issue.CreatedAt);
}
- private static RepositoryRef RepositoryFromIssueUrl(string htmlUrl)
+ private static RepositoryRef RepositoryFromIssueUrl(string htmlUrl, string host)
{
var segments = new Uri(htmlUrl).AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
- return new RepositoryRef(segments[0], segments[1]);
+ return new RepositoryRef(segments[0], segments[1], Host: host);
}
}
diff --git a/src/Kontext.GitHub/Kontext.GitHub.csproj b/src/Kontext.GitHub/Kontext.GitHub.csproj
index 9de96aa..e297934 100644
--- a/src/Kontext.GitHub/Kontext.GitHub.csproj
+++ b/src/Kontext.GitHub/Kontext.GitHub.csproj
@@ -16,7 +16,7 @@
-
+
diff --git a/src/Kontext.GitHub/OctokitCall.cs b/src/Kontext.GitHub/OctokitCall.cs
index d5d5316..cd80e50 100644
--- a/src/Kontext.GitHub/OctokitCall.cs
+++ b/src/Kontext.GitHub/OctokitCall.cs
@@ -12,7 +12,7 @@ public static async Task RunAsync(Func> call, CancellationToken ca
}
catch (Octokit.AuthorizationException ex)
{
- throw new AuthenticationFailedException("GitHub rejected this token. Check that it is correct, not expired, and not revoked.", ex);
+ throw new AuthenticationFailedException("GitHub rejected this sign-in. It may have expired or been revoked.", ex);
}
catch (Octokit.ApiException ex)
{
diff --git a/src/Kontext.GitHub/ServiceCollectionExtensions.cs b/src/Kontext.GitHub/ServiceCollectionExtensions.cs
index fe01081..7311bba 100644
--- a/src/Kontext.GitHub/ServiceCollectionExtensions.cs
+++ b/src/Kontext.GitHub/ServiceCollectionExtensions.cs
@@ -5,8 +5,12 @@ namespace Kontext.GitHub;
public static class ServiceCollectionExtensions
{
- public static IServiceCollection AddKontextGitHub(this IServiceCollection services)
+ public static IServiceCollection AddKontextGitHub(this IServiceCollection services, GitHubOptions options)
{
+ services.AddSingleton(options);
+ services.AddSingleton(new HttpClient());
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
return services;
}
diff --git a/src/Kontext.Kernel/GitHubHosts.cs b/src/Kontext.Kernel/GitHubHosts.cs
new file mode 100644
index 0000000..b3e289d
--- /dev/null
+++ b/src/Kontext.Kernel/GitHubHosts.cs
@@ -0,0 +1,8 @@
+namespace Kontext.Kernel;
+
+public static class GitHubHosts
+{
+ public const string Default = "github.com";
+
+ public static bool IsDefault(string? host) => string.Equals(host, Default, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/Kontext.Kernel/IssueKey.cs b/src/Kontext.Kernel/IssueKey.cs
index 826cf5c..b493680 100644
--- a/src/Kontext.Kernel/IssueKey.cs
+++ b/src/Kontext.Kernel/IssueKey.cs
@@ -2,14 +2,15 @@ namespace Kontext.Kernel;
public readonly record struct IssueKey
{
- public IssueKey(string repositoryFullName, int number)
+ public IssueKey(string repositoryFullName, int number, string host = GitHubHosts.Default)
{
RepositoryFullName = repositoryFullName.ToUpperInvariant();
Number = number;
+ Host = host.ToUpperInvariant();
}
public IssueKey(RepositoryRef repository, int number)
- : this(repository.FullName, number)
+ : this(repository.FullName, number, repository.Host)
{
}
@@ -17,5 +18,7 @@ public IssueKey(RepositoryRef repository, int number)
public int Number { get; }
- public override string ToString() => $"{RepositoryFullName}#{Number}";
+ public string Host { get; }
+
+ public override string ToString() => GitHubHosts.IsDefault(Host) ? $"{RepositoryFullName}#{Number}" : $"{Host}/{RepositoryFullName}#{Number}";
}
diff --git a/src/Kontext.Kernel/RepositoryRef.cs b/src/Kontext.Kernel/RepositoryRef.cs
index 2fa89fc..4b366b1 100644
--- a/src/Kontext.Kernel/RepositoryRef.cs
+++ b/src/Kontext.Kernel/RepositoryRef.cs
@@ -1,6 +1,8 @@
namespace Kontext.Kernel;
-public sealed record RepositoryRef(string Owner, string Name, string? LocalPath = null)
+public sealed record RepositoryRef(string Owner, string Name, string? LocalPath = null, string Host = GitHubHosts.Default)
{
public string FullName => $"{Owner}/{Name}";
+
+ public string DisplayName => GitHubHosts.IsDefault(Host) ? FullName : $"{Host}/{FullName}";
}
diff --git a/src/Kontext.Presentation/IssueDetailViewModel.cs b/src/Kontext.Presentation/IssueDetailViewModel.cs
index 50ccea5..c9d7435 100644
--- a/src/Kontext.Presentation/IssueDetailViewModel.cs
+++ b/src/Kontext.Presentation/IssueDetailViewModel.cs
@@ -52,7 +52,7 @@ public IssueDetailViewModel(
public string Title => Item?.Title ?? string.Empty;
- public string RepositoryName => Item?.Issue.Repository.FullName ?? string.Empty;
+ public string RepositoryName => Item?.Issue.Repository.DisplayName ?? string.Empty;
public string NumberText => Item?.NumberText ?? string.Empty;
diff --git a/src/Kontext.Presentation/IssueSearch.cs b/src/Kontext.Presentation/IssueSearch.cs
index 3df0168..a034a79 100644
--- a/src/Kontext.Presentation/IssueSearch.cs
+++ b/src/Kontext.Presentation/IssueSearch.cs
@@ -27,7 +27,7 @@ private static bool MatchesWord(GitHubIssue issue, string word)
}
return Contains(issue.Title, word)
- || Contains(issue.Repository.FullName, word)
+ || Contains(issue.Repository.DisplayName, word)
|| Contains(issue.Author, word)
|| issue.Labels.Any(label => Contains(label, word));
}
diff --git a/src/Kontext.Presentation/IssueSidebarViewModel.cs b/src/Kontext.Presentation/IssueSidebarViewModel.cs
index 4a7082d..30b70be 100644
--- a/src/Kontext.Presentation/IssueSidebarViewModel.cs
+++ b/src/Kontext.Presentation/IssueSidebarViewModel.cs
@@ -171,7 +171,7 @@ private void ApplyFilter()
{
var visible = _all.Where(item => IssueSearch.Matches(item.Issue, SearchText)).ToList();
- var groups = IssueGrouper.Group(visible, item => item.Issue.Repository.FullName, item => item.Issue.UpdatedAt);
+ var groups = IssueGrouper.Group(visible, item => item.Issue.Repository.DisplayName, item => item.Issue.UpdatedAt);
Groups.Clear();
foreach (var group in groups)
diff --git a/src/Kontext.Presentation/Kontext.Presentation.csproj b/src/Kontext.Presentation/Kontext.Presentation.csproj
index 35af4b4..48b5015 100644
--- a/src/Kontext.Presentation/Kontext.Presentation.csproj
+++ b/src/Kontext.Presentation/Kontext.Presentation.csproj
@@ -16,7 +16,7 @@
-
+
diff --git a/src/Kontext.Presentation/ShellViewModel.cs b/src/Kontext.Presentation/ShellViewModel.cs
index 2ad2b96..6295f1a 100644
--- a/src/Kontext.Presentation/ShellViewModel.cs
+++ b/src/Kontext.Presentation/ShellViewModel.cs
@@ -6,52 +6,91 @@
namespace Kontext.Presentation;
-public sealed partial class ShellViewModel(IGitHubService gitHub, IGitHubTokenStore tokenStore, WorkspaceViewModel workspace) : ObservableObject
+public sealed partial class ShellViewModel(
+ IGitHubService gitHub,
+ IGitHubDeviceLogin deviceLogin,
+ IGitHubTokenStore tokenStore,
+ IEnterpriseServerPrompt enterprisePrompt,
+ WorkspaceViewModel workspace) : ObservableObject
{
public WorkspaceViewModel Workspace { get; } = workspace;
[ObservableProperty]
- [NotifyPropertyChangedFor(nameof(IsSignedIn), nameof(IsSignedOut), nameof(ShowSignIn), nameof(SignedInText))]
+ [NotifyPropertyChangedFor(nameof(IsSignedIn), nameof(IsSignedOut), nameof(ShowSignIn), nameof(SignedInText), nameof(GitHubStatusText), nameof(HasGitHubAccount), nameof(HasNoGitHubAccount))]
+ [NotifyCanExecuteChangedFor(nameof(SignInCommand))]
public partial GitHubUser? User { get; set; }
[ObservableProperty]
- [NotifyPropertyChangedFor(nameof(ShowSignIn))]
- public partial bool IsStarting { get; set; } = true;
+ [NotifyPropertyChangedFor(nameof(IsSignedIn), nameof(IsSignedOut), nameof(ShowSignIn), nameof(EnterpriseSignedInText), nameof(EnterpriseStatusText), nameof(HasEnterpriseAccount), nameof(HasNoEnterpriseAccount))]
+ [NotifyCanExecuteChangedFor(nameof(SignInEnterpriseCommand))]
+ public partial GitHubUser? EnterpriseUser { get; set; }
[ObservableProperty]
- [NotifyCanExecuteChangedFor(nameof(SignInCommand))]
- public partial string Token { get; set; } = string.Empty;
+ [NotifyPropertyChangedFor(nameof(ShowSignIn))]
+ public partial bool IsStarting { get; set; } = true;
[ObservableProperty]
- [NotifyCanExecuteChangedFor(nameof(SignInCommand))]
+ [NotifyCanExecuteChangedFor(nameof(SignInCommand), nameof(SignInEnterpriseCommand))]
public partial bool IsBusy { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasError))]
public partial string? ErrorMessage { get; set; }
- public bool IsSignedIn => User is not null;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasDeviceCode))]
+ public partial string? UserCode { get; set; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(DeviceCodePrompt), nameof(VerificationLinkText))]
+ public partial Uri? VerificationUrl { get; set; }
+
+ public bool HasDeviceCode => !string.IsNullOrEmpty(UserCode);
- public bool IsSignedOut => User is null;
+ public string DeviceCodePrompt => VerificationUrl is null ? string.Empty : $"Enter this code on {VerificationUrl.Authority}:";
+
+ public string VerificationLinkText => VerificationUrl is null ? string.Empty : $"Open {VerificationUrl.Authority}{VerificationUrl.AbsolutePath}";
+
+ public bool IsSignedIn => User is not null || EnterpriseUser is not null;
+
+ public bool IsSignedOut => !IsSignedIn;
public bool ShowSignIn => IsSignedOut && !IsStarting;
+ public bool HasGitHubAccount => User is not null;
+
+ public bool HasNoGitHubAccount => User is null;
+
+ public bool HasEnterpriseAccount => EnterpriseUser is not null;
+
+ public bool HasNoEnterpriseAccount => EnterpriseUser is null;
+
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public string SignedInText => User is null ? string.Empty : $"Signed in as {User.Name ?? User.Login} (@{User.Login})";
+ public string EnterpriseSignedInText => EnterpriseUser is null ? string.Empty : $"Signed in to {EnterpriseUser.Host} as {EnterpriseUser.Name ?? EnterpriseUser.Login} (@{EnterpriseUser.Login})";
+
+ public string GitHubStatusText => User is null ? "Not signed in" : SignedInText;
+
+ public string EnterpriseStatusText => EnterpriseUser is null ? "Not signed in" : EnterpriseSignedInText;
+
[RelayCommand]
private async Task InitializeAsync()
{
try
{
- var savedToken = tokenStore.Load();
- if (savedToken is null)
+ var signedIn = await SignInWithSavedTokenAsync(GitHubHosts.Default, "GitHub");
+
+ if (tokenStore.FindEnterpriseHost() is { } enterpriseHost)
{
- return;
+ signedIn |= await SignInWithSavedTokenAsync(enterpriseHost, $"GitHub Enterprise ({enterpriseHost})");
}
- await SignInWithSavedTokenAsync(savedToken);
+ if (signedIn)
+ {
+ Workspace.Sidebar.RefreshCommand.Execute(null);
+ }
}
finally
{
@@ -59,18 +98,53 @@ private async Task InitializeAsync()
}
}
- private async Task SignInWithSavedTokenAsync(string savedToken)
+ private async Task SignInWithSavedTokenAsync(string host, string accountName)
{
+ var savedToken = tokenStore.Load(host);
+ if (savedToken is null)
+ {
+ return false;
+ }
+
IsBusy = true;
try
{
- User = await gitHub.AuthenticateAsync(savedToken);
- Workspace.Sidebar.RefreshCommand.Execute(null);
+ ShowUser(await gitHub.AuthenticateAsync(host, savedToken));
+ return true;
}
catch (AuthenticationFailedException)
{
- tokenStore.Clear();
- ErrorMessage = "Your saved GitHub token is no longer valid. Please sign in again.";
+ tokenStore.Clear(host);
+ AddError($"Your saved {accountName} sign-in is no longer valid. Please sign in again.");
+ return false;
+ }
+ catch (KontextException ex)
+ {
+ AddError(ex.Message);
+ return false;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ [RelayCommand(IncludeCancelCommand = true, CanExecute = nameof(CanSignIn))]
+ private async Task SignInAsync(CancellationToken cancellationToken)
+ {
+ ErrorMessage = null;
+ IsBusy = true;
+ try
+ {
+ var challenge = await deviceLogin.StartAsync(GitHubServer.GitHub, cancellationToken);
+ UserCode = challenge.UserCode;
+ VerificationUrl = challenge.VerificationUri;
+
+ var token = await deviceLogin.WaitForAccessTokenAsync(challenge, cancellationToken);
+ await CompleteSignInAsync(GitHubHosts.Default, token, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
}
catch (KontextException ex)
{
@@ -78,22 +152,29 @@ private async Task SignInWithSavedTokenAsync(string savedToken)
}
finally
{
+ UserCode = null;
+ VerificationUrl = null;
IsBusy = false;
}
}
- [RelayCommand(CanExecute = nameof(CanSignIn))]
- private async Task SignInAsync()
+ [RelayCommand(IncludeCancelCommand = true, CanExecute = nameof(CanSignInEnterprise))]
+ private async Task SignInEnterpriseAsync(CancellationToken cancellationToken)
{
+ var credentials = await enterprisePrompt.AskAsync();
+ if (credentials is null)
+ {
+ return;
+ }
+
ErrorMessage = null;
IsBusy = true;
try
{
- var token = Token.Trim();
- User = await gitHub.AuthenticateAsync(token);
- tokenStore.Save(token);
- Token = string.Empty;
- Workspace.Sidebar.RefreshCommand.Execute(null);
+ await CompleteSignInAsync(credentials.Server.Host, credentials.Token, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
}
catch (KontextException ex)
{
@@ -105,16 +186,79 @@ private async Task SignInAsync()
}
}
+ private async Task CompleteSignInAsync(string host, string token, CancellationToken cancellationToken)
+ {
+ ShowUser(await gitHub.AuthenticateAsync(host, token, cancellationToken));
+ tokenStore.Save(host, token);
+ Workspace.Sidebar.RefreshCommand.Execute(null);
+ }
+
[RelayCommand]
- private void SignOut()
+ private void CancelSignIn()
+ {
+ SignInCancelCommand.Execute(null);
+ SignInEnterpriseCancelCommand.Execute(null);
+ }
+
+ [RelayCommand]
+ private void SignOut() => SignOutOf(GitHubHosts.Default, SignInCancelCommand);
+
+ [RelayCommand]
+ private void SignOutEnterprise()
+ {
+ var host = EnterpriseUser?.Host ?? tokenStore.FindEnterpriseHost();
+ if (host is null)
+ {
+ SignInEnterpriseCancelCommand.Execute(null);
+ return;
+ }
+
+ SignOutOf(host, SignInEnterpriseCancelCommand);
+ }
+
+ private void SignOutOf(string host, System.Windows.Input.ICommand cancelSignIn)
{
- gitHub.SignOut();
- tokenStore.Clear();
+ cancelSignIn.Execute(null);
+ UserCode = null;
+ VerificationUrl = null;
+ gitHub.SignOut(host);
+ tokenStore.Clear(host);
+ ErrorMessage = null;
+
+ if (GitHubHosts.IsDefault(host))
+ {
+ User = null;
+ }
+ else
+ {
+ EnterpriseUser = null;
+ }
+
+ if (IsSignedIn)
+ {
+ Workspace.Sidebar.RefreshCommand.Execute(null);
+ return;
+ }
+
Workspace.Sidebar.Clear();
Workspace.IsSettingsOpen = false;
- ErrorMessage = null;
- User = null;
}
- private bool CanSignIn() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
+ private void ShowUser(GitHubUser user)
+ {
+ if (GitHubHosts.IsDefault(user.Host))
+ {
+ User = user;
+ }
+ else
+ {
+ EnterpriseUser = user;
+ }
+ }
+
+ private void AddError(string message) => ErrorMessage = string.IsNullOrEmpty(ErrorMessage) ? message : $"{ErrorMessage} {message}";
+
+ private bool CanSignIn() => !IsBusy && User is null;
+
+ private bool CanSignInEnterprise() => !IsBusy && EnterpriseUser is null;
}
diff --git a/src/Kontext.Sessions/Kontext.Sessions.csproj b/src/Kontext.Sessions/Kontext.Sessions.csproj
index ee3b057..b27dae1 100644
--- a/src/Kontext.Sessions/Kontext.Sessions.csproj
+++ b/src/Kontext.Sessions/Kontext.Sessions.csproj
@@ -15,7 +15,8 @@
-
+
+
diff --git a/src/Kontext.TerminalHost/Kontext.TerminalHost.csproj b/src/Kontext.TerminalHost/Kontext.TerminalHost.csproj
index d3a2a85..888006c 100644
--- a/src/Kontext.TerminalHost/Kontext.TerminalHost.csproj
+++ b/src/Kontext.TerminalHost/Kontext.TerminalHost.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/src/Kontext/App.xaml.cs b/src/Kontext/App.xaml.cs
index 5104b1f..2e374c7 100644
--- a/src/Kontext/App.xaml.cs
+++ b/src/Kontext/App.xaml.cs
@@ -1,5 +1,6 @@
using Kontext.Claude;
using Kontext.Tray;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.UI.Xaml;
@@ -15,7 +16,9 @@ public App()
InitializeComponent();
_host = Host.CreateDefaultBuilder()
- .ConfigureServices(services => services.AddKontext())
+ .UseContentRoot(AppContext.BaseDirectory)
+ .ConfigureAppConfiguration(configuration => configuration.AddJsonFile("appsettings.local.json", optional: true))
+ .ConfigureServices((context, services) => services.AddKontext(context.Configuration))
.Build();
}
diff --git a/src/Kontext/Assets/LargeTile.scale-100.png b/src/Kontext/Assets/LargeTile.scale-100.png
new file mode 100644
index 0000000..2766cb2
Binary files /dev/null and b/src/Kontext/Assets/LargeTile.scale-100.png differ
diff --git a/src/Kontext/Assets/LargeTile.scale-200.png b/src/Kontext/Assets/LargeTile.scale-200.png
new file mode 100644
index 0000000..4a6e736
Binary files /dev/null and b/src/Kontext/Assets/LargeTile.scale-200.png differ
diff --git a/src/Kontext/Assets/LargeTile.scale-400.png b/src/Kontext/Assets/LargeTile.scale-400.png
new file mode 100644
index 0000000..116b002
Binary files /dev/null and b/src/Kontext/Assets/LargeTile.scale-400.png differ
diff --git a/src/Kontext/Assets/SmallTile.scale-100.png b/src/Kontext/Assets/SmallTile.scale-100.png
new file mode 100644
index 0000000..d0fb8d5
Binary files /dev/null and b/src/Kontext/Assets/SmallTile.scale-100.png differ
diff --git a/src/Kontext/Assets/SmallTile.scale-200.png b/src/Kontext/Assets/SmallTile.scale-200.png
new file mode 100644
index 0000000..1f760a8
Binary files /dev/null and b/src/Kontext/Assets/SmallTile.scale-200.png differ
diff --git a/src/Kontext/Assets/SmallTile.scale-400.png b/src/Kontext/Assets/SmallTile.scale-400.png
new file mode 100644
index 0000000..524545c
Binary files /dev/null and b/src/Kontext/Assets/SmallTile.scale-400.png differ
diff --git a/src/Kontext/Assets/SplashScreen.scale-100.png b/src/Kontext/Assets/SplashScreen.scale-100.png
new file mode 100644
index 0000000..11fed75
Binary files /dev/null and b/src/Kontext/Assets/SplashScreen.scale-100.png differ
diff --git a/src/Kontext/Assets/SplashScreen.scale-200.png b/src/Kontext/Assets/SplashScreen.scale-200.png
new file mode 100644
index 0000000..490900c
Binary files /dev/null and b/src/Kontext/Assets/SplashScreen.scale-200.png differ
diff --git a/src/Kontext/Assets/SplashScreen.scale-400.png b/src/Kontext/Assets/SplashScreen.scale-400.png
new file mode 100644
index 0000000..12dd876
Binary files /dev/null and b/src/Kontext/Assets/SplashScreen.scale-400.png differ
diff --git a/src/Kontext/Assets/Square150x150Logo.scale-100.png b/src/Kontext/Assets/Square150x150Logo.scale-100.png
new file mode 100644
index 0000000..2870f6c
Binary files /dev/null and b/src/Kontext/Assets/Square150x150Logo.scale-100.png differ
diff --git a/src/Kontext/Assets/Square150x150Logo.scale-200.png b/src/Kontext/Assets/Square150x150Logo.scale-200.png
new file mode 100644
index 0000000..0cb707d
Binary files /dev/null and b/src/Kontext/Assets/Square150x150Logo.scale-200.png differ
diff --git a/src/Kontext/Assets/Square150x150Logo.scale-400.png b/src/Kontext/Assets/Square150x150Logo.scale-400.png
new file mode 100644
index 0000000..55a024d
Binary files /dev/null and b/src/Kontext/Assets/Square150x150Logo.scale-400.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-16.png b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-16.png
new file mode 100644
index 0000000..77dccc1
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-16.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-24.png b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-24.png
new file mode 100644
index 0000000..a87c7c0
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-24.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-256.png b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-256.png
new file mode 100644
index 0000000..fc75584
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-256.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-32.png b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-32.png
new file mode 100644
index 0000000..121f174
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-32.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-48.png b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-48.png
new file mode 100644
index 0000000..b6ae140
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-48.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.scale-100.png b/src/Kontext/Assets/Square44x44Logo.scale-100.png
new file mode 100644
index 0000000..6ff7e61
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.scale-100.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.scale-200.png b/src/Kontext/Assets/Square44x44Logo.scale-200.png
new file mode 100644
index 0000000..b06081c
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.scale-200.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.scale-400.png b/src/Kontext/Assets/Square44x44Logo.scale-400.png
new file mode 100644
index 0000000..2939867
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.scale-400.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.targetsize-16.png b/src/Kontext/Assets/Square44x44Logo.targetsize-16.png
new file mode 100644
index 0000000..77dccc1
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.targetsize-16.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.targetsize-24.png b/src/Kontext/Assets/Square44x44Logo.targetsize-24.png
new file mode 100644
index 0000000..a87c7c0
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.targetsize-24.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.targetsize-256.png b/src/Kontext/Assets/Square44x44Logo.targetsize-256.png
new file mode 100644
index 0000000..fc75584
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.targetsize-256.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.targetsize-32.png b/src/Kontext/Assets/Square44x44Logo.targetsize-32.png
new file mode 100644
index 0000000..121f174
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.targetsize-32.png differ
diff --git a/src/Kontext/Assets/Square44x44Logo.targetsize-48.png b/src/Kontext/Assets/Square44x44Logo.targetsize-48.png
new file mode 100644
index 0000000..b6ae140
Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.targetsize-48.png differ
diff --git a/src/Kontext/Assets/StoreLogo.scale-100.png b/src/Kontext/Assets/StoreLogo.scale-100.png
new file mode 100644
index 0000000..68d786a
Binary files /dev/null and b/src/Kontext/Assets/StoreLogo.scale-100.png differ
diff --git a/src/Kontext/Assets/StoreLogo.scale-200.png b/src/Kontext/Assets/StoreLogo.scale-200.png
new file mode 100644
index 0000000..6d9d4cd
Binary files /dev/null and b/src/Kontext/Assets/StoreLogo.scale-200.png differ
diff --git a/src/Kontext/Assets/StoreLogo.scale-400.png b/src/Kontext/Assets/StoreLogo.scale-400.png
new file mode 100644
index 0000000..ca1c974
Binary files /dev/null and b/src/Kontext/Assets/StoreLogo.scale-400.png differ
diff --git a/src/Kontext/Assets/Wide310x150Logo.scale-100.png b/src/Kontext/Assets/Wide310x150Logo.scale-100.png
new file mode 100644
index 0000000..7cfb319
Binary files /dev/null and b/src/Kontext/Assets/Wide310x150Logo.scale-100.png differ
diff --git a/src/Kontext/Assets/Wide310x150Logo.scale-200.png b/src/Kontext/Assets/Wide310x150Logo.scale-200.png
new file mode 100644
index 0000000..30bddd0
Binary files /dev/null and b/src/Kontext/Assets/Wide310x150Logo.scale-200.png differ
diff --git a/src/Kontext/Assets/Wide310x150Logo.scale-400.png b/src/Kontext/Assets/Wide310x150Logo.scale-400.png
new file mode 100644
index 0000000..3a1f96c
Binary files /dev/null and b/src/Kontext/Assets/Wide310x150Logo.scale-400.png differ
diff --git a/src/Kontext/Kontext.csproj b/src/Kontext/Kontext.csproj
index dbebf99..8b71a5a 100644
--- a/src/Kontext/Kontext.csproj
+++ b/src/Kontext/Kontext.csproj
@@ -11,13 +11,17 @@
$(NETCoreSdkRuntimeIdentifier)
true
true
+
None
true
+ win-$(Platform).pubxml
-
+
+
+
diff --git a/src/Kontext/Package.appxmanifest b/src/Kontext/Package.appxmanifest
new file mode 100644
index 0000000..50353b3
--- /dev/null
+++ b/src/Kontext/Package.appxmanifest
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+ Kontext
+ Buning Software
+ Assets\StoreLogo.png
+
+ disabled
+ disabled
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Kontext/Properties/PublishProfiles/win-x64.pubxml b/src/Kontext/Properties/PublishProfiles/win-x64.pubxml
new file mode 100644
index 0000000..2cdf43f
--- /dev/null
+++ b/src/Kontext/Properties/PublishProfiles/win-x64.pubxml
@@ -0,0 +1,13 @@
+
+
+
+ x64
+ win-x64
+ bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\
+ true
+ false
+ true
+ false
+ Never
+
+
diff --git a/src/Kontext/ServiceCollectionExtensions.cs b/src/Kontext/ServiceCollectionExtensions.cs
index 325e43c..bdf40c7 100644
--- a/src/Kontext/ServiceCollectionExtensions.cs
+++ b/src/Kontext/ServiceCollectionExtensions.cs
@@ -11,6 +11,7 @@
using Kontext.TerminalHost;
using Kontext.Tray;
using Kontext.Views;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Dispatching;
@@ -18,7 +19,7 @@ namespace Kontext;
public static class ServiceCollectionExtensions
{
- public static IServiceCollection AddKontext(this IServiceCollection services)
+ public static IServiceCollection AddKontext(this IServiceCollection services, IConfiguration configuration)
{
var overridden = Environment.GetEnvironmentVariable("KONTEXT_DATA_DIR");
var dataRoot = string.IsNullOrWhiteSpace(overridden)
@@ -26,7 +27,7 @@ public static IServiceCollection AddKontext(this IServiceCollection services)
: overridden;
services
- .AddKontextGitHub()
+ .AddKontextGitHub(new GitHubOptions(configuration["GitHub:ClientId"]))
.AddKontextGitAutomation(new GitAutomationOptions(
StateDirectory: dataRoot,
WorktreesRoot: Path.Combine(dataRoot, "worktrees"),
@@ -41,6 +42,7 @@ public static IServiceCollection AddKontext(this IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton(_ => new DispatcherQueueDispatcher(DispatcherQueue.GetForCurrentThread()));
diff --git a/src/Kontext/Services/ContentDialogEnterprisePrompt.cs b/src/Kontext/Services/ContentDialogEnterprisePrompt.cs
new file mode 100644
index 0000000..7f2b7ac
--- /dev/null
+++ b/src/Kontext/Services/ContentDialogEnterprisePrompt.cs
@@ -0,0 +1,69 @@
+using Kontext.Contracts;
+using Kontext.Domain;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+
+namespace Kontext.Services;
+
+internal sealed class ContentDialogEnterprisePrompt(XamlRootProvider root) : IEnterpriseServerPrompt
+{
+ private string _lastAddress = string.Empty;
+
+ public async Task AskAsync()
+ {
+ var address = new TextBox { Header = "Server address", PlaceholderText = "yourcompany.ghe.com", Text = _lastAddress };
+ var token = new PasswordBox { Header = "Personal access token" };
+ var error = new TextBlock
+ {
+ TextWrapping = TextWrapping.Wrap,
+ Visibility = Visibility.Collapsed,
+ Foreground = (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"],
+ };
+
+ var hint = new TextBlock
+ {
+ TextWrapping = TextWrapping.Wrap,
+ Text = "Create a personal access token on your GitHub Enterprise server (Settings, Developer settings, Personal access tokens) with the repo scope, then paste it here. Kontext stores it in the Windows Credential Locker.",
+ Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"],
+ };
+
+ var dialog = new ContentDialog
+ {
+ XamlRoot = root.Current,
+ Title = "Sign in with GitHub Enterprise",
+ Content = new StackPanel { Spacing = 12, Children = { hint, address, token, error } },
+ PrimaryButtonText = "Sign in",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Primary,
+ };
+
+ GitHubServer? server = null;
+ dialog.PrimaryButtonClick += (_, args) =>
+ {
+ var valid = GitHubServer.TryCreate(address.Text, out server, out var message);
+ if (valid && string.IsNullOrWhiteSpace(token.Password))
+ {
+ valid = false;
+ message = "Paste your personal access token.";
+ }
+
+ if (valid)
+ {
+ return;
+ }
+
+ error.Text = message;
+ error.Visibility = Visibility.Visible;
+ args.Cancel = true;
+ };
+
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary || server is null)
+ {
+ return null;
+ }
+
+ _lastAddress = address.Text.Trim();
+ return new EnterpriseCredentials(server, token.Password.Trim());
+ }
+}
diff --git a/src/Kontext/Services/CredentialLockerTokenStore.cs b/src/Kontext/Services/CredentialLockerTokenStore.cs
index 848f85d..c4f4924 100644
--- a/src/Kontext/Services/CredentialLockerTokenStore.cs
+++ b/src/Kontext/Services/CredentialLockerTokenStore.cs
@@ -1,4 +1,5 @@
using Kontext.Contracts;
+using Kontext.Kernel;
using Windows.Security.Credentials;
namespace Kontext.Services;
@@ -6,38 +7,80 @@ namespace Kontext.Services;
internal sealed class CredentialLockerTokenStore : IGitHubTokenStore
{
private const string Resource = "Kontext/GitHub";
- private const string UserName = "personal-access-token";
+ private const string UserName = "github-oauth-token";
+ private const string LegacyUserName = "personal-access-token";
+ private const string EnterpriseResource = "Kontext/GitHub-Enterprise";
private const int ElementNotFound = unchecked((int)0x80070490);
private readonly PasswordVault _vault = new();
- public string? Load()
+ public string? Load(string host)
{
- var credential = Find();
+ RemoveLegacy();
+
+ var credential = GitHubHosts.IsDefault(host) ? Find(Resource, UserName) : Find(EnterpriseResource, host);
credential?.RetrievePassword();
return credential?.Password;
}
- public void Save(string token) => _vault.Add(new PasswordCredential(Resource, UserName, token));
+ public void Save(string host, string token)
+ {
+ if (GitHubHosts.IsDefault(host))
+ {
+ _vault.Add(new PasswordCredential(Resource, UserName, token));
+ return;
+ }
+
+ // Only one enterprise account is kept, so a new one replaces whatever was saved before.
+ foreach (var existing in FindAll(EnterpriseResource))
+ {
+ _vault.Remove(existing);
+ }
+
+ _vault.Add(new PasswordCredential(EnterpriseResource, host, token));
+ }
- public void Clear()
+ public void Clear(string host)
{
- var credential = Find();
+ var credential = GitHubHosts.IsDefault(host) ? Find(Resource, UserName) : Find(EnterpriseResource, host);
if (credential is not null)
{
_vault.Remove(credential);
}
}
- private PasswordCredential? Find()
+ public string? FindEnterpriseHost() => FindAll(EnterpriseResource).FirstOrDefault()?.UserName;
+
+ private void RemoveLegacy()
+ {
+ var legacy = Find(Resource, LegacyUserName);
+ if (legacy is not null)
+ {
+ _vault.Remove(legacy);
+ }
+ }
+
+ private PasswordCredential? Find(string resource, string userName)
{
try
{
- return _vault.Retrieve(Resource, UserName);
+ return _vault.Retrieve(resource, userName);
}
catch (Exception ex) when (ex.HResult == ElementNotFound)
{
return null;
}
}
+
+ private List FindAll(string resource)
+ {
+ try
+ {
+ return [.. _vault.FindAllByResource(resource)];
+ }
+ catch (Exception ex) when (ex.HResult == ElementNotFound)
+ {
+ return [];
+ }
+ }
}
diff --git a/src/Kontext/Views/DeviceCodePanel.xaml b/src/Kontext/Views/DeviceCodePanel.xaml
new file mode 100644
index 0000000..c6cdfdf
--- /dev/null
+++ b/src/Kontext/Views/DeviceCodePanel.xaml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Kontext/Views/DeviceCodePanel.xaml.cs b/src/Kontext/Views/DeviceCodePanel.xaml.cs
new file mode 100644
index 0000000..c791ede
--- /dev/null
+++ b/src/Kontext/Views/DeviceCodePanel.xaml.cs
@@ -0,0 +1,51 @@
+using System.Runtime.InteropServices;
+using Kontext.Presentation;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Windows.ApplicationModel.DataTransfer;
+
+namespace Kontext.Views;
+
+public sealed partial class DeviceCodePanel : UserControl
+{
+ private const string CopyGlyph = "๎ฃ";
+ private const string CopiedGlyph = "๎พ";
+
+ public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register(
+ nameof(ViewModel),
+ typeof(ShellViewModel),
+ typeof(DeviceCodePanel),
+ new PropertyMetadata(null, (panel, _) => ((DeviceCodePanel)panel).Bindings?.Update()));
+
+ public DeviceCodePanel() => InitializeComponent();
+
+ public ShellViewModel? ViewModel
+ {
+ get => (ShellViewModel?)GetValue(ViewModelProperty);
+ set => SetValue(ViewModelProperty, value);
+ }
+
+ private async void CopyCodeButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrEmpty(ViewModel?.UserCode))
+ {
+ return;
+ }
+
+ try
+ {
+ var package = new DataPackage();
+ package.SetText(ViewModel.UserCode);
+ Clipboard.SetContent(package);
+ Clipboard.Flush();
+ }
+ catch (Exception ex) when (ex is COMException or InvalidOperationException)
+ {
+ return;
+ }
+
+ CopyCodeIcon.Glyph = CopiedGlyph;
+ await Task.Delay(TimeSpan.FromSeconds(1.5));
+ CopyCodeIcon.Glyph = CopyGlyph;
+ }
+}
diff --git a/src/Kontext/Views/IssueDetailView.xaml b/src/Kontext/Views/IssueDetailView.xaml
index 0216268..6652bf4 100644
--- a/src/Kontext/Views/IssueDetailView.xaml
+++ b/src/Kontext/Views/IssueDetailView.xaml
@@ -114,7 +114,7 @@
-
+
diff --git a/src/Kontext/Views/IssueDetailView.xaml.cs b/src/Kontext/Views/IssueDetailView.xaml.cs
index 5391929..6f5f390 100644
--- a/src/Kontext/Views/IssueDetailView.xaml.cs
+++ b/src/Kontext/Views/IssueDetailView.xaml.cs
@@ -38,7 +38,7 @@ public TerminalControlHost? ControlHost
private void OpenInTerminal_Click(object sender, RoutedEventArgs e) => OpenIn(WorkspaceTool.Terminal);
- private void OpenInVisualStudio_Click(object sender, RoutedEventArgs e) => OpenIn(WorkspaceTool.VisualStudio);
+ private void OpenSolution_Click(object sender, RoutedEventArgs e) => OpenIn(WorkspaceTool.Solution);
private void OpenIn(WorkspaceTool tool) => ViewModel?.OpenInCommand.Execute(tool);
diff --git a/src/Kontext/Views/SettingsView.xaml b/src/Kontext/Views/SettingsView.xaml
index 86bbd7f..45d72e7 100644
--- a/src/Kontext/Views/SettingsView.xaml
+++ b/src/Kontext/Views/SettingsView.xaml
@@ -1,7 +1,8 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:views="using:Kontext.Views">