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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/actions/setup/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Setup
description: Installs the .NET SDK, loads the NuGet cache and restores the solution. Run after checkout.

runs:
using: composite
steps:
- 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', '**/*.csproj', '**/Directory.Build.props') }}
restore-keys: |
${{ runner.os }}-nuget-

- name: Install dependencies
shell: bash
run: dotnet restore
100 changes: 81 additions & 19 deletions .github/workflows/build-and-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,23 @@ on:
workflow_dispatch:
workflow_call: {}

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
build:
# WinUI 3 (Kontext) and ConPTY (Kontext.TerminalHost) only build on Windows.
name: Build
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- 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
!~/.nuget/packages/unwanted
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Setup
uses: ./.github/actions/setup

- name: Install dependencies
run: dotnet restore
- name: Check for unused usings
run: dotnet format style Kontext.slnx --diagnostics IDE0005 --severity info --verify-no-changes

- name: Set GitHub OAuth client id
if: ${{ vars.KONTEXT_GITHUB_CLIENT_ID != '' }}
Expand All @@ -45,5 +37,75 @@ jobs:
- name: Build
run: dotnet build --no-restore --configuration Release

- name: Test
run: dotnet test --no-build --configuration Release --logger GitHubActions
- name: Upload build output
uses: actions/upload-artifact@v7
with:
name: build-output
path: |
src/*/bin/Release
tests/*/bin/Release
if-no-files-found: error
retention-days: 1

unit-test:
name: Run unit tests
needs: build
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup
uses: ./.github/actions/setup

- name: Download build output
uses: actions/download-artifact@v8
with:
name: build-output

- name: Unit test
run: dotnet test --no-build --configuration Release --logger GitHubActions --filter "FullyQualifiedName~.UnitTests."

- name: Architecture test
run: dotnet test --no-build --configuration Release --logger GitHubActions --filter "FullyQualifiedName~.ArchitectureTests.|FullyQualifiedName~.CompositionTests."

integration-test:
name: Run integration tests
needs: build
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup
uses: ./.github/actions/setup

- name: Download build output
uses: actions/download-artifact@v8
with:
name: build-output

- name: Integration test
run: dotnet test --no-build --configuration Release --logger GitHubActions --filter "FullyQualifiedName~.IntegrationTests."

ui-test:
name: Run UI tests
needs: build
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup
uses: ./.github/actions/setup

- name: Download build output
uses: actions/download-artifact@v8
with:
name: build-output

- name: UI test
run: dotnet test --no-build --configuration Release --logger GitHubActions --filter "FullyQualifiedName~.UiTests."
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@
<PackageVersion Include="NUnit.Analyzers" Version="4.15.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="6.3.1" />
<PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="WinUia" Version="0.2.0" />
<PackageVersion Include="WinUia.NUnit" Version="0.2.0" />
</ItemGroup>
</Project>
1 change: 1 addition & 0 deletions Kontext.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@
<Project Path="tests/Kontext.Sessions.IntegrationTests/Kontext.Sessions.IntegrationTests.csproj" />
<Project Path="tests/Kontext.TerminalHost.UnitTests/Kontext.TerminalHost.UnitTests.csproj" />
<Project Path="tests/Kontext.TestSupport/Kontext.TestSupport.csproj" />
<Project Path="tests/Kontext.UiTests/Kontext.UiTests.csproj" />
</Folder>
</Solution>
8 changes: 7 additions & 1 deletion src/Kontext.Claude/ClaudeLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace Kontext.Claude;

internal sealed class ClaudeLauncher(ClaudeLaunchBuilder launch, IClaudeTranscripts transcripts, IClaudeHooks hooks) : IClaudeLauncher
internal sealed class ClaudeLauncher(ClaudeLaunchBuilder launch, IClaudeTranscripts transcripts, IClaudeHooks hooks, IClaudeLiveSessions live) : IClaudeLauncher
{
public ClaudeLaunch Prepare(StoredSession? earlier, WorktreeInfo worktree)
{
Expand All @@ -15,6 +15,12 @@ public ClaudeLaunch Prepare(StoredSession? earlier, WorktreeInfo worktree)
? earlier
: null;

// Claude refuses (exit code 1) to resume a session another Claude has open, so say why instead.
if (resumable is not null && live.FindRunningProcess(resumable.SessionId) is not null)
{
throw new ClaudeSessionInUseException();
}

var id = resumable?.SessionId ?? TerminalSessionId.New();
var settings = hooks.SettingsFor(id);
var options = resumable is null
Expand Down
82 changes: 82 additions & 0 deletions src/Kontext.Claude/ClaudeLiveSessions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Diagnostics;
using System.Text.Json;
using Kontext.Domain;

namespace Kontext.Claude;

/// <summary>
/// Reads the files Claude Code keeps for its running sessions (<c>&lt;config&gt;/sessions/&lt;pid&gt;.json</c>). A file only
/// counts while its process still runs and started when the file says, so a leftover file whose process id has since been
/// reused by another program is ignored. Anything unreadable is skipped: this check may only prevent a failed resume,
/// never block a good one.
/// </summary>
internal sealed class ClaudeLiveSessions(ClaudeOptions options) : IClaudeLiveSessions
{
public int? FindRunningProcess(TerminalSessionId sessionId)
{
var folder = Path.Combine(options.ConfigDirectory, "sessions");
if (!Directory.Exists(folder))
{
return null;
}

foreach (var file in Directory.EnumerateFiles(folder, "*.json"))
{
if (Read(file) is { } entry && entry.SessionId == sessionId.Value && IsRunning(entry.Pid, entry.ProcessStart))
{
return entry.Pid;
}
}

return null;
}

private static (int Pid, Guid SessionId, long ProcessStart)? Read(string file)
{
try
{
using var document = JsonDocument.Parse(File.ReadAllText(file));
var root = document.RootElement;

return root.TryGetProperty("pid", out var pid) && pid.TryGetInt32(out var processId)
&& root.TryGetProperty("sessionId", out var session) && Guid.TryParse(session.GetString(), out var id)
&& root.TryGetProperty("procStart", out var start) && TryGetInt64(start, out var processStart)
? (processId, id, processStart)
: null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidOperationException)
{
return null;
}
}

// Claude Code writes procStart as a string (it does not fit a JavaScript number); accept a plain number too.
private static bool TryGetInt64(JsonElement element, out long value)
{
if (element.ValueKind == JsonValueKind.String)
{
return long.TryParse(element.GetString(), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out value);
}

if (element.ValueKind == JsonValueKind.Number)
{
return element.TryGetInt64(out value);
}

value = 0;
return false;
}

private static bool IsRunning(int pid, long processStart)
{
try
{
using var process = Process.GetProcessById(pid);
return !process.HasExited && process.StartTime.ToFileTimeUtc() == processStart;
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception)
{
return false;
}
}
}
22 changes: 22 additions & 0 deletions src/Kontext.Claude/ClaudeSessionInUseException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Kontext.Kernel;

namespace Kontext.Claude;

/// <summary>The session to resume is already open in another Claude (another terminal, or another Kontext).</summary>
public class ClaudeSessionInUseException : KontextException
{
public ClaudeSessionInUseException()
: this("This session is already open in another Claude window. Close it there, then resume it here.")
{
}

public ClaudeSessionInUseException(string message)
: base(message)
{
}

public ClaudeSessionInUseException(string message, Exception innerException)
: base(message, innerException)
{
}
}
10 changes: 10 additions & 0 deletions src/Kontext.Claude/IClaudeLiveSessions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Kontext.Domain;

namespace Kontext.Claude;

/// <summary>The Claude sessions that are open right now, in any terminal or in another Kontext.</summary>
internal interface IClaudeLiveSessions
{
/// <summary>The process id of the running Claude that has <paramref name="sessionId"/> open, or null when none has.</summary>
int? FindRunningProcess(TerminalSessionId sessionId);
}
1 change: 1 addition & 0 deletions src/Kontext.Claude/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public static IServiceCollection AddKontextClaude(this IServiceCollection servic
services.AddSingleton(options);
services.AddSingleton<ClaudeLaunchBuilder>();
services.AddSingleton<IClaudeTranscripts, ClaudeTranscripts>();
services.AddSingleton<IClaudeLiveSessions, ClaudeLiveSessions>();
services.AddSingleton<IClaudeHookServer, LoopbackClaudeHookServer>();
services.AddSingleton<ClaudeActivityReporter>();
services.AddSingleton<IClaudeHooks, ClaudeHooks>();
Expand Down
54 changes: 54 additions & 0 deletions src/Kontext.Presentation/CollectionSync.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Collections.ObjectModel;

namespace Kontext.Presentation;

/// <summary>
/// Updates an <see cref="ObservableCollection{T}"/> to match a desired list using only
/// insert, move and remove notifications (never a reset). New items are inserted before
/// stale ones are removed, so a non-empty collection never passes through empty.
/// Bound controls such as NavigationView then keep their containers (and expand state).
/// </summary>
internal static class CollectionSync
{
public static void SyncInPlace<T>(ObservableCollection<T> target, IReadOnlyList<T> desired)
where T : class
{
for (var index = 0; index < desired.Count; index++)
{
var wanted = desired[index];
if (index < target.Count && ReferenceEquals(target[index], wanted))
{
continue;
}

var existing = IndexOf(target, wanted, from: index + 1);
if (existing >= 0)
{
target.Move(existing, index);
}
else
{
target.Insert(index, wanted);
}
}

while (target.Count > desired.Count)
{
target.RemoveAt(target.Count - 1);
}
}

private static int IndexOf<T>(ObservableCollection<T> target, T item, int from)
where T : class
{
for (var index = from; index < target.Count; index++)
{
if (ReferenceEquals(target[index], item))
{
return index;
}
}

return -1;
}
}
4 changes: 2 additions & 2 deletions src/Kontext.Presentation/GitChangeRow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ public sealed record GitChangeRow(string Text, FileChange? Change)
public string StatusLetter => Change?.Status switch
{
FileChangeStatus.Modified => "M",
FileChangeStatus.Added => "A",
// A new file is "+", staged or not.
FileChangeStatus.Added or FileChangeStatus.Untracked => "+",
FileChangeStatus.Deleted => "D",
FileChangeStatus.Renamed => "R",
FileChangeStatus.Untracked => "?",
FileChangeStatus.Conflicted => "!",
_ => string.Empty,
};
Expand Down
10 changes: 9 additions & 1 deletion src/Kontext.Presentation/IssueDetailViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ public IssueDetailViewModel(
[NotifyPropertyChangedFor(nameof(VisibleSession))]
public partial bool IsConfirmingClear { get; set; }

/// <summary>
/// True while another page (the settings) covers the detail pane. The terminal is a native window that XAML cannot
/// hide by collapsing its parent, so no session is shown then.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(VisibleSession))]
public partial bool IsCovered { get; set; }

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasToolError))]
public partial string? ToolError { get; set; }
Expand All @@ -100,7 +108,7 @@ public IssueDetailViewModel(

public ITerminalSession? Session => Item is null ? null : _registry.Find(Item.Key);

public ITerminalSession? VisibleSession => ShowTerminalTab && !IsConfirmingClear ? Session : null;
public ITerminalSession? VisibleSession => ShowTerminalTab && !IsConfirmingClear && !IsCovered ? Session : null;

public bool HasSession => Session is not null;

Expand Down
Loading