From ef8d3e9c471b6231aab47a65165ddd4928ade7f5 Mon Sep 17 00:00:00 2001 From: Jelle Buning Date: Wed, 4 Nov 2026 14:40:56 +0100 Subject: [PATCH 1/2] Add Git changes review tab --- src/Kontext.Claude/ClaudeActivityReporter.cs | 17 +- src/Kontext.Claude/ClaudeHookEvent.cs | 4 +- src/Kontext.Claude/ClaudeHookPayload.cs | 4 +- .../LoopbackClaudeHookServer.cs | 4 +- .../ICollapsedRepositoryStore.cs | 9 + src/Kontext.Contracts/IWorkspaceChanges.cs | 10 + src/Kontext.Domain/FileChange.cs | 23 + src/Kontext.Domain/FileDiff.cs | 32 ++ src/Kontext.Domain/WorkingTreeChanges.cs | 26 + src/Kontext.GitAutomation/NumstatParser.cs | 47 ++ .../ServiceCollectionExtensions.cs | 1 + .../StatusPorcelainV2Parser.cs | 60 +++ .../UnifiedDiffParser.cs | 99 ++++ src/Kontext.GitAutomation/WorkspaceChanges.cs | 250 ++++++++++ src/Kontext.Presentation/DetailTab.cs | 8 + src/Kontext.Presentation/GitChangeRow.cs | 31 ++ src/Kontext.Presentation/GitChangesOptions.cs | 6 + .../GitChangesViewModel.cs | 298 +++++++++++ .../IssueDetailViewModel.cs | 33 +- .../IssueItemViewModel.cs | 15 +- .../IssueSidebarViewModel.cs | 31 +- .../RepositoryGroupViewModel.cs | 19 +- .../ServiceCollectionExtensions.cs | 3 + src/Kontext.Presentation/ShellViewModel.cs | 9 +- src/Kontext.Presentation/SplitDiffRows.cs | 68 +++ .../JsonCollapsedRepositoryStore.cs | 12 + .../JsonHiddenRepositoryStore.cs | 38 +- src/Kontext.Sessions/JsonNameSetFile.cs | 44 ++ .../ServiceCollectionExtensions.cs | 1 + src/Kontext.TerminalHost/TerminalShortcut.cs | 1 + .../TerminalShortcutReader.cs | 8 + src/Kontext/Terminal/TerminalKeyboardHook.cs | 12 +- .../Views/GitChangesTemplateSelectors.cs | 85 ++++ src/Kontext/Views/GitChangesView.xaml | 435 ++++++++++++++++ src/Kontext/Views/GitChangesView.xaml.cs | 122 +++++ src/Kontext/Views/GitViewFormat.cs | 18 + src/Kontext/Views/IssueDetailView.xaml | 10 +- src/Kontext/Views/IssueDetailView.xaml.cs | 47 +- src/Kontext/Views/ShellPage.xaml | 22 +- src/Kontext/Views/VisibilityFormat.cs | 17 + src/Kontext/Views/WorkspaceView.xaml | 35 +- .../LoopbackClaudeHookServerTests.cs | 14 + .../ClaudeActivityReporterTests.cs | 64 ++- .../ClaudeHookPayloadTests.cs | 12 +- .../ClaudeHooksTests.cs | 2 +- .../CompositionTests.cs | 3 + .../WorkspaceChangesIntegrationTests.cs | 315 ++++++++++++ .../NumstatParserTests.cs | 54 ++ .../StatusPorcelainV2ParserTests.cs | 95 ++++ .../UnifiedDiffParserTests.cs | 114 +++++ .../WorkspaceChangesArgumentsTests.cs | 89 ++++ .../GitChangeRowTests.cs | 61 +++ .../GitChangesViewModelTests.cs | 465 ++++++++++++++++++ .../IssueDetailViewModelTests.cs | 93 +++- .../IssueItemViewModelTests.cs | 50 ++ .../IssueSidebarViewModelTests.cs | 102 +++- .../ShellViewModelFixture.cs | 4 +- .../ShellViewModelTests.cs | 40 ++ .../SplitDiffRowsTests.cs | 112 +++++ .../WorkingTreeChangesTests.cs | 51 ++ .../WorkspaceViewModelTests.cs | 4 +- .../JsonCollapsedRepositoryStoreTests.cs | 58 +++ .../TerminalShortcutReaderTests.cs | 23 + tests/Kontext.TestSupport/InMemoryFakes.cs | 15 + .../WorkspaceChangesFakes.cs | 88 ++++ 65 files changed, 3843 insertions(+), 99 deletions(-) create mode 100644 src/Kontext.Contracts/ICollapsedRepositoryStore.cs create mode 100644 src/Kontext.Contracts/IWorkspaceChanges.cs create mode 100644 src/Kontext.Domain/FileChange.cs create mode 100644 src/Kontext.Domain/FileDiff.cs create mode 100644 src/Kontext.Domain/WorkingTreeChanges.cs create mode 100644 src/Kontext.GitAutomation/NumstatParser.cs create mode 100644 src/Kontext.GitAutomation/StatusPorcelainV2Parser.cs create mode 100644 src/Kontext.GitAutomation/UnifiedDiffParser.cs create mode 100644 src/Kontext.GitAutomation/WorkspaceChanges.cs create mode 100644 src/Kontext.Presentation/DetailTab.cs create mode 100644 src/Kontext.Presentation/GitChangeRow.cs create mode 100644 src/Kontext.Presentation/GitChangesOptions.cs create mode 100644 src/Kontext.Presentation/GitChangesViewModel.cs create mode 100644 src/Kontext.Presentation/SplitDiffRows.cs create mode 100644 src/Kontext.Sessions/JsonCollapsedRepositoryStore.cs create mode 100644 src/Kontext.Sessions/JsonNameSetFile.cs create mode 100644 src/Kontext/Views/GitChangesTemplateSelectors.cs create mode 100644 src/Kontext/Views/GitChangesView.xaml create mode 100644 src/Kontext/Views/GitChangesView.xaml.cs create mode 100644 src/Kontext/Views/GitViewFormat.cs create mode 100644 src/Kontext/Views/VisibilityFormat.cs create mode 100644 tests/Kontext.GitAutomation.IntegrationTests/WorkspaceChangesIntegrationTests.cs create mode 100644 tests/Kontext.GitAutomation.UnitTests/NumstatParserTests.cs create mode 100644 tests/Kontext.GitAutomation.UnitTests/StatusPorcelainV2ParserTests.cs create mode 100644 tests/Kontext.GitAutomation.UnitTests/UnifiedDiffParserTests.cs create mode 100644 tests/Kontext.GitAutomation.UnitTests/WorkspaceChangesArgumentsTests.cs create mode 100644 tests/Kontext.Presentation.UnitTests/GitChangeRowTests.cs create mode 100644 tests/Kontext.Presentation.UnitTests/GitChangesViewModelTests.cs create mode 100644 tests/Kontext.Presentation.UnitTests/SplitDiffRowsTests.cs create mode 100644 tests/Kontext.Presentation.UnitTests/WorkingTreeChangesTests.cs create mode 100644 tests/Kontext.Sessions.IntegrationTests/JsonCollapsedRepositoryStoreTests.cs create mode 100644 tests/Kontext.TestSupport/WorkspaceChangesFakes.cs diff --git a/src/Kontext.Claude/ClaudeActivityReporter.cs b/src/Kontext.Claude/ClaudeActivityReporter.cs index 1027b62..014ef89 100644 --- a/src/Kontext.Claude/ClaudeActivityReporter.cs +++ b/src/Kontext.Claude/ClaudeActivityReporter.cs @@ -1,9 +1,10 @@ using Kontext.Contracts; +using Kontext.Domain; using Kontext.Kernel; namespace Kontext.Claude; -internal sealed class ClaudeActivityReporter(IIssueSessionRegistry registry, IIssueWorkTracker tracker, IUiDispatcher ui) +internal sealed class ClaudeActivityReporter(IIssueSessionRegistry registry, IIssueWorkTracker tracker, ISessionStore sessions, IUiDispatcher ui) { public void OnHook(ClaudeHookEvent hook) { @@ -19,6 +20,8 @@ private void Apply(ClaudeHookEvent hook) return; } + RememberSession(issue, hook.ClaudeSession); + var state = tracker.StateOf(issue); if (state.SessionEnded) @@ -31,4 +34,16 @@ private void Apply(ClaudeHookEvent hook) tracker.Report(issue, state with { Status = next }); } } + + // Resuming a conversation gives Claude a new session id with the history copied into it. Keeping the id the + // launch used would bring the next resume back to the copy as it was, losing everything said since. + private void RememberSession(IssueKey issue, Guid? claudeSession) + { + if (claudeSession is not { } id || sessions.Find(issue) is not { } stored || stored.SessionId.Value == id) + { + return; + } + + sessions.Save(stored with { SessionId = new TerminalSessionId(id) }); + } } diff --git a/src/Kontext.Claude/ClaudeHookEvent.cs b/src/Kontext.Claude/ClaudeHookEvent.cs index aeb08d0..54dfa9e 100644 --- a/src/Kontext.Claude/ClaudeHookEvent.cs +++ b/src/Kontext.Claude/ClaudeHookEvent.cs @@ -2,7 +2,9 @@ namespace Kontext.Claude; -internal sealed record ClaudeHookEvent(TerminalSessionId Session, string Name, string? NotificationType = null); +/// The id Kontext gave the terminal session; it is part of the hook address. +/// The id Claude itself reports. Resuming gives a conversation a new id, so it can differ from the one it was started with. +internal sealed record ClaudeHookEvent(TerminalSessionId Session, string Name, string? NotificationType = null, Guid? ClaudeSession = null); internal sealed class ClaudeHookEventArgs(ClaudeHookEvent hook) : EventArgs { diff --git a/src/Kontext.Claude/ClaudeHookPayload.cs b/src/Kontext.Claude/ClaudeHookPayload.cs index 3086b8e..cc32237 100644 --- a/src/Kontext.Claude/ClaudeHookPayload.cs +++ b/src/Kontext.Claude/ClaudeHookPayload.cs @@ -4,7 +4,7 @@ namespace Kontext.Claude; internal static class ClaudeHookPayload { - public static (string Name, string? NotificationType)? Parse(string json) + public static (string Name, string? NotificationType, string? SessionId)? Parse(string json) { try { @@ -15,7 +15,7 @@ public static (string Name, string? NotificationType)? Parse(string json) } var name = StringProperty(document.RootElement, "hook_event_name"); - return string.IsNullOrWhiteSpace(name) ? null : (name, StringProperty(document.RootElement, "notification_type")); + return string.IsNullOrWhiteSpace(name) ? null : (name, StringProperty(document.RootElement, "notification_type"), StringProperty(document.RootElement, "session_id")); } catch (JsonException) { diff --git a/src/Kontext.Claude/LoopbackClaudeHookServer.cs b/src/Kontext.Claude/LoopbackClaudeHookServer.cs index 7a5fb3a..533b156 100644 --- a/src/Kontext.Claude/LoopbackClaudeHookServer.cs +++ b/src/Kontext.Claude/LoopbackClaudeHookServer.cs @@ -98,9 +98,9 @@ private async Task HandleAsync(HttpListenerContext context) var session = SessionIn(context.Request.Url); Respond(context); - if (session is { } id && ClaudeHookPayload.Parse(body) is var (name, notificationType)) + if (session is { } id && ClaudeHookPayload.Parse(body) is var (name, notificationType, claudeSession)) { - EventReceived?.Invoke(this, new ClaudeHookEventArgs(new ClaudeHookEvent(id, name, notificationType))); + EventReceived?.Invoke(this, new ClaudeHookEventArgs(new ClaudeHookEvent(id, name, notificationType, Guid.TryParse(claudeSession, CultureInfo.InvariantCulture, out var guid) ? guid : null))); } } diff --git a/src/Kontext.Contracts/ICollapsedRepositoryStore.cs b/src/Kontext.Contracts/ICollapsedRepositoryStore.cs new file mode 100644 index 0000000..905d737 --- /dev/null +++ b/src/Kontext.Contracts/ICollapsedRepositoryStore.cs @@ -0,0 +1,9 @@ +namespace Kontext.Contracts; + +/// Remembers which repository groups the user collapsed in the issue menu, by RepositoryRef.DisplayName. +public interface ICollapsedRepositoryStore +{ + IReadOnlySet Load(); + + void Save(IReadOnlySet collapsed); +} diff --git a/src/Kontext.Contracts/IWorkspaceChanges.cs b/src/Kontext.Contracts/IWorkspaceChanges.cs new file mode 100644 index 0000000..9edc0ed --- /dev/null +++ b/src/Kontext.Contracts/IWorkspaceChanges.cs @@ -0,0 +1,10 @@ +using Kontext.Domain; + +namespace Kontext.Contracts; + +public interface IWorkspaceChanges +{ + Task GetChangesAsync(string worktreePath, CancellationToken cancellationToken = default); + + Task GetDiffAsync(string worktreePath, FileChange change, CancellationToken cancellationToken = default); +} diff --git a/src/Kontext.Domain/FileChange.cs b/src/Kontext.Domain/FileChange.cs new file mode 100644 index 0000000..9ee926e --- /dev/null +++ b/src/Kontext.Domain/FileChange.cs @@ -0,0 +1,23 @@ +namespace Kontext.Domain; + +public enum FileChangeStatus +{ + Modified, + Added, + Deleted, + Renamed, + Untracked, + Conflicted, +} + +/// One changed file. Paths are relative to the worktree root and use '/' as git prints them. +/// Null when unknown (binary or oversized files). +/// Null when unknown (binary or oversized files). +public sealed record FileChange( + string Path, + string? OriginalPath, + FileChangeStatus Status, + bool IsStaged, + int? Additions, + int? Deletions, + bool IsBinary); diff --git a/src/Kontext.Domain/FileDiff.cs b/src/Kontext.Domain/FileDiff.cs new file mode 100644 index 0000000..5ce118c --- /dev/null +++ b/src/Kontext.Domain/FileDiff.cs @@ -0,0 +1,32 @@ +namespace Kontext.Domain; + +public enum DiffLineKind +{ + Context, + Added, + Removed, + HunkHeader, + Note, +} + +public sealed record DiffLine(DiffLineKind Kind, int? OldNumber, int? NewNumber, string Text); + +/// The diff of one file. explains a missing or cut-off diff (binary, too large, conflict). +public sealed record FileDiff(string Path, IReadOnlyList Lines, string? Notice = null) +{ + public bool Equals(FileDiff? other) + => other is not null && Path == other.Path && Notice == other.Notice && Lines.SequenceEqual(other.Lines); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Path); + hash.Add(Notice); + foreach (var line in Lines) + { + hash.Add(line); + } + + return hash.ToHashCode(); + } +} diff --git a/src/Kontext.Domain/WorkingTreeChanges.cs b/src/Kontext.Domain/WorkingTreeChanges.cs new file mode 100644 index 0000000..42f4d45 --- /dev/null +++ b/src/Kontext.Domain/WorkingTreeChanges.cs @@ -0,0 +1,26 @@ +namespace Kontext.Domain; + +public sealed record WorkingTreeChanges(IReadOnlyList Staged, IReadOnlyList Unstaged) +{ + public static WorkingTreeChanges Empty { get; } = new([], []); + + public bool Equals(WorkingTreeChanges? other) + => other is not null && Staged.SequenceEqual(other.Staged) && Unstaged.SequenceEqual(other.Unstaged); + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var change in Staged) + { + hash.Add(change); + } + + hash.Add(int.MinValue); + foreach (var change in Unstaged) + { + hash.Add(change); + } + + return hash.ToHashCode(); + } +} diff --git a/src/Kontext.GitAutomation/NumstatParser.cs b/src/Kontext.GitAutomation/NumstatParser.cs new file mode 100644 index 0000000..0748349 --- /dev/null +++ b/src/Kontext.GitAutomation/NumstatParser.cs @@ -0,0 +1,47 @@ +using System.Globalization; + +namespace Kontext.GitAutomation; + +/// Line counts of one file; both null when git reports the file as binary. +internal readonly record struct NumstatEntry(int? Additions, int? Deletions); + +internal static class NumstatParser +{ + /// Parses git diff --numstat -z. The result is keyed by the (new) path. + public static IReadOnlyDictionary Parse(string output) + { + ArgumentNullException.ThrowIfNull(output); + + var counts = new Dictionary(StringComparer.Ordinal); + var records = output.Split('\0'); + + for (var i = 0; i < records.Length; i++) + { + var parts = records[i].Split('\t', 3); + if (parts.Length < 3) + { + continue; + } + + var path = parts[2]; + if (path.Length == 0) + { + // A rename: the old and new paths follow as two separate records. + if (i + 2 >= records.Length) + { + break; + } + + path = records[i + 2]; + i += 2; + } + + counts[path] = new NumstatEntry(Count(parts[0]), Count(parts[1])); + } + + return counts; + } + + private static int? Count(string text) + => int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var value) ? value : null; +} diff --git a/src/Kontext.GitAutomation/ServiceCollectionExtensions.cs b/src/Kontext.GitAutomation/ServiceCollectionExtensions.cs index c00cbb2..9ecd11e 100644 --- a/src/Kontext.GitAutomation/ServiceCollectionExtensions.cs +++ b/src/Kontext.GitAutomation/ServiceCollectionExtensions.cs @@ -15,6 +15,7 @@ public static IServiceCollection AddKontextGitAutomation(this IServiceCollection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Kontext.GitAutomation/StatusPorcelainV2Parser.cs b/src/Kontext.GitAutomation/StatusPorcelainV2Parser.cs new file mode 100644 index 0000000..d0a3cb2 --- /dev/null +++ b/src/Kontext.GitAutomation/StatusPorcelainV2Parser.cs @@ -0,0 +1,60 @@ +namespace Kontext.GitAutomation; + +/// One record of git status --porcelain=v2 -z. and are the XY characters; '.' means unchanged. +internal sealed record StatusEntry(string Path, string? OriginalPath, char Index, char Worktree, bool IsUntracked, bool IsConflicted); + +internal static class StatusPorcelainV2Parser +{ + // Number of space-separated fields before the path (the path itself may contain spaces). + private const int OrdinaryFields = 9; + private const int RenamedFields = 10; + private const int UnmergedFields = 11; + + public static IReadOnlyList Parse(string output) + { + ArgumentNullException.ThrowIfNull(output); + + var records = output.Split('\0'); + var entries = new List(); + + for (var i = 0; i < records.Length; i++) + { + var record = records[i]; + if (record.Length < 2) + { + continue; + } + + switch (record[0]) + { + case '1': + AddWithPath(entries, record, OrdinaryFields, originalPath: null, conflicted: false); + break; + case '2': + // A rename or copy is followed by its original path as a separate NUL-terminated record. + var original = i + 1 < records.Length ? records[++i] : null; + AddWithPath(entries, record, RenamedFields, original, conflicted: false); + break; + case 'u': + AddWithPath(entries, record, UnmergedFields, originalPath: null, conflicted: true); + break; + case '?': + entries.Add(new StatusEntry(record[2..], null, '.', '?', IsUntracked: true, IsConflicted: false)); + break; + } + } + + return entries; + } + + private static void AddWithPath(List entries, string record, int fieldCount, string? originalPath, bool conflicted) + { + var fields = record.Split(' ', fieldCount); + if (fields.Length < fieldCount || fields[1].Length != 2) + { + return; + } + + entries.Add(new StatusEntry(fields[^1], originalPath, fields[1][0], fields[1][1], IsUntracked: false, conflicted)); + } +} diff --git a/src/Kontext.GitAutomation/UnifiedDiffParser.cs b/src/Kontext.GitAutomation/UnifiedDiffParser.cs new file mode 100644 index 0000000..c0d8893 --- /dev/null +++ b/src/Kontext.GitAutomation/UnifiedDiffParser.cs @@ -0,0 +1,99 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Kontext.Domain; + +namespace Kontext.GitAutomation; + +internal static partial class UnifiedDiffParser +{ + public const int MaxLines = 5000; + + public const string BinaryNotice = "Binary file changed."; + + public const string NoChangesNotice = "No textual changes."; + + /// Turns the output of git diff for one file into numbered lines, capped at (hunk headers count). + public static FileDiff Parse(string path, string output, int maxLines = MaxLines) + { + ArgumentNullException.ThrowIfNull(output); + + var lines = new List(); + var inHunk = false; + var truncated = false; + int oldNumber = 0, newNumber = 0; + + foreach (var raw in output.Split('\n')) + { + var line = raw.EndsWith('\r') ? raw[..^1] : raw; + + if (!inHunk) + { + if (IsBinaryMarker(line)) + { + return new FileDiff(path, [], BinaryNotice); + } + + if (!line.StartsWith("@@", StringComparison.Ordinal)) + { + continue; + } + + inHunk = true; + } + + if (line.Length == 0) + { + continue; + } + + if (lines.Count >= maxLines) + { + truncated = true; + break; + } + + if (line.StartsWith("@@", StringComparison.Ordinal)) + { + var header = HunkHeader().Match(line); + if (header.Success) + { + oldNumber = int.Parse(header.Groups["old"].Value, CultureInfo.InvariantCulture); + newNumber = int.Parse(header.Groups["new"].Value, CultureInfo.InvariantCulture); + } + + lines.Add(new DiffLine(DiffLineKind.HunkHeader, null, null, line)); + continue; + } + + switch (line[0]) + { + case ' ': + lines.Add(new DiffLine(DiffLineKind.Context, oldNumber++, newNumber++, line[1..])); + break; + case '-': + lines.Add(new DiffLine(DiffLineKind.Removed, oldNumber++, null, line[1..])); + break; + case '+': + lines.Add(new DiffLine(DiffLineKind.Added, null, newNumber++, line[1..])); + break; + case '\\': + lines.Add(new DiffLine(DiffLineKind.Note, null, null, line)); + break; + } + } + + if (truncated) + { + return new FileDiff(path, lines, string.Create(CultureInfo.InvariantCulture, $"Showing the first {maxLines:N0} lines.")); + } + + return new FileDiff(path, lines, lines.Count == 0 ? NoChangesNotice : null); + } + + private static bool IsBinaryMarker(string line) + => line == "GIT binary patch" + || (line.StartsWith("Binary files ", StringComparison.Ordinal) && line.EndsWith(" differ", StringComparison.Ordinal)); + + [GeneratedRegex(@"^@@ -(?\d+)(?:,\d+)? \+(?\d+)(?:,\d+)? @@")] + private static partial Regex HunkHeader(); +} diff --git a/src/Kontext.GitAutomation/WorkspaceChanges.cs b/src/Kontext.GitAutomation/WorkspaceChanges.cs new file mode 100644 index 0000000..795ca02 --- /dev/null +++ b/src/Kontext.GitAutomation/WorkspaceChanges.cs @@ -0,0 +1,250 @@ +using System.Text; +using Kontext.Contracts; +using Kontext.Domain; + +namespace Kontext.GitAutomation; + +internal sealed class WorkspaceChanges(IGitProcess git) : IWorkspaceChanges +{ + private const int MaxUntrackedBytes = 256 * 1024; + private const int BinarySniffBytes = 8000; + + // Untracked files can number in the thousands (an unignored build folder); only the first ones are read for line counts. + private const int MaxCountedUntrackedFiles = 200; + + // A diff this big is never worth reading into memory on every poll. + private const int MaxDiffChangedLines = 20_000; + + // Polling runs while Claude may be running git itself; never take the index lock. + private const string NoLocks = "--no-optional-locks"; + + public const string ConflictNotice = "Merge conflict. Resolve it in the terminal."; + + public const string TooLargeNotice = "File too large to preview."; + + public const string UnavailableNotice = "File not available."; + + public const string DiffTooLargeNotice = "Diff too large to preview."; + + public async Task GetChangesAsync(string worktreePath, CancellationToken cancellationToken = default) + { + var status = await git.RunCheckedAsync(worktreePath, [NoLocks, "status", "--porcelain=v2", "-z", "--untracked-files=all"], cancellationToken); + var entries = StatusPorcelainV2Parser.Parse(status.StandardOutput); + if (entries.Count == 0) + { + return WorkingTreeChanges.Empty; + } + + var stagedCounts = await CountsAsync(worktreePath, staged: true, needed: entries.Any(entry => IsTrackedChange(entry, entry.Index)), cancellationToken); + var unstagedCounts = await CountsAsync(worktreePath, staged: false, needed: entries.Any(entry => IsTrackedChange(entry, entry.Worktree)), cancellationToken); + + var staged = new List(); + var unstaged = new List(); + var untrackedSeen = 0; + + foreach (var entry in entries) + { + if (entry.IsConflicted) + { + unstaged.Add(new FileChange(entry.Path, null, FileChangeStatus.Conflicted, IsStaged: false, null, null, IsBinary: false)); + } + else if (entry.IsUntracked) + { + unstaged.Add(await UntrackedChangeAsync(worktreePath, entry.Path, countLines: untrackedSeen++ < MaxCountedUntrackedFiles, cancellationToken)); + } + else + { + if (entry.Index != '.') + { + staged.Add(TrackedChange(entry, entry.Index, isStaged: true, stagedCounts)); + } + + if (entry.Worktree != '.') + { + unstaged.Add(TrackedChange(entry, entry.Worktree, isStaged: false, unstagedCounts)); + } + } + } + + return new WorkingTreeChanges(Sorted(staged), Sorted(unstaged)); + } + + public async Task GetDiffAsync(string worktreePath, FileChange change, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(change); + + switch (change.Status) + { + case FileChangeStatus.Untracked: + return await UntrackedDiffAsync(worktreePath, change.Path, cancellationToken); + case FileChangeStatus.Conflicted: + return new FileDiff(change.Path, [], ConflictNotice); + } + + if ((change.Additions ?? 0) + (change.Deletions ?? 0) > MaxDiffChangedLines) + { + return new FileDiff(change.Path, [], DiffTooLargeNotice); + } + + var arguments = new List { NoLocks, "diff" }; + if (change.IsStaged) + { + arguments.Add("--cached"); + } + + arguments.AddRange(["--no-color", "--no-ext-diff", "--unified=3", "--"]); + if (change.IsStaged && change.OriginalPath is not null) + { + arguments.Add(change.OriginalPath); + } + + arguments.Add(change.Path); + + var result = await git.RunCheckedAsync(worktreePath, arguments, cancellationToken); + return UnifiedDiffParser.Parse(change.Path, result.StandardOutput); + } + + private static bool IsTrackedChange(StatusEntry entry, char code) + => !entry.IsUntracked && !entry.IsConflicted && code != '.'; + + private static List Sorted(List changes) + { + changes.Sort((left, right) => StringComparer.OrdinalIgnoreCase.Compare(left.Path, right.Path)); + return changes; + } + + private async Task> CountsAsync(string worktreePath, bool staged, bool needed, CancellationToken cancellationToken) + { + if (!needed) + { + return new Dictionary(); + } + + string[] arguments = staged + ? [NoLocks, "diff", "--cached", "--numstat", "-z"] + : [NoLocks, "diff", "--numstat", "-z"]; + + var result = await git.RunCheckedAsync(worktreePath, arguments, cancellationToken); + return NumstatParser.Parse(result.StandardOutput); + } + + private static FileChange TrackedChange(StatusEntry entry, char code, bool isStaged, IReadOnlyDictionary counts) + { + var status = code switch + { + 'A' or 'C' => FileChangeStatus.Added, + 'D' => FileChangeStatus.Deleted, + 'R' => FileChangeStatus.Renamed, + _ => FileChangeStatus.Modified, + }; + + var original = isStaged && status == FileChangeStatus.Renamed ? entry.OriginalPath : null; + + if (!counts.TryGetValue(entry.Path, out var count)) + { + return new FileChange(entry.Path, original, status, isStaged, null, null, IsBinary: false); + } + + var binary = count.Additions is null && count.Deletions is null; + return new FileChange(entry.Path, original, status, isStaged, count.Additions, count.Deletions, binary); + } + + private static string FullPath(string worktreePath, string path) + => Path.Combine(worktreePath, path.Replace('/', Path.DirectorySeparatorChar)); + + private static bool LooksBinary(byte[] content) + => Array.IndexOf(content, (byte)0, 0, Math.Min(content.Length, BinarySniffBytes)) >= 0; + + /// Reads a file that another process may be writing to. Returns null when it cannot be read (locked, vanished, no access). + private static async Task TryReadAsync(string fullPath, CancellationToken cancellationToken) + { + try + { + await using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: 4096, useAsync: true); + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer, cancellationToken); + return buffer.ToArray(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + private static async Task UntrackedChangeAsync(string worktreePath, string path, bool countLines, CancellationToken cancellationToken) + { + var file = new FileInfo(FullPath(worktreePath, path)); + if (!countLines || !file.Exists || file.Length > MaxUntrackedBytes) + { + return new FileChange(path, null, FileChangeStatus.Untracked, IsStaged: false, null, null, IsBinary: false); + } + + var content = await TryReadAsync(file.FullName, cancellationToken); + if (content is null) + { + return new FileChange(path, null, FileChangeStatus.Untracked, IsStaged: false, null, null, IsBinary: false); + } + + if (LooksBinary(content)) + { + return new FileChange(path, null, FileChangeStatus.Untracked, IsStaged: false, null, null, IsBinary: true); + } + + return new FileChange(path, null, FileChangeStatus.Untracked, IsStaged: false, LineCount(content), 0, IsBinary: false); + } + + private static int LineCount(byte[] content) + { + if (content.Length == 0) + { + return 0; + } + + var lines = content.Count(value => value == (byte)'\n'); + return content[^1] == (byte)'\n' ? lines : lines + 1; + } + + private static async Task UntrackedDiffAsync(string worktreePath, string path, CancellationToken cancellationToken) + { + var file = new FileInfo(FullPath(worktreePath, path)); + if (!file.Exists) + { + return new FileDiff(path, [], UnavailableNotice); + } + + if (file.Length > MaxUntrackedBytes) + { + return new FileDiff(path, [], TooLargeNotice); + } + + var content = await TryReadAsync(file.FullName, cancellationToken); + if (content is null) + { + return new FileDiff(path, [], UnavailableNotice); + } + + if (LooksBinary(content)) + { + return new FileDiff(path, [], UnifiedDiffParser.BinaryNotice); + } + + var text = Encoding.UTF8.GetString(content); + var lines = text.Split('\n').Select(line => line.EndsWith('\r') ? line[..^1] : line).ToList(); + if (lines.Count > 0 && lines[^1].Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + if (lines.Count == 0) + { + return new FileDiff(path, [], UnifiedDiffParser.NoChangesNotice); + } + + var capped = lines.Take(UnifiedDiffParser.MaxLines).Select((line, index) => new DiffLine(DiffLineKind.Added, null, index + 1, line)).ToList(); + var notice = lines.Count > UnifiedDiffParser.MaxLines + ? string.Create(System.Globalization.CultureInfo.InvariantCulture, $"Showing the first {UnifiedDiffParser.MaxLines:N0} lines.") + : null; + + return new FileDiff(path, capped, notice); + } +} diff --git a/src/Kontext.Presentation/DetailTab.cs b/src/Kontext.Presentation/DetailTab.cs new file mode 100644 index 0000000..0e8fcd2 --- /dev/null +++ b/src/Kontext.Presentation/DetailTab.cs @@ -0,0 +1,8 @@ +namespace Kontext.Presentation; + +public enum DetailTab +{ + Terminal, + Issue, + Git, +} diff --git a/src/Kontext.Presentation/GitChangeRow.cs b/src/Kontext.Presentation/GitChangeRow.cs new file mode 100644 index 0000000..7ecd701 --- /dev/null +++ b/src/Kontext.Presentation/GitChangeRow.cs @@ -0,0 +1,31 @@ +using System.Globalization; +using Kontext.Domain; + +namespace Kontext.Presentation; + +/// One line of the change list: either a group header ("Staged (2)") or a changed file. +public sealed record GitChangeRow(string Text, FileChange? Change) +{ + public bool IsHeader => Change is null; + + public string StatusLetter => Change?.Status switch + { + FileChangeStatus.Modified => "M", + FileChangeStatus.Added => "A", + FileChangeStatus.Deleted => "D", + FileChangeStatus.Renamed => "R", + FileChangeStatus.Untracked => "?", + FileChangeStatus.Conflicted => "!", + _ => string.Empty, + }; + + public string FileName => Change is null ? string.Empty : Change.Path[(Change.Path.LastIndexOf('/') + 1)..]; + + public string Folder => Change is null ? string.Empty : Change.Path[..Math.Max(Change.Path.LastIndexOf('/'), 0)]; + + public bool HasCounts => Change is { Additions: not null } or { Deletions: not null }; + + public string AdditionsText => Change?.Additions is { } additions ? string.Create(CultureInfo.InvariantCulture, $"+{additions}") : string.Empty; + + public string DeletionsText => Change?.Deletions is { } deletions ? string.Create(CultureInfo.InvariantCulture, $"−{deletions}") : string.Empty; +} diff --git a/src/Kontext.Presentation/GitChangesOptions.cs b/src/Kontext.Presentation/GitChangesOptions.cs new file mode 100644 index 0000000..164450b --- /dev/null +++ b/src/Kontext.Presentation/GitChangesOptions.cs @@ -0,0 +1,6 @@ +namespace Kontext.Presentation; + +public sealed record GitChangesOptions(TimeSpan PollInterval) +{ + public static GitChangesOptions Default { get; } = new(TimeSpan.FromSeconds(3)); +} diff --git a/src/Kontext.Presentation/GitChangesViewModel.cs b/src/Kontext.Presentation/GitChangesViewModel.cs new file mode 100644 index 0000000..fc25052 --- /dev/null +++ b/src/Kontext.Presentation/GitChangesViewModel.cs @@ -0,0 +1,298 @@ +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using Kontext.Contracts; +using Kontext.Domain; + +namespace Kontext.Presentation; + +/// +/// The uncommitted changes of one worktree. It reloads when it becomes active and then polls, but only while +/// and a are both set. Await continuations return to the +/// thread that set those properties (the UI thread), so no dispatcher is needed. +/// +public sealed partial class GitChangesViewModel(IWorkspaceChanges changes, TimeProvider clock, GitChangesOptions options) : ObservableObject, IDisposable +{ + private readonly LatestOnly _reloads = new(); + private readonly LatestOnly _selections = new(); + private CancellationTokenSource? _loop; + private WorkingTreeChanges _last = WorkingTreeChanges.Empty; + private FileChange? _selectedChange; + private bool _disposed; + + [ObservableProperty] + public partial string? WorktreePath { get; set; } + + [ObservableProperty] + public partial bool IsActive { get; set; } + + [ObservableProperty] + public partial IReadOnlyList Rows { get; set; } = []; + + [ObservableProperty] + public partial FileDiff? SelectedDiff { get; set; } + + [ObservableProperty] + public partial IReadOnlyList SplitRows { get; set; } = []; + + /// Whether the diff is shown old-beside-new instead of as one column. It is a per-session choice, not saved. + [ObservableProperty] + public partial bool IsSplitView { get; set; } + + [ObservableProperty] + public partial string Summary { get; set; } = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError), nameof(IsEmpty))] + public partial string? ErrorMessage { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsEmpty))] + public partial bool HasLoaded { get; set; } + + public bool HasChanges => _last.Staged.Count + _last.Unstaged.Count > 0; + + public bool HasError => !string.IsNullOrEmpty(ErrorMessage); + + public bool IsEmpty => HasLoaded && !HasChanges && !HasError; + + public bool HasSelectedChange => _selectedChange is not null; + + public bool NeedsSelection => HasChanges && _selectedChange is null; + + public FileChange? SelectedChange + { + get => _selectedChange; + set + { + if (Select(value)) + { + _ = LoadSelectedDiffAsync(_selections.Begin().Token); + } + } + } + + public void Dispose() + { + _disposed = true; + StopLoop(); + _reloads.Dispose(); + _selections.Dispose(); + } + + public async Task ReloadAsync() + { + var path = WorktreePath; + if (_disposed || string.IsNullOrEmpty(path)) + { + return; + } + + var operation = _reloads.Begin(); + var token = operation.Token; + + try + { + // Starting git and reading files must not happen on the UI thread; the continuation comes back to it. + var result = await Task.Run(() => changes.GetChangesAsync(path, token), token); + if (!IsCurrent(operation, path)) + { + return; + } + + Apply(result); + ErrorMessage = null; + HasLoaded = true; + await LoadSelectedDiffAsync(token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } +#pragma warning disable CA1031 // Any git failure is shown to the user; the next poll tries again. + catch (Exception ex) +#pragma warning restore CA1031 + { + if (IsCurrent(operation, path)) + { + ErrorMessage = ex.Message; + } + } + } + + partial void OnWorktreePathChanged(string? value) + { + StopLoop(); + + _last = WorkingTreeChanges.Empty; + Rows = []; + Summary = string.Empty; + ErrorMessage = null; + HasLoaded = false; + Select(null); + SelectedDiff = null; + OnPropertyChanged(nameof(HasChanges)); + OnPropertyChanged(nameof(IsEmpty)); + + StartLoopIfNeeded(); + } + + partial void OnSelectedDiffChanged(FileDiff? value) => SplitRows = value is null ? [] : SplitDiffRows.Build(value.Lines); + + partial void OnIsActiveChanged(bool value) + { + StopLoop(); + StartLoopIfNeeded(); + } + + private bool IsCurrent(CancellationTokenSource operation, string path) + => !_disposed && _reloads.IsCurrent(operation) && WorktreePath == path; + + private void StartLoopIfNeeded() + { + if (_disposed || !IsActive || string.IsNullOrEmpty(WorktreePath)) + { + return; + } + + var loop = _loop = new CancellationTokenSource(); + _ = RunLoopAsync(loop.Token); + } + + private void StopLoop() + { + if (_loop is { } loop) + { + _loop = null; + loop.Cancel(); + loop.Dispose(); + } + + _reloads.CancelCurrent(); + _selections.CancelCurrent(); + } + + private async Task RunLoopAsync(CancellationToken token) + { + try + { + // Created before the first reload so a tick that arrives while it runs is not lost. + using var timer = new PeriodicTimer(options.PollInterval, clock); + + await ReloadAsync(); + while (await timer.WaitForNextTickAsync(token)) + { + await ReloadAsync(); + } + } + catch (OperationCanceledException) + { + } + } + + private void Apply(WorkingTreeChanges result) + { + if (!result.Equals(_last)) + { + _last = result; + Rows = BuildRows(result); + Summary = BuildSummary(result); + OnPropertyChanged(nameof(HasChanges)); + OnPropertyChanged(nameof(IsEmpty)); + } + + var current = _selectedChange; + var next = current is null + ? null + : result.Staged.Concat(result.Unstaged).FirstOrDefault(change => change.Path == current.Path && change.IsStaged == current.IsStaged); + + Select(next ?? FirstOf(result.Staged) ?? FirstOf(result.Unstaged)); + } + + private static FileChange? FirstOf(IReadOnlyList group) => group.Count > 0 ? group[0] : null; + + private bool Select(FileChange? value) + { + if (EqualityComparer.Default.Equals(_selectedChange, value)) + { + return false; + } + + var sameFile = _selectedChange is { } previous && value is { } next && previous.Path == next.Path && previous.IsStaged == next.IsStaged; + _selectedChange = value; + + if (!sameFile) + { + SelectedDiff = null; + } + + OnPropertyChanged(nameof(SelectedChange)); + OnPropertyChanged(nameof(HasSelectedChange)); + OnPropertyChanged(nameof(NeedsSelection)); + return true; + } + + private async Task LoadSelectedDiffAsync(CancellationToken token) + { + var change = _selectedChange; + var path = WorktreePath; + if (change is null || string.IsNullOrEmpty(path)) + { + SelectedDiff = null; + return; + } + + try + { + var diff = await Task.Run(() => changes.GetDiffAsync(path, change, token), token); + if (!token.IsCancellationRequested && ReferenceEquals(change, _selectedChange) && path == WorktreePath) + { + SelectedDiff = diff; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } +#pragma warning disable CA1031 // Any git failure is shown to the user; the next poll tries again. + catch (Exception ex) +#pragma warning restore CA1031 + { + if (!token.IsCancellationRequested && ReferenceEquals(change, _selectedChange) && path == WorktreePath) + { + ErrorMessage = ex.Message; + } + } + } + + private static List BuildRows(WorkingTreeChanges result) + { + var rows = new List(); + AddGroup(rows, "Staged", result.Staged); + AddGroup(rows, "Changes", result.Unstaged); + return rows; + } + + private static void AddGroup(List rows, string title, IReadOnlyList group) + { + if (group.Count == 0) + { + return; + } + + rows.Add(new GitChangeRow(string.Create(CultureInfo.InvariantCulture, $"{title} ({group.Count})"), null)); + rows.AddRange(group.Select(change => new GitChangeRow(change.Path, change))); + } + + private static string BuildSummary(WorkingTreeChanges result) + { + var all = result.Staged.Concat(result.Unstaged).ToList(); + var files = all.Select(change => change.Path).Distinct(StringComparer.Ordinal).Count(); + if (files == 0) + { + return string.Empty; + } + + var additions = all.Sum(change => change.Additions ?? 0); + var deletions = all.Sum(change => change.Deletions ?? 0); + var noun = files == 1 ? "file" : "files"; + return string.Create(CultureInfo.InvariantCulture, $"{files} {noun} changed +{additions} −{deletions}"); + } +} diff --git a/src/Kontext.Presentation/IssueDetailViewModel.cs b/src/Kontext.Presentation/IssueDetailViewModel.cs index c9d7435..032da59 100644 --- a/src/Kontext.Presentation/IssueDetailViewModel.cs +++ b/src/Kontext.Presentation/IssueDetailViewModel.cs @@ -24,7 +24,8 @@ public IssueDetailViewModel( IConfirmation confirmation, IWorkspaceOpener opener, IIssueSessionRegistry registry, - SelectionAutoStart autoStart) + SelectionAutoStart autoStart, + GitChangesViewModel gitChanges) { _coordinator = coordinator; _clearer = clearer; @@ -32,6 +33,7 @@ public IssueDetailViewModel( _opener = opener; _registry = registry; _autoStart = autoStart; + GitChanges = gitChanges; _registry.Changed += OnSessionChanged; _autoStart.PropertyChanged += OnAutoStartChanged; @@ -41,10 +43,16 @@ public IssueDetailViewModel( public partial IssueItemViewModel? Item { get; set; } [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowTerminalTab), nameof(VisibleSession))] - public partial bool ShowIssueTab { get; set; } + [NotifyPropertyChangedFor(nameof(ShowTerminalTab), nameof(ShowGitTab), nameof(ShowIssueTab), nameof(VisibleSession))] + public partial DetailTab SelectedTab { get; set; } - public bool ShowTerminalTab => !ShowIssueTab; + public bool ShowTerminalTab => SelectedTab == DetailTab.Terminal; + + public bool ShowGitTab => SelectedTab == DetailTab.Git; + + public bool ShowIssueTab => SelectedTab == DetailTab.Issue; + + public GitChangesViewModel GitChanges { get; } public bool HasSelection => Item is not null; @@ -120,8 +128,11 @@ public void Dispose() _registry.Changed -= OnSessionChanged; _autoStart.PropertyChanged -= OnAutoStartChanged; UnsubscribeFrom(Item); + GitChanges.Dispose(); } + partial void OnSelectedTabChanged(DetailTab value) => SyncGitChanges(); + partial void OnItemChanged(IssueItemViewModel? oldValue, IssueItemViewModel? newValue) { UnsubscribeFrom(oldValue); @@ -208,8 +219,22 @@ private void OnSessionChanged(object? sender, IssueSessionChangedEventArgs e) } } + // The Git tab only exists for an issue with a worktree, and the changes are polled only while that tab is shown. + private void SyncGitChanges() + { + if (ShowGitTab && !HasWorkspace) + { + SelectedTab = DetailTab.Terminal; + return; + } + + GitChanges.WorktreePath = WorktreePath; + GitChanges.IsActive = ShowGitTab; + } + private void Refresh() { + SyncGitChanges(); OnPropertyChanged(string.Empty); ResumeCommand.NotifyCanExecuteChanged(); OpenInCommand.NotifyCanExecuteChanged(); diff --git a/src/Kontext.Presentation/IssueItemViewModel.cs b/src/Kontext.Presentation/IssueItemViewModel.cs index eb691e3..a4b1444 100644 --- a/src/Kontext.Presentation/IssueItemViewModel.cs +++ b/src/Kontext.Presentation/IssueItemViewModel.cs @@ -31,7 +31,20 @@ public IssueItemViewModel(GitHubIssue issue, IssueWorkState state, DateTimeOffse [NotifyPropertyChangedFor(nameof(Status), nameof(StatusText))] public partial IssueWorkState State { get; set; } - public IssueWorkStatus Status => Issue.IsClosed && State.Status is IssueWorkStatus.Ready or IssueWorkStatus.NeedsInput ? IssueWorkStatus.Done : State.Status; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Status), nameof(StatusText))] + public partial bool HasSavedSession { get; set; } + + /// + /// What the menu shows. A ready environment whose Claude session was saved earlier shows as waiting for the user, + /// which is what resuming it gives; the tracked state itself stays Ready so that selecting the issue still resumes it. + /// + public IssueWorkStatus Status => State.Status switch + { + IssueWorkStatus.Ready or IssueWorkStatus.NeedsInput when Issue.IsClosed => IssueWorkStatus.Done, + IssueWorkStatus.Ready when HasSavedSession && !State.SessionEnded => IssueWorkStatus.NeedsInput, + var other => other, + }; public string StatusText => IssueStatusText.For(Status); diff --git a/src/Kontext.Presentation/IssueSidebarViewModel.cs b/src/Kontext.Presentation/IssueSidebarViewModel.cs index ac24b92..a4352db 100644 --- a/src/Kontext.Presentation/IssueSidebarViewModel.cs +++ b/src/Kontext.Presentation/IssueSidebarViewModel.cs @@ -14,8 +14,11 @@ public sealed partial class IssueSidebarViewModel : ObservableObject, IDisposabl private readonly IExistingWorkspaceFinder _existingWorkspaces; private readonly IWorkspaceIssues _workspaceIssues; private readonly IHiddenRepositoryStore _hiddenRepositories; + private readonly ICollapsedRepositoryStore _collapsedRepositories; + private readonly ISessionStore _sessions; private readonly TimeProvider _clock; private readonly HashSet _hidden; + private readonly HashSet _collapsed; private readonly LatestOnly _loads = new(); private readonly Dictionary _groupsByName = new(StringComparer.OrdinalIgnoreCase); @@ -29,6 +32,8 @@ public IssueSidebarViewModel( IExistingWorkspaceFinder existingWorkspaces, IWorkspaceIssues workspaceIssues, IHiddenRepositoryStore hiddenRepositories, + ICollapsedRepositoryStore collapsedRepositories, + ISessionStore sessions, TimeProvider clock) { _gitHub = gitHub; @@ -36,8 +41,11 @@ public IssueSidebarViewModel( _existingWorkspaces = existingWorkspaces; _workspaceIssues = workspaceIssues; _hiddenRepositories = hiddenRepositories; + _collapsedRepositories = collapsedRepositories; + _sessions = sessions; _clock = clock; _hidden = new HashSet(hiddenRepositories.Load(), StringComparer.OrdinalIgnoreCase); + _collapsed = new HashSet(collapsedRepositories.Load(), StringComparer.OrdinalIgnoreCase); _tracker.Changed += OnTrackerChanged; } @@ -204,6 +212,20 @@ private void OnRepositoryVisibilityChanged(RepositoryFilterItemViewModel filter) ApplyFilter(); } + private void OnGroupExpandedChanged(RepositoryGroupViewModel group) + { + if (group.IsExpanded) + { + _collapsed.Remove(group.Name); + } + else + { + _collapsed.Add(group.Name); + } + + _collapsedRepositories.Save(_collapsed); + } + private void MarkExistingWorkspaces(IReadOnlyList workspaces) { foreach (var workspace in workspaces) @@ -213,6 +235,8 @@ private void MarkExistingWorkspaces(IReadOnlyList workspaces) continue; } + item.HasSavedSession = _sessions.Find(workspace.Issue) is not null; + var status = _tracker.StateOf(workspace.Issue).Status; var closed = item.Issue.IsClosed; @@ -241,7 +265,7 @@ private void ApplyFilter() { if (!_groupsByName.TryGetValue(group.Repository, out var viewModel)) { - viewModel = new RepositoryGroupViewModel(group.Repository, group.Items); + viewModel = new RepositoryGroupViewModel(group.Repository, group.Items, !_collapsed.Contains(group.Repository), OnGroupExpandedChanged); _groupsByName[group.Repository] = viewModel; } else @@ -288,6 +312,11 @@ private void OnTrackerChanged(object? sender, IssueWorkChangedEventArgs e) item.State = e.State; + if (e.State.Status == IssueWorkStatus.NotStarted) + { + item.HasSavedSession = false; + } + if (item.Issue.IsClosed && e.State.Status == IssueWorkStatus.NotStarted) { Remove(item); diff --git a/src/Kontext.Presentation/RepositoryGroupViewModel.cs b/src/Kontext.Presentation/RepositoryGroupViewModel.cs index 3d8b78e..dad14b2 100644 --- a/src/Kontext.Presentation/RepositoryGroupViewModel.cs +++ b/src/Kontext.Presentation/RepositoryGroupViewModel.cs @@ -2,12 +2,27 @@ namespace Kontext.Presentation; -public sealed class RepositoryGroupViewModel(string name, IEnumerable items) +public sealed class RepositoryGroupViewModel(string name, IEnumerable items, bool isExpanded = true, Action? expandedChanged = null) { + private bool _isExpanded = isExpanded; + public string Name { get; } = name; public ObservableCollection Items { get; } = new(items); + /// Set by the menu when the user expands or collapses the group; a change is reported so it can be remembered. + public bool IsExpanded + { + get => _isExpanded; + set + { + if (_isExpanded == value) + { + return; + } - public bool IsExpanded { get; set; } = true; + _isExpanded = value; + expandedChanged?.Invoke(this); + } + } } diff --git a/src/Kontext.Presentation/ServiceCollectionExtensions.cs b/src/Kontext.Presentation/ServiceCollectionExtensions.cs index 84a5f7b..2213e46 100644 --- a/src/Kontext.Presentation/ServiceCollectionExtensions.cs +++ b/src/Kontext.Presentation/ServiceCollectionExtensions.cs @@ -14,6 +14,9 @@ public static IServiceCollection AddKontextPresentation(this IServiceCollection services.AddSingleton(); services.AddTransient(); + services.AddSingleton(GitChangesOptions.Default); + + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Kontext.Presentation/ShellViewModel.cs b/src/Kontext.Presentation/ShellViewModel.cs index 6295f1a..7626cbe 100644 --- a/src/Kontext.Presentation/ShellViewModel.cs +++ b/src/Kontext.Presentation/ShellViewModel.cs @@ -16,17 +16,17 @@ public sealed partial class ShellViewModel( public WorkspaceViewModel Workspace { get; } = workspace; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsSignedIn), nameof(IsSignedOut), nameof(ShowSignIn), nameof(SignedInText), nameof(GitHubStatusText), nameof(HasGitHubAccount), nameof(HasNoGitHubAccount))] + [NotifyPropertyChangedFor(nameof(IsSignedIn), nameof(IsSignedOut), nameof(ShowSignIn), nameof(ShowWorkspace), nameof(SignedInText), nameof(GitHubStatusText), nameof(HasGitHubAccount), nameof(HasNoGitHubAccount))] [NotifyCanExecuteChangedFor(nameof(SignInCommand))] public partial GitHubUser? User { get; set; } [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsSignedIn), nameof(IsSignedOut), nameof(ShowSignIn), nameof(EnterpriseSignedInText), nameof(EnterpriseStatusText), nameof(HasEnterpriseAccount), nameof(HasNoEnterpriseAccount))] + [NotifyPropertyChangedFor(nameof(IsSignedIn), nameof(IsSignedOut), nameof(ShowSignIn), nameof(ShowWorkspace), nameof(EnterpriseSignedInText), nameof(EnterpriseStatusText), nameof(HasEnterpriseAccount), nameof(HasNoEnterpriseAccount))] [NotifyCanExecuteChangedFor(nameof(SignInEnterpriseCommand))] public partial GitHubUser? EnterpriseUser { get; set; } [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowSignIn))] + [NotifyPropertyChangedFor(nameof(ShowSignIn), nameof(ShowWorkspace))] public partial bool IsStarting { get; set; } = true; [ObservableProperty] @@ -57,6 +57,9 @@ public sealed partial class ShellViewModel( public bool ShowSignIn => IsSignedOut && !IsStarting; + /// The main layout is there from the first frame, while starting and once signed in; only the sign-in form replaces it. + public bool ShowWorkspace => IsSignedIn || IsStarting; + public bool HasGitHubAccount => User is not null; public bool HasNoGitHubAccount => User is null; diff --git a/src/Kontext.Presentation/SplitDiffRows.cs b/src/Kontext.Presentation/SplitDiffRows.cs new file mode 100644 index 0000000..5c04fae --- /dev/null +++ b/src/Kontext.Presentation/SplitDiffRows.cs @@ -0,0 +1,68 @@ +using Kontext.Domain; + +namespace Kontext.Presentation; + +/// One side of a split diff row. is Context, Removed (left) or Added (right). +public sealed record SplitCell(DiffLineKind Kind, int? Number, string Text); + +/// +/// One row of a split diff: the old text on the left, the new text on the right. A hunk header or note has no +/// sides and instead spans the whole row through . +/// +public sealed record SplitDiffRow(SplitCell? Left, SplitCell? Right, DiffLine? Spanning = null); + +public static class SplitDiffRows +{ + /// Lays a unified diff out side by side. A run of removed lines is paired, in order, with the added lines that follow it. + public static IReadOnlyList Build(IReadOnlyList lines) + { + var rows = new List(lines.Count); + var removed = new List(); + var added = new List(); + + foreach (var line in lines) + { + if (line.Kind == DiffLineKind.Added) + { + added.Add(line); + continue; + } + + // Anything else ends the pending run; a removed line then starts the next one. + if (line.Kind != DiffLineKind.Removed || added.Count > 0) + { + Flush(rows, removed, added); + } + + if (line.Kind == DiffLineKind.Removed) + { + removed.Add(line); + } + else if (line.Kind == DiffLineKind.Context) + { + rows.Add(new SplitDiffRow(new SplitCell(DiffLineKind.Context, line.OldNumber, line.Text), new SplitCell(DiffLineKind.Context, line.NewNumber, line.Text))); + } + else + { + rows.Add(new SplitDiffRow(null, null, line)); + } + } + + Flush(rows, removed, added); + return rows; + } + + private static void Flush(List rows, List removed, List added) + { + var count = Math.Max(removed.Count, added.Count); + for (var index = 0; index < count; index++) + { + var left = index < removed.Count ? new SplitCell(DiffLineKind.Removed, removed[index].OldNumber, removed[index].Text) : null; + var right = index < added.Count ? new SplitCell(DiffLineKind.Added, added[index].NewNumber, added[index].Text) : null; + rows.Add(new SplitDiffRow(left, right)); + } + + removed.Clear(); + added.Clear(); + } +} diff --git a/src/Kontext.Sessions/JsonCollapsedRepositoryStore.cs b/src/Kontext.Sessions/JsonCollapsedRepositoryStore.cs new file mode 100644 index 0000000..aaa9c3f --- /dev/null +++ b/src/Kontext.Sessions/JsonCollapsedRepositoryStore.cs @@ -0,0 +1,12 @@ +using Kontext.Contracts; + +namespace Kontext.Sessions; + +internal sealed class JsonCollapsedRepositoryStore(SessionsOptions options) : ICollapsedRepositoryStore +{ + private readonly JsonNameSetFile _file = new(options.StateDirectory, "collapsed-repositories.json"); + + public IReadOnlySet Load() => _file.Load(); + + public void Save(IReadOnlySet collapsed) => _file.Save(collapsed); +} diff --git a/src/Kontext.Sessions/JsonHiddenRepositoryStore.cs b/src/Kontext.Sessions/JsonHiddenRepositoryStore.cs index 1a47de0..699f901 100644 --- a/src/Kontext.Sessions/JsonHiddenRepositoryStore.cs +++ b/src/Kontext.Sessions/JsonHiddenRepositoryStore.cs @@ -1,44 +1,12 @@ -using System.Text.Json; using Kontext.Contracts; namespace Kontext.Sessions; internal sealed class JsonHiddenRepositoryStore(SessionsOptions options) : IHiddenRepositoryStore { - private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private readonly JsonNameSetFile _file = new(options.StateDirectory, "hidden-repositories.json"); - private readonly Lock _gate = new(); - private readonly string _filePath = Path.Combine(options.StateDirectory, "hidden-repositories.json"); + public IReadOnlySet Load() => _file.Load(); - public IReadOnlySet Load() - { - lock (_gate) - { - if (!File.Exists(_filePath)) - { - return new HashSet(StringComparer.OrdinalIgnoreCase); - } - - try - { - var names = JsonSerializer.Deserialize(File.ReadAllText(_filePath)) ?? []; - return new HashSet(names, StringComparer.OrdinalIgnoreCase); - } - catch (JsonException) - { - return new HashSet(StringComparer.OrdinalIgnoreCase); - } - } - } - - public void Save(IReadOnlySet hidden) - { - ArgumentNullException.ThrowIfNull(hidden); - - lock (_gate) - { - Directory.CreateDirectory(options.StateDirectory); - File.WriteAllText(_filePath, JsonSerializer.Serialize(hidden.Order(StringComparer.OrdinalIgnoreCase), JsonOptions)); - } - } + public void Save(IReadOnlySet hidden) => _file.Save(hidden); } diff --git a/src/Kontext.Sessions/JsonNameSetFile.cs b/src/Kontext.Sessions/JsonNameSetFile.cs new file mode 100644 index 0000000..810930b --- /dev/null +++ b/src/Kontext.Sessions/JsonNameSetFile.cs @@ -0,0 +1,44 @@ +using System.Text.Json; + +namespace Kontext.Sessions; + +/// A small case-insensitive set of names kept in one JSON file. A missing or corrupt file reads as empty. +internal sealed class JsonNameSetFile(string directory, string fileName) +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private readonly Lock _gate = new(); + private readonly string _filePath = Path.Combine(directory, fileName); + + public IReadOnlySet Load() + { + lock (_gate) + { + if (!File.Exists(_filePath)) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + try + { + var names = JsonSerializer.Deserialize(File.ReadAllText(_filePath)) ?? []; + return new HashSet(names, StringComparer.OrdinalIgnoreCase); + } + catch (JsonException) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + } + } + + public void Save(IReadOnlySet names) + { + ArgumentNullException.ThrowIfNull(names); + + lock (_gate) + { + Directory.CreateDirectory(directory); + File.WriteAllText(_filePath, JsonSerializer.Serialize(names.Order(StringComparer.OrdinalIgnoreCase), JsonOptions)); + } + } +} diff --git a/src/Kontext.Sessions/ServiceCollectionExtensions.cs b/src/Kontext.Sessions/ServiceCollectionExtensions.cs index 273f54f..061cc27 100644 --- a/src/Kontext.Sessions/ServiceCollectionExtensions.cs +++ b/src/Kontext.Sessions/ServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ public static IServiceCollection AddKontextSessions(this IServiceCollection serv services.AddSingleton(options); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Kontext.TerminalHost/TerminalShortcut.cs b/src/Kontext.TerminalHost/TerminalShortcut.cs index 06a9e33..7564d6b 100644 --- a/src/Kontext.TerminalHost/TerminalShortcut.cs +++ b/src/Kontext.TerminalHost/TerminalShortcut.cs @@ -4,4 +4,5 @@ public enum TerminalShortcut { Copy, Paste, + NewLine, } diff --git a/src/Kontext.TerminalHost/TerminalShortcutReader.cs b/src/Kontext.TerminalHost/TerminalShortcutReader.cs index bdb9513..a42f441 100644 --- a/src/Kontext.TerminalHost/TerminalShortcutReader.cs +++ b/src/Kontext.TerminalHost/TerminalShortcutReader.cs @@ -2,10 +2,17 @@ namespace Kontext.TerminalHost; public static class TerminalShortcutReader { + private const int VkReturn = 0x0D; private const int VkInsert = 0x2D; private const int VkC = 0x43; private const int VkV = 0x56; + /// + /// What Shift+Enter sends: Escape then Enter, the sequence Claude (and most shells) read as "new line, do not submit". + /// The terminal itself would send the same code as plain Enter. + /// + public const string NewLineInput = "\u001b\r"; + public static TerminalShortcut? FromKey(int virtualKey, bool control, bool shift, bool alt) { if (alt) @@ -15,6 +22,7 @@ public static class TerminalShortcutReader return virtualKey switch { + VkReturn when shift && !control => TerminalShortcut.NewLine, VkV when control => TerminalShortcut.Paste, VkInsert when shift && !control => TerminalShortcut.Paste, VkInsert when control && !shift => TerminalShortcut.Copy, diff --git a/src/Kontext/Terminal/TerminalKeyboardHook.cs b/src/Kontext/Terminal/TerminalKeyboardHook.cs index 191c4aa..4c92b24 100644 --- a/src/Kontext/Terminal/TerminalKeyboardHook.cs +++ b/src/Kontext/Terminal/TerminalKeyboardHook.cs @@ -10,8 +10,10 @@ internal sealed class TerminalKeyboardHook : IDisposable private const uint WmChar = 0x0102; private const int VkC = 0x43; private const int VkV = 0x56; + private const int VkReturn = 0x0D; private const nint CharCopy = 0x03; private const nint CharPaste = 0x16; + private const nint CharReturn = 0x0D; private const nuint SubclassId = 3; private readonly MouseReportingState _state; @@ -46,7 +48,7 @@ private bool Handle(uint message, nint wParam, nint lParam, out nint result) return _handledKeys.Remove((int)wParam); case WmChar: - var swallow = _swallowChar && wParam is CharCopy or CharPaste; + var swallow = _swallowChar && wParam is CharCopy or CharPaste or CharReturn; _swallowChar = false; return swallow; @@ -64,7 +66,11 @@ private bool KeyDown(int virtualKey) return false; } - if (action == TerminalShortcut.Paste) + if (action == TerminalShortcut.NewLine) + { + _send(TerminalShortcutReader.NewLineInput); + } + else if (action == TerminalShortcut.Paste) { if (!ClipboardHasText()) { @@ -79,7 +85,7 @@ private bool KeyDown(int virtualKey) } _handledKeys.Add(virtualKey); - _swallowChar = virtualKey is VkC or VkV; + _swallowChar = virtualKey is VkC or VkV or VkReturn; return true; } diff --git a/src/Kontext/Views/GitChangesTemplateSelectors.cs b/src/Kontext/Views/GitChangesTemplateSelectors.cs new file mode 100644 index 0000000..c4e065a --- /dev/null +++ b/src/Kontext/Views/GitChangesTemplateSelectors.cs @@ -0,0 +1,85 @@ +using Kontext.Domain; +using Kontext.Presentation; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Kontext.Views; + +/// Picks the template for a group header or a changed file in the change list. +public sealed class GitRowTemplateSelector : DataTemplateSelector +{ + public DataTemplate? HeaderTemplate { get; set; } + + public DataTemplate? FileTemplate { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item) => item is GitChangeRow { IsHeader: true } ? HeaderTemplate : FileTemplate; + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container) => SelectTemplateCore(item); +} + +/// Gives group headers a container that cannot be selected. +public sealed class GitRowStyleSelector : StyleSelector +{ + public Style? HeaderStyle { get; set; } + + public Style? FileStyle { get; set; } + + protected override Style? SelectStyleCore(object item, DependencyObject container) => item is GitChangeRow { IsHeader: true } ? HeaderStyle : FileStyle; +} + +/// Picks the layout of a split-diff row: two sides, or one cell across both for a hunk header. +public sealed class SplitRowTemplateSelector : DataTemplateSelector +{ + public DataTemplate? PairTemplate { get; set; } + + public DataTemplate? SpanTemplate { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item) => item is SplitDiffRow { Spanning: not null } ? SpanTemplate : PairTemplate; + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container) => SelectTemplateCore(item); +} + +/// Picks the template (and so the background) for one side of a split-diff row. +public sealed class SplitCellTemplateSelector : DataTemplateSelector +{ + public DataTemplate? AddedTemplate { get; set; } + + public DataTemplate? RemovedTemplate { get; set; } + + public DataTemplate? ContextTemplate { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item) => item is SplitCell cell + ? cell.Kind switch + { + DiffLineKind.Added => AddedTemplate, + DiffLineKind.Removed => RemovedTemplate, + _ => ContextTemplate, + } + : ContextTemplate; + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container) => SelectTemplateCore(item); +} + +/// Picks the template (and so the background) for one line of a diff. +public sealed class DiffLineTemplateSelector : DataTemplateSelector +{ + public DataTemplate? AddedTemplate { get; set; } + + public DataTemplate? RemovedTemplate { get; set; } + + public DataTemplate? ContextTemplate { get; set; } + + public DataTemplate? HunkTemplate { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item) => item is DiffLine line + ? line.Kind switch + { + DiffLineKind.Added => AddedTemplate, + DiffLineKind.Removed => RemovedTemplate, + DiffLineKind.HunkHeader => HunkTemplate, + _ => ContextTemplate, + } + : ContextTemplate; + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container) => SelectTemplateCore(item); +} diff --git a/src/Kontext/Views/GitChangesView.xaml b/src/Kontext/Views/GitChangesView.xaml new file mode 100644 index 0000000..c0490f6 --- /dev/null +++ b/src/Kontext/Views/GitChangesView.xaml @@ -0,0 +1,435 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Kontext/Views/GitChangesView.xaml.cs b/src/Kontext/Views/GitChangesView.xaml.cs new file mode 100644 index 0000000..1a7c479 --- /dev/null +++ b/src/Kontext/Views/GitChangesView.xaml.cs @@ -0,0 +1,122 @@ +using System.ComponentModel; +using Kontext.Presentation; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Kontext.Views; + +public sealed partial class GitChangesView : UserControl +{ + public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register( + nameof(ViewModel), + typeof(GitChangesViewModel), + typeof(GitChangesView), + new PropertyMetadata(null, (view, args) => ((GitChangesView)view).OnViewModelChanged((GitChangesViewModel?)args.OldValue, (GitChangesViewModel?)args.NewValue))); + + private bool _syncing; + + public GitChangesView() => InitializeComponent(); + + public GitChangesViewModel? ViewModel + { + get => (GitChangesViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + private void OnViewModelChanged(GitChangesViewModel? oldValue, GitChangesViewModel? newValue) + { + if (oldValue is not null) + { + oldValue.PropertyChanged -= OnViewModelPropertyChanged; + } + + if (newValue is not null) + { + newValue.PropertyChanged += OnViewModelPropertyChanged; + } + + Bindings?.Update(); + SyncList(); + SyncLayout(); + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrEmpty(e.PropertyName) || e.PropertyName is nameof(GitChangesViewModel.Rows) or nameof(GitChangesViewModel.SelectedChange)) + { + SyncList(); + } + + if (string.IsNullOrEmpty(e.PropertyName) || e.PropertyName == nameof(GitChangesViewModel.IsSplitView)) + { + SyncLayout(); + } + } + + private void SyncLayout() + { + var item = ViewModel?.IsSplitView == true ? SplitItem : UnifiedItem; + if (!ReferenceEquals(LayoutBar.SelectedItem, item)) + { + _syncing = true; + try + { + LayoutBar.SelectedItem = item; + } + finally + { + _syncing = false; + } + } + } + + private void LayoutBar_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args) + { + if (!_syncing && ViewModel is not null) + { + ViewModel.IsSplitView = ReferenceEquals(sender.SelectedItem, SplitItem); + } + } + + // The list owns no state of its own: it shows the view model's rows and follows its selection. + private void SyncList() + { + _syncing = true; + try + { + if (ViewModel is not { } viewModel) + { + FileList.ItemsSource = null; + return; + } + + if (!ReferenceEquals(FileList.ItemsSource, viewModel.Rows)) + { + FileList.ItemsSource = viewModel.Rows; + } + + var selected = viewModel.SelectedChange; + var row = selected is null ? null : viewModel.Rows.FirstOrDefault(candidate => candidate.Change == selected); + if (!ReferenceEquals(FileList.SelectedItem, row)) + { + FileList.SelectedItem = row; + } + } + finally + { + _syncing = false; + } + } + + private void FileList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!_syncing && ViewModel is not null && FileList.SelectedItem is GitChangeRow { Change: { } change }) + { + ViewModel.SelectedChange = change; + } + } + + // Rows must be at least as wide as the viewport so their backgrounds reach the right edge. + private void DiffScroller_SizeChanged(object sender, SizeChangedEventArgs e) + => DiffRepeater.MinWidth = Math.Max(0, e.NewSize.Width - 20); +} diff --git a/src/Kontext/Views/GitViewFormat.cs b/src/Kontext/Views/GitViewFormat.cs new file mode 100644 index 0000000..79d32e2 --- /dev/null +++ b/src/Kontext/Views/GitViewFormat.cs @@ -0,0 +1,18 @@ +using System.Globalization; +using Kontext.Domain; +using Microsoft.UI.Xaml; + +namespace Kontext.Views; + +/// Small helpers that XAML function bindings call from . +public static class GitViewFormat +{ + public static string Number(int? value) => value?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + + public static Visibility VisibleWhenText(string? text) => string.IsNullOrEmpty(text) ? Visibility.Collapsed : Visibility.Visible; + + public static string Title(FileChange? change) + => change is null + ? string.Empty + : change.OriginalPath is null ? change.Path : $"{change.OriginalPath} → {change.Path}"; +} diff --git a/src/Kontext/Views/IssueDetailView.xaml b/src/Kontext/Views/IssueDetailView.xaml index 6652bf4..a18d10c 100644 --- a/src/Kontext/Views/IssueDetailView.xaml +++ b/src/Kontext/Views/IssueDetailView.xaml @@ -180,8 +180,12 @@ - + Text="terminal" /> + + @@ -225,6 +229,8 @@ + + diff --git a/src/Kontext/Views/IssueDetailView.xaml.cs b/src/Kontext/Views/IssueDetailView.xaml.cs index 6f5f390..00d4a24 100644 --- a/src/Kontext/Views/IssueDetailView.xaml.cs +++ b/src/Kontext/Views/IssueDetailView.xaml.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using Kontext.Domain; using Kontext.Presentation; using Kontext.Terminal; @@ -12,7 +13,7 @@ public sealed partial class IssueDetailView : UserControl nameof(ViewModel), typeof(IssueDetailViewModel), typeof(IssueDetailView), - new PropertyMetadata(null, (view, _) => ((IssueDetailView)view).Bindings?.Update())); + new PropertyMetadata(null, (view, args) => ((IssueDetailView)view).OnViewModelChanged((IssueDetailViewModel?)args.OldValue, (IssueDetailViewModel?)args.NewValue))); public static readonly DependencyProperty ControlHostProperty = DependencyProperty.Register( nameof(ControlHost), @@ -42,11 +43,53 @@ public TerminalControlHost? ControlHost private void OpenIn(WorkspaceTool tool) => ViewModel?.OpenInCommand.Execute(tool); + private void OnViewModelChanged(IssueDetailViewModel? oldValue, IssueDetailViewModel? newValue) + { + if (oldValue is not null) + { + oldValue.PropertyChanged -= OnViewModelPropertyChanged; + } + + if (newValue is not null) + { + newValue.PropertyChanged += OnViewModelPropertyChanged; + } + + Bindings?.Update(); + SelectTabItem(); + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrEmpty(e.PropertyName) || e.PropertyName == nameof(IssueDetailViewModel.SelectedTab)) + { + SelectTabItem(); + } + } + + // The view model can move the selection itself (for example back to Terminal when the Git tab disappears). + private void SelectTabItem() + { + var item = ViewModel?.SelectedTab switch + { + DetailTab.Git => GitTab, + DetailTab.Issue => IssueTab, + _ => TerminalTab, + }; + + if (!ReferenceEquals(Tabs.SelectedItem, item)) + { + Tabs.SelectedItem = item; + } + } + private void Tabs_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args) { if (ViewModel is not null) { - ViewModel.ShowIssueTab = ReferenceEquals(sender.SelectedItem, IssueTab); + ViewModel.SelectedTab = ReferenceEquals(sender.SelectedItem, GitTab) + ? DetailTab.Git + : ReferenceEquals(sender.SelectedItem, IssueTab) ? DetailTab.Issue : DetailTab.Terminal; } } } diff --git a/src/Kontext/Views/ShellPage.xaml b/src/Kontext/Views/ShellPage.xaml index 954eb9e..555de7a 100644 --- a/src/Kontext/Views/ShellPage.xaml +++ b/src/Kontext/Views/ShellPage.xaml @@ -5,26 +5,6 @@ xmlns:views="using:Kontext.Views"> - - - - - - + Visibility="{x:Bind ViewModel.ShowWorkspace, Mode=OneWay}" /> diff --git a/src/Kontext/Views/VisibilityFormat.cs b/src/Kontext/Views/VisibilityFormat.cs new file mode 100644 index 0000000..542b148 --- /dev/null +++ b/src/Kontext/Views/VisibilityFormat.cs @@ -0,0 +1,17 @@ +using Microsoft.UI.Xaml; + +namespace Kontext.Views; + +/// Visibility helpers that XAML function bindings call. +public static class VisibilityFormat +{ + public static Visibility VisibleWhen(bool value) => value ? Visibility.Visible : Visibility.Collapsed; + + public static Visibility VisibleWhen(bool value, bool also) => value && also ? Visibility.Visible : Visibility.Collapsed; + + public static Visibility VisibleWhenNot(bool value, bool not) => value && !not ? Visibility.Visible : Visibility.Collapsed; + + public static bool Either(bool first, bool second) => first || second; + + public static bool Not(bool value) => !value; +} diff --git a/src/Kontext/Views/WorkspaceView.xaml b/src/Kontext/Views/WorkspaceView.xaml index 7693224..e67ce9d 100644 --- a/src/Kontext/Views/WorkspaceView.xaml +++ b/src/Kontext/Views/WorkspaceView.xaml @@ -90,10 +90,15 @@ Foreground="{ThemeResource TextFillColorSecondaryBrush}" Style="{StaticResource BodyTextBlockStyle}" Text="{x:Bind ViewModel.Workspace.Sidebar.CountText, Mode=OneWay}" /> + + IsActive="{x:Bind views:VisibilityFormat.Either(ViewModel.Workspace.Sidebar.IsLoading, ViewModel.IsStarting), Mode=OneWay}" /> @@ -171,10 +177,35 @@ + + + + + + + Visibility="{x:Bind views:VisibilityFormat.VisibleWhenNot(ViewModel.Workspace.IsDetailVisible, ViewModel.IsStarting), Mode=OneWay}" /> diff --git a/tests/Kontext.Claude.IntegrationTests/LoopbackClaudeHookServerTests.cs b/tests/Kontext.Claude.IntegrationTests/LoopbackClaudeHookServerTests.cs index f59b6c5..e17465c 100644 --- a/tests/Kontext.Claude.IntegrationTests/LoopbackClaudeHookServerTests.cs +++ b/tests/Kontext.Claude.IntegrationTests/LoopbackClaudeHookServerTests.cs @@ -66,6 +66,20 @@ public async Task ReportsAnEventForTheSessionNamedInTheAddress() Assert.That(hook, Is.EqualTo(new ClaudeHookEvent(session, "Stop"))); } + [Test] + public async Task PassesTheSessionIdClaudeReportsAlong() + { + var address = _server.Start()!; + var received = Capture(_server); + var session = TerminalSessionId.New(); + var claudeSession = Guid.NewGuid(); + + await _client.PostAsync(new Uri(address, $"hook/{session.Value:D}"), Json($$"""{ "hook_event_name": "Stop", "session_id": "{{claudeSession:D}}" }""")); + + var hook = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.That(hook, Is.EqualTo(new ClaudeHookEvent(session, "Stop", ClaudeSession: claudeSession))); + } + [Test] public async Task PassesTheNotificationTypeAlong() { diff --git a/tests/Kontext.Claude.UnitTests/ClaudeActivityReporterTests.cs b/tests/Kontext.Claude.UnitTests/ClaudeActivityReporterTests.cs index 8620071..0147ff7 100644 --- a/tests/Kontext.Claude.UnitTests/ClaudeActivityReporterTests.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeActivityReporterTests.cs @@ -12,6 +12,7 @@ public class ClaudeActivityReporterTests private InMemoryIssueWorkTracker _tracker = null!; private InMemoryIssueSessionRegistry _registry = null!; private FakeTerminalSession _session = null!; + private FakeSessionStore _store = null!; private ClaudeActivityReporter _reporter = null!; [SetUp] @@ -22,7 +23,8 @@ public void CreateReporter() _session = new FakeTerminalSession(TerminalSessionId.New()); _registry.Register(Key, _session); _tracker.Report(Key, new IssueWorkState(IssueWorkStatus.Ready, "kontext/4-x", @"C:\wt\4-x")); - _reporter = new ClaudeActivityReporter(_registry, _tracker, new ImmediateDispatcher()); + _store = new FakeSessionStore(); + _reporter = new ClaudeActivityReporter(_registry, _tracker, _store, new ImmediateDispatcher()); } private void Hook(string name, string? notificationType = null) => _reporter.OnHook(new ClaudeHookEvent(_session.Id, name, notificationType)); @@ -95,4 +97,64 @@ public void RefusesANullEvent() { Assert.That(() => _reporter.OnHook(null!), Throws.ArgumentNullException); } + + private static StoredSession Stored(TerminalSessionId id) => new(Key, id, @"C:\wt\4-x", "kontext/4-x"); + + [Test] + public void RemembersTheSessionClaudeIsReallyUsingWhenResumingGaveItANewId() + { + var earlier = TerminalSessionId.New(); + var now = Guid.NewGuid(); + _store.Save(Stored(earlier)); + + _reporter.OnHook(new ClaudeHookEvent(_session.Id, "UserPromptSubmit", ClaudeSession: now)); + + var saved = _store.Find(Key)!; + Assert.That(saved.SessionId, Is.EqualTo(new TerminalSessionId(now)), "the next resume must continue this conversation, not the one it was copied from"); + Assert.That(saved.WorktreePath, Is.EqualTo(@"C:\wt\4-x")); + Assert.That(saved.BranchName, Is.EqualTo("kontext/4-x")); + } + + [Test] + public void LeavesTheStoredSessionAloneWhileClaudeKeepsTheSameId() + { + var id = TerminalSessionId.New(); + var stored = Stored(id); + _store.Save(stored); + + _reporter.OnHook(new ClaudeHookEvent(_session.Id, "UserPromptSubmit", ClaudeSession: id.Value)); + + Assert.That(_store.Find(Key), Is.SameAs(stored)); + } + + [Test] + public void LeavesTheStoredSessionAloneWhenTheEventNamesNoSession() + { + var stored = Stored(TerminalSessionId.New()); + _store.Save(stored); + + Hook("UserPromptSubmit"); + + Assert.That(_store.Find(Key), Is.SameAs(stored)); + } + + [Test] + public void StoresNothingForAnIssueThatHasNoSavedSession() + { + _reporter.OnHook(new ClaudeHookEvent(_session.Id, "UserPromptSubmit", ClaudeSession: Guid.NewGuid())); + + Assert.That(_store.Find(Key), Is.Null); + } + + [Test] + public void StillRemembersTheSessionFromALateEventAfterItEnded() + { + _store.Save(Stored(TerminalSessionId.New())); + _tracker.Report(Key, _tracker.StateOf(Key) with { Status = IssueWorkStatus.Ready, SessionEnded = true }); + var now = Guid.NewGuid(); + + _reporter.OnHook(new ClaudeHookEvent(_session.Id, "Stop", ClaudeSession: now)); + + Assert.That(_store.Find(Key)!.SessionId, Is.EqualTo(new TerminalSessionId(now))); + } } diff --git a/tests/Kontext.Claude.UnitTests/ClaudeHookPayloadTests.cs b/tests/Kontext.Claude.UnitTests/ClaudeHookPayloadTests.cs index 8897ffc..4c29e27 100644 --- a/tests/Kontext.Claude.UnitTests/ClaudeHookPayloadTests.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeHookPayloadTests.cs @@ -9,7 +9,7 @@ public void ReadsTheEventName() { var parsed = ClaudeHookPayload.Parse("""{ "session_id": "abc", "hook_event_name": "Stop", "last_assistant_message": "done" }"""); - Assert.That(parsed, Is.EqualTo(("Stop", (string?)null))); + Assert.That(parsed, Is.EqualTo(("Stop", (string?)null, (string?)"abc"))); } [Test] @@ -17,7 +17,15 @@ public void ReadsTheNotificationType() { var parsed = ClaudeHookPayload.Parse("""{ "hook_event_name": "Notification", "notification_type": "permission_prompt", "message": "Claude needs your permission" }"""); - Assert.That(parsed, Is.EqualTo(("Notification", "permission_prompt"))); + Assert.That(parsed, Is.EqualTo(("Notification", (string?)"permission_prompt", (string?)null))); + } + + [Test] + public void ReadsTheSessionIdClaudeIsUsing() + { + var parsed = ClaudeHookPayload.Parse("""{ "session_id": "0a1e4b01-845d-4b16-9dad-8f5539363baa", "hook_event_name": "UserPromptSubmit" }"""); + + Assert.That(parsed?.SessionId, Is.EqualTo("0a1e4b01-845d-4b16-9dad-8f5539363baa")); } [TestCase("")] diff --git a/tests/Kontext.Claude.UnitTests/ClaudeHooksTests.cs b/tests/Kontext.Claude.UnitTests/ClaudeHooksTests.cs index 19a1dd1..f76af55 100644 --- a/tests/Kontext.Claude.UnitTests/ClaudeHooksTests.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeHooksTests.cs @@ -39,7 +39,7 @@ public void CreateFakes() } private ClaudeHooks Create(FakeHookServer server) - => new(server, new ClaudeActivityReporter(_registry, _tracker, new ImmediateDispatcher())); + => new(server, new ClaudeActivityReporter(_registry, _tracker, new FakeSessionStore(), new ImmediateDispatcher())); [Test] public void StartsListeningOnlyWhenAFirstSessionNeedsIt() diff --git a/tests/Kontext.CompositionTests/CompositionTests.cs b/tests/Kontext.CompositionTests/CompositionTests.cs index b9bfd15..28e0f00 100644 --- a/tests/Kontext.CompositionTests/CompositionTests.cs +++ b/tests/Kontext.CompositionTests/CompositionTests.cs @@ -54,11 +54,13 @@ public void BuildTheContainer() typeof(IRepositoryCloneStore), typeof(ISessionStore), typeof(IHiddenRepositoryStore), + typeof(ICollapsedRepositoryStore), typeof(IIssueWorkTracker), typeof(IIssueSessionRegistry), typeof(IIssueSessionCoordinator), typeof(IClaudeLauncher), typeof(IWorkspaceOpener), + typeof(IWorkspaceChanges), typeof(ClaudeEnvironmentScrubber), typeof(TerminalZoom), ]; @@ -69,6 +71,7 @@ public void BuildTheContainer() typeof(WorkspaceViewModel), typeof(IssueSidebarViewModel), typeof(IssueDetailViewModel), + typeof(GitChangesViewModel), typeof(AppLifetime), ]; diff --git a/tests/Kontext.GitAutomation.IntegrationTests/WorkspaceChangesIntegrationTests.cs b/tests/Kontext.GitAutomation.IntegrationTests/WorkspaceChangesIntegrationTests.cs new file mode 100644 index 0000000..b4dc08a --- /dev/null +++ b/tests/Kontext.GitAutomation.IntegrationTests/WorkspaceChangesIntegrationTests.cs @@ -0,0 +1,315 @@ +using Kontext.Domain; +using Kontext.TestSupport; + +namespace Kontext.GitAutomation.IntegrationTests; + +[TestFixture] +public class WorkspaceChangesIntegrationTests +{ + private const string Original = "one\ntwo\nthree\n"; + + private readonly GitProcess _git = new(new NoGitAuthentication()); + private TempDirectory _temp = null!; + private WorkspaceChanges _changes = null!; + private string _repo = null!; + + [SetUp] + public async Task CreateRepository() + { + _temp = new TempDirectory(); + _changes = new WorkspaceChanges(_git); + + _repo = _temp.Combine("repo"); + Directory.CreateDirectory(_repo); + await Git("init", "--initial-branch=main"); + await Git("config", "user.email", "test@example.com"); + await Git("config", "user.name", "Test"); + await Git("config", "core.autocrlf", "false"); + Write("README.md", Original); + await Git("add", "--", "."); + await Git("commit", "-m", "initial"); + } + + [TearDown] + public void DeleteRepository() => _temp.Dispose(); + + private async Task Git(params string[] arguments) => await _git.RunCheckedAsync(_repo, arguments); + + private void Write(string name, string content) => File.WriteAllText(System.IO.Path.Combine(_repo, name), content); + + private async Task CommitAsync(string name, string content) + { + Write(name, content); + await Git("add", "--", name); + await Git("commit", "-m", $"add {name}"); + } + + private static IEnumerable ChangedLines(FileDiff diff, DiffLineKind kind) + => diff.Lines.Where(line => line.Kind == kind).Select(line => line.Text); + + [Test] + public async Task ReportsAModifiedFileAsUnstaged() + { + Write("README.md", "one\nTWO\nthree\nfour\n"); + + var changes = await _changes.GetChangesAsync(_repo); + + Assert.That(changes.Staged, Is.Empty); + Assert.That(changes.Unstaged.Single(), Is.EqualTo(new FileChange("README.md", null, FileChangeStatus.Modified, IsStaged: false, 2, 1, IsBinary: false))); + } + + [Test] + public async Task ReportsAStagedFileAsStaged() + { + Write("README.md", "one\nTWO\nthree\nfour\n"); + await Git("add", "--", "README.md"); + + var changes = await _changes.GetChangesAsync(_repo); + + Assert.That(changes.Unstaged, Is.Empty); + Assert.That(changes.Staged.Single(), Is.EqualTo(new FileChange("README.md", null, FileChangeStatus.Modified, IsStaged: true, 2, 1, IsBinary: false))); + } + + [Test] + public async Task ReportsAPartiallyStagedFileInBothGroupsEachWithItsOwnDiff() + { + Write("README.md", "one\nTWO\nthree\n"); + await Git("add", "--", "README.md"); + Write("README.md", "one\nTWO\nthree\nfour\n"); + + var changes = await _changes.GetChangesAsync(_repo); + + var staged = changes.Staged.Single(); + var unstaged = changes.Unstaged.Single(); + Assert.That((staged.Additions, staged.Deletions), Is.EqualTo((1, 1))); + Assert.That((unstaged.Additions, unstaged.Deletions), Is.EqualTo((1, 0))); + + var stagedDiff = await _changes.GetDiffAsync(_repo, staged); + var unstagedDiff = await _changes.GetDiffAsync(_repo, unstaged); + Assert.That(ChangedLines(stagedDiff, DiffLineKind.Added), Is.EqualTo(Expect.Seq("TWO"))); + Assert.That(ChangedLines(stagedDiff, DiffLineKind.Removed), Is.EqualTo(Expect.Seq("two"))); + Assert.That(ChangedLines(unstagedDiff, DiffLineKind.Added), Is.EqualTo(Expect.Seq("four"))); + Assert.That(ChangedLines(unstagedDiff, DiffLineKind.Removed), Is.Empty); + } + + [Test] + public async Task ReportsAnUntrackedFileWithItsLineCount() + { + Write("notes.txt", "a\nb\nc\n"); + + var changes = await _changes.GetChangesAsync(_repo); + + Assert.That(changes.Staged, Is.Empty); + Assert.That(changes.Unstaged.Single(), Is.EqualTo(new FileChange("notes.txt", null, FileChangeStatus.Untracked, IsStaged: false, 3, 0, IsBinary: false))); + } + + [Test] + public async Task ReportsADeletedFile() + { + File.Delete(System.IO.Path.Combine(_repo, "README.md")); + + var changes = await _changes.GetChangesAsync(_repo); + + Assert.That(changes.Unstaged.Single(), Is.EqualTo(new FileChange("README.md", null, FileChangeStatus.Deleted, IsStaged: false, 0, 3, IsBinary: false))); + } + + [Test] + public async Task ReportsARenameWithItsOriginalPath() + { + await Git("mv", "README.md", "docs.md"); + + var changes = await _changes.GetChangesAsync(_repo); + + Assert.That(changes.Unstaged, Is.Empty); + var renamed = changes.Staged.Single(); + Assert.That(renamed.Status, Is.EqualTo(FileChangeStatus.Renamed)); + Assert.That(renamed.Path, Is.EqualTo("docs.md")); + Assert.That(renamed.OriginalPath, Is.EqualTo("README.md")); + Assert.That(renamed.IsStaged, Is.True); + + var diff = await _changes.GetDiffAsync(_repo, renamed); + Assert.That(diff.Lines.Where(line => line.Kind is DiffLineKind.Added or DiffLineKind.Removed), Is.Empty, "a pure rename has no textual hunk"); + } + + [Test] + public async Task ReportsABinaryFileWithoutCounts() + { + File.WriteAllBytes(System.IO.Path.Combine(_repo, "img.bin"), [1, 0, 2, 0]); + await Git("add", "--", "img.bin"); + await Git("commit", "-m", "binary"); + File.WriteAllBytes(System.IO.Path.Combine(_repo, "img.bin"), [1, 0, 3, 0]); + + var change = (await _changes.GetChangesAsync(_repo)).Unstaged.Single(); + + Assert.That(change.IsBinary, Is.True); + Assert.That(change.Additions, Is.Null); + Assert.That(change.Deletions, Is.Null); + + var diff = await _changes.GetDiffAsync(_repo, change); + Assert.That(diff.Lines, Is.Empty); + Assert.That(diff.Notice, Is.EqualTo("Binary file changed.")); + } + + [Test] + public async Task ReportsAnUntrackedBinaryFileWithoutCountsAndWithANotice() + { + File.WriteAllBytes(System.IO.Path.Combine(_repo, "new.bin"), [7, 0, 7]); + + var change = (await _changes.GetChangesAsync(_repo)).Unstaged.Single(); + + Assert.That((change.IsBinary, change.Additions, change.Deletions), Is.EqualTo((true, (int?)null, (int?)null))); + Assert.That((await _changes.GetDiffAsync(_repo, change)).Notice, Is.EqualTo("Binary file changed.")); + } + + [Test] + public async Task HandlesFileNamesWithSpacesDashesAndUnicode() + { + const string tracked = "-sp ace ü.txt"; + await CommitAsync(tracked, "x\n"); + Write(tracked, "x\ny\n"); + Write("héllo wörld.txt", "new\n"); + + var changes = await _changes.GetChangesAsync(_repo); + + Assert.That(changes.Unstaged.Select(change => change.Path), Is.EqualTo(Expect.Seq("-sp ace ü.txt", "héllo wörld.txt"))); + var diff = await _changes.GetDiffAsync(_repo, changes.Unstaged[0]); + Assert.That(diff.Lines.Single(line => line.Kind == DiffLineKind.Added).Text, Is.EqualTo("y")); + } + + [Test] + public async Task ReturnsNothingForACleanWorktree() + { + Assert.That(await _changes.GetChangesAsync(_repo), Is.EqualTo(WorkingTreeChanges.Empty)); + } + + [Test] + public async Task DoesNotListIgnoredFiles() + { + await CommitAsync(".gitignore", "*.log\n"); + Write("debug.log", "noise\n"); + + Assert.That(await _changes.GetChangesAsync(_repo), Is.EqualTo(WorkingTreeChanges.Empty)); + } + + [Test] + public async Task TheDiffOfAModifiedFileHasNumberedLines() + { + Write("README.md", "one\nTWO\nthree\nfour\n"); + var change = (await _changes.GetChangesAsync(_repo)).Unstaged.Single(); + + var diff = await _changes.GetDiffAsync(_repo, change); + + Assert.That(diff.Notice, Is.Null); + Assert.That(diff.Lines[0].Kind, Is.EqualTo(DiffLineKind.HunkHeader)); + Assert.That(diff.Lines.Skip(1), Is.EqualTo(Expect.Seq( + new DiffLine(DiffLineKind.Context, 1, 1, "one"), + new DiffLine(DiffLineKind.Removed, 2, null, "two"), + new DiffLine(DiffLineKind.Added, null, 2, "TWO"), + new DiffLine(DiffLineKind.Context, 3, 3, "three"), + new DiffLine(DiffLineKind.Added, null, 4, "four")))); + } + + [Test] + public async Task TheDiffOfAnUntrackedFileIsAllAddedLines() + { + Write("notes.txt", "a\nb\n"); + var change = (await _changes.GetChangesAsync(_repo)).Unstaged.Single(); + + var diff = await _changes.GetDiffAsync(_repo, change); + + Assert.That(diff.Notice, Is.Null); + Assert.That(diff.Lines, Is.EqualTo(Expect.Seq( + new DiffLine(DiffLineKind.Added, null, 1, "a"), + new DiffLine(DiffLineKind.Added, null, 2, "b")))); + } + + [Test] + public async Task AnUntrackedFileOver256KbHasNoCountsAndAPreviewNotice() + { + Write("big.txt", string.Concat(Enumerable.Repeat("xxxxxxxxxxxxxxx\n", 20_000))); + + var change = (await _changes.GetChangesAsync(_repo)).Unstaged.Single(); + + Assert.That((change.IsBinary, change.Additions, change.Deletions), Is.EqualTo((false, (int?)null, (int?)null))); + var diff = await _changes.GetDiffAsync(_repo, change); + Assert.That(diff.Lines, Is.Empty); + Assert.That(diff.Notice, Is.EqualTo("File too large to preview.")); + } + + [Test] + public async Task StillListsEverythingWhenAnUntrackedFileIsOpenForWriting() + { + // For example a log file that Claude, a dev server or a test run keeps appending to. + await using var writer = new FileStream(System.IO.Path.Combine(_repo, "busy.log"), FileMode.Create, FileAccess.Write, FileShare.ReadWrite); + await writer.WriteAsync("line\n"u8.ToArray()); + await writer.FlushAsync(); + Write("other.txt", "x\n"); + + var changes = await _changes.GetChangesAsync(_repo); + + Assert.That(changes.Unstaged.Select(change => change.Path), Is.EqualTo(Expect.Seq("busy.log", "other.txt"))); + var busy = changes.Unstaged[0]; + Assert.That(busy.Additions, Is.EqualTo(1)); + Assert.That((await _changes.GetDiffAsync(_repo, busy)).Lines.Single().Text, Is.EqualTo("line")); + } + + [Test] + public async Task ListsAnExclusivelyLockedUntrackedFileWithoutCountsInsteadOfFailing() + { + await using var locked = new FileStream(System.IO.Path.Combine(_repo, "locked.bin"), FileMode.Create, FileAccess.Write, FileShare.None); + await locked.WriteAsync("data\n"u8.ToArray()); + await locked.FlushAsync(); + Write("other.txt", "x\n"); + + var changes = await _changes.GetChangesAsync(_repo); + + Assert.That(changes.Unstaged.Select(change => change.Path), Is.EqualTo(Expect.Seq("locked.bin", "other.txt"))); + Assert.That((changes.Unstaged[0].Additions, changes.Unstaged[0].Deletions), Is.EqualTo(((int?)null, (int?)null))); + var diff = await _changes.GetDiffAsync(_repo, changes.Unstaged[0]); + Assert.That(diff.Lines, Is.Empty); + Assert.That(diff.Notice, Is.EqualTo("File not available.")); + } + + [Test] + public async Task CountsTheLinesOfOnlyTheFirstUntrackedFilesWhenThereAreHundreds() + { + for (var i = 0; i < 210; i++) + { + Write($"file{i:D3}.txt", "a\n"); + } + + var changes = await _changes.GetChangesAsync(_repo); + + Assert.That(changes.Unstaged, Has.Count.EqualTo(210), "every file is still listed"); + Assert.That(changes.Unstaged.Count(change => change.Additions is not null), Is.EqualTo(200)); + Assert.That(changes.Unstaged.Count(change => change.Additions is null), Is.EqualTo(10)); + } + + [Test] + public async Task ListsAConflictedFileWithoutADiff() + { + await Git("checkout", "-b", "side"); + await CommitAsync("README.md", "one\nside\nthree\n"); + await Git("checkout", "main"); + await CommitAsync("README.md", "one\nmain\nthree\n"); + var merge = await _git.RunAsync(_repo, ["merge", "side"]); + Assert.That(merge.Succeeded, Is.False, "the merge must conflict for this test to mean anything"); + + var changes = await _changes.GetChangesAsync(_repo); + + var conflicted = changes.Unstaged.Single(); + Assert.That(conflicted.Status, Is.EqualTo(FileChangeStatus.Conflicted)); + var diff = await _changes.GetDiffAsync(_repo, conflicted); + Assert.That(diff.Lines, Is.Empty); + Assert.That(diff.Notice, Is.EqualTo("Merge conflict. Resolve it in the terminal.")); + } + + [Test] + public async Task ThrowsGitAutomationExceptionWhenTheFolderIsNotARepository() + { + var plain = _temp.Combine("plain"); + Directory.CreateDirectory(plain); + + await Assert.ThatAsync(() => _changes.GetChangesAsync(plain), Throws.TypeOf()); + } +} diff --git a/tests/Kontext.GitAutomation.UnitTests/NumstatParserTests.cs b/tests/Kontext.GitAutomation.UnitTests/NumstatParserTests.cs new file mode 100644 index 0000000..02a52ec --- /dev/null +++ b/tests/Kontext.GitAutomation.UnitTests/NumstatParserTests.cs @@ -0,0 +1,54 @@ +namespace Kontext.GitAutomation.UnitTests; + +[TestFixture] +public class NumstatParserTests +{ + [Test] + public void ParsesCounts() + { + var counts = NumstatParser.Parse("3\t1\tsrc/Foo.cs\0"); + + Assert.That(counts["src/Foo.cs"], Is.EqualTo(new NumstatEntry(3, 1))); + } + + [Test] + public void ReportsBinaryFilesWithNullCounts() + { + var counts = NumstatParser.Parse("-\t-\timg.png\0"); + + Assert.That(counts["img.png"], Is.EqualTo(new NumstatEntry(null, null))); + } + + [Test] + public void KeysARenameByItsNewPath() + { + var counts = NumstatParser.Parse("0\t0\t\0old.txt\0new.txt\0"); + + Assert.That(counts.Keys, Is.EqualTo(Expect.Seq("new.txt"))); + Assert.That(counts["new.txt"], Is.EqualTo(new NumstatEntry(0, 0))); + } + + [Test] + public void KeepsSpacesInThePath() + { + var counts = NumstatParser.Parse("2\t0\ta b.txt\0"); + + Assert.That(counts["a b.txt"], Is.EqualTo(new NumstatEntry(2, 0))); + } + + [Test] + public void ParsesSeveralRecordsIncludingARenameInTheMiddle() + { + var counts = NumstatParser.Parse("1\t1\ta.cs\0" + "5\t2\t\0old.cs\0new.cs\0" + "7\t0\tz.cs\0"); + + Assert.That(counts.Keys, Is.EquivalentTo(Expect.Seq("a.cs", "new.cs", "z.cs"))); + Assert.That(counts["new.cs"], Is.EqualTo(new NumstatEntry(5, 2))); + Assert.That(counts["z.cs"], Is.EqualTo(new NumstatEntry(7, 0))); + } + + [Test] + public void ReturnsNothingForEmptyOutput() + { + Assert.That(NumstatParser.Parse(string.Empty), Is.Empty); + } +} diff --git a/tests/Kontext.GitAutomation.UnitTests/StatusPorcelainV2ParserTests.cs b/tests/Kontext.GitAutomation.UnitTests/StatusPorcelainV2ParserTests.cs new file mode 100644 index 0000000..fe92754 --- /dev/null +++ b/tests/Kontext.GitAutomation.UnitTests/StatusPorcelainV2ParserTests.cs @@ -0,0 +1,95 @@ +namespace Kontext.GitAutomation.UnitTests; + +[TestFixture] +public class StatusPorcelainV2ParserTests +{ + [Test] + public void ParsesAnUnstagedModification() + { + var entries = StatusPorcelainV2Parser.Parse("1 .M N... 100644 100644 100644 aaa bbb src/Foo.cs\0"); + + var entry = entries.Single(); + Assert.That(entry.Path, Is.EqualTo("src/Foo.cs")); + Assert.That(entry.Index, Is.EqualTo('.')); + Assert.That(entry.Worktree, Is.EqualTo('M')); + Assert.That(entry.OriginalPath, Is.Null); + Assert.That(entry.IsUntracked, Is.False); + Assert.That(entry.IsConflicted, Is.False); + } + + [Test] + public void ParsesAStagedAddition() + { + var entry = StatusPorcelainV2Parser.Parse("1 A. N... 000000 100644 100644 000 bbb new.cs\0").Single(); + + Assert.That(entry.Index, Is.EqualTo('A')); + Assert.That(entry.Worktree, Is.EqualTo('.')); + } + + [TestCase("a b.txt")] + [TestCase("héllo wörld.txt")] + [TestCase("-leading dash.txt")] + public void KeepsSpacesAndUnusualCharactersInThePath(string path) + { + var entry = StatusPorcelainV2Parser.Parse($"1 M. N... 100644 100644 100644 aaa bbb {path}\0").Single(); + + Assert.That(entry.Path, Is.EqualTo(path)); + } + + [Test] + public void ParsesARenameWithItsOriginalPath() + { + var entry = StatusPorcelainV2Parser.Parse("2 R. N... 100644 100644 100644 aaa bbb R100 new.txt\0old.txt\0").Single(); + + Assert.That(entry.Path, Is.EqualTo("new.txt")); + Assert.That(entry.OriginalPath, Is.EqualTo("old.txt")); + Assert.That(entry.Index, Is.EqualTo('R')); + } + + [Test] + public void ParsesAnUntrackedFile() + { + var entry = StatusPorcelainV2Parser.Parse("? new.txt\0").Single(); + + Assert.That(entry.Path, Is.EqualTo("new.txt")); + Assert.That(entry.IsUntracked, Is.True); + Assert.That(entry.Index, Is.EqualTo('.')); + Assert.That(entry.Worktree, Is.EqualTo('?')); + } + + [Test] + public void ParsesAConflict() + { + var entry = StatusPorcelainV2Parser.Parse("u UU N... 100644 100644 100644 100644 aaa bbb ccc c.txt\0").Single(); + + Assert.That(entry.Path, Is.EqualTo("c.txt")); + Assert.That(entry.IsConflicted, Is.True); + } + + [Test] + public void SkipsIgnoredAndHeaderRecords() + { + var entries = StatusPorcelainV2Parser.Parse("# branch.oid abc\0! ignored.log\0? kept.txt\0"); + + Assert.That(entries.Select(entry => entry.Path), Is.EqualTo(Expect.Seq("kept.txt"))); + } + + [Test] + public void ReturnsNothingForEmptyOutput() + { + Assert.That(StatusPorcelainV2Parser.Parse(string.Empty), Is.Empty); + } + + [Test] + public void ParsesSeveralRecordsInOrder() + { + const string output = + "1 .M N... 100644 100644 100644 aaa bbb a.cs\0" + + "2 R. N... 100644 100644 100644 aaa bbb R100 c.cs\0b.cs\0" + + "? d.cs\0"; + + var entries = StatusPorcelainV2Parser.Parse(output); + + Assert.That(entries.Select(entry => entry.Path), Is.EqualTo(Expect.Seq("a.cs", "c.cs", "d.cs"))); + } +} diff --git a/tests/Kontext.GitAutomation.UnitTests/UnifiedDiffParserTests.cs b/tests/Kontext.GitAutomation.UnitTests/UnifiedDiffParserTests.cs new file mode 100644 index 0000000..7c2197b --- /dev/null +++ b/tests/Kontext.GitAutomation.UnitTests/UnifiedDiffParserTests.cs @@ -0,0 +1,114 @@ +using Kontext.Domain; + +namespace Kontext.GitAutomation.UnitTests; + +[TestFixture] +public class UnifiedDiffParserTests +{ + private const string Header = + "diff --git a/src/Foo.cs b/src/Foo.cs\n" + + "index 1111111..2222222 100644\n" + + "--- a/src/Foo.cs\n" + + "+++ b/src/Foo.cs\n"; + + [Test] + public void IgnoresTheHeaderBeforeTheFirstHunk() + { + var diff = UnifiedDiffParser.Parse("src/Foo.cs", Header + "@@ -1 +1 @@\n-a\n+b\n"); + + Assert.That(diff.Lines.Select(line => line.Kind), Is.EqualTo(Expect.Seq(DiffLineKind.HunkHeader, DiffLineKind.Removed, DiffLineKind.Added))); + Assert.That(diff.Path, Is.EqualTo("src/Foo.cs")); + Assert.That(diff.Notice, Is.Null); + } + + [Test] + public void NumbersLinesFromTheHunkHeader() + { + var diff = UnifiedDiffParser.Parse("src/Foo.cs", Header + "@@ -10,3 +10,4 @@ class A\n a\n-b\n+b2\n+c\n d\n"); + + Assert.That(diff.Lines, Is.EqualTo(Expect.Seq( + new DiffLine(DiffLineKind.HunkHeader, null, null, "@@ -10,3 +10,4 @@ class A"), + new DiffLine(DiffLineKind.Context, 10, 10, "a"), + new DiffLine(DiffLineKind.Removed, 11, null, "b"), + new DiffLine(DiffLineKind.Added, null, 11, "b2"), + new DiffLine(DiffLineKind.Added, null, 12, "c"), + new DiffLine(DiffLineKind.Context, 12, 13, "d")))); + } + + [Test] + public void RestartsTheNumbersAtEachHunk() + { + var diff = UnifiedDiffParser.Parse("f", Header + "@@ -1 +1 @@\n-a\n+b\n@@ -50,2 +60,2 @@\n x\n-y\n+z\n"); + + var second = diff.Lines.Skip(3).ToList(); + Assert.That(second[0].Kind, Is.EqualTo(DiffLineKind.HunkHeader)); + Assert.That(second[1], Is.EqualTo(new DiffLine(DiffLineKind.Context, 50, 60, "x"))); + Assert.That(second[2], Is.EqualTo(new DiffLine(DiffLineKind.Removed, 51, null, "y"))); + Assert.That(second[3], Is.EqualTo(new DiffLine(DiffLineKind.Added, null, 61, "z"))); + } + + [TestCase("Binary files a/x.png and b/x.png differ\n")] + [TestCase("index 1..2 100644\nGIT binary patch\nliteral 3\nHcmZ\n")] + public void ReportsABinaryFileWithANoticeAndNoLines(string body) + { + var diff = UnifiedDiffParser.Parse("x.png", "diff --git a/x.png b/x.png\n" + body); + + Assert.That(diff.Lines, Is.Empty); + Assert.That(diff.Notice, Is.EqualTo("Binary file changed.")); + } + + [Test] + public void KeepsTheNoNewlineMarkerAsANote() + { + var diff = UnifiedDiffParser.Parse("f", Header + "@@ -1 +1 @@\n-a\n\\ No newline at end of file\n+b\n"); + + Assert.That(diff.Lines[2], Is.EqualTo(new DiffLine(DiffLineKind.Note, null, null, "\\ No newline at end of file"))); + Assert.That(diff.Lines[3], Is.EqualTo(new DiffLine(DiffLineKind.Added, null, 1, "b"))); + } + + [Test] + public void StripsTrailingCarriageReturns() + { + var diff = UnifiedDiffParser.Parse("f", Header + "@@ -1 +1 @@\r\n-a\r\n+b\r\n"); + + Assert.That(diff.Lines.Select(line => line.Text), Is.EqualTo(Expect.Seq("@@ -1 +1 @@", "a", "b"))); + } + + [Test] + public void CapsTheNumberOfLinesAndSaysSo() + { + var body = string.Concat(Enumerable.Range(1, 10).Select(i => $"+line {i}\n")); + + var diff = UnifiedDiffParser.Parse("f", Header + "@@ -0,0 +1,10 @@\n" + body, maxLines: 3); + + Assert.That(diff.Lines, Has.Count.EqualTo(3)); + Assert.That(diff.Notice, Is.EqualTo("Showing the first 3 lines.")); + } + + [Test] + public void DoesNotMentionACapWhenEverythingFits() + { + var diff = UnifiedDiffParser.Parse("f", Header + "@@ -0,0 +1,3 @@\n+a\n+b\n+c\n", maxLines: 4); + + Assert.That(diff.Lines, Has.Count.EqualTo(4)); + Assert.That(diff.Notice, Is.Null); + } + + [Test] + public void ReportsNoTextualChangesForEmptyOutput() + { + var diff = UnifiedDiffParser.Parse("f", string.Empty); + + Assert.That(diff.Lines, Is.Empty); + Assert.That(diff.Notice, Is.EqualTo("No textual changes.")); + } + + [Test] + public void ReportsNoTextualChangesForAHeaderOnlyDiffSuchAsAModeChange() + { + var diff = UnifiedDiffParser.Parse("f", "diff --git a/f b/f\nold mode 100644\nnew mode 100755\n"); + + Assert.That(diff.Lines, Is.Empty); + Assert.That(diff.Notice, Is.EqualTo("No textual changes.")); + } +} diff --git a/tests/Kontext.GitAutomation.UnitTests/WorkspaceChangesArgumentsTests.cs b/tests/Kontext.GitAutomation.UnitTests/WorkspaceChangesArgumentsTests.cs new file mode 100644 index 0000000..df291b8 --- /dev/null +++ b/tests/Kontext.GitAutomation.UnitTests/WorkspaceChangesArgumentsTests.cs @@ -0,0 +1,89 @@ +using Kontext.Domain; + +namespace Kontext.GitAutomation.UnitTests; + +[TestFixture] +public class WorkspaceChangesArgumentsTests +{ + // One file that is both staged and modified again, so every git command is needed. + private const string BothStagedAndUnstaged = "1 MM N... 100644 100644 100644 aaa bbb a.cs\0"; + + private FakeGitProcess _git = null!; + private WorkspaceChanges _changes = null!; + + [SetUp] + public void CreateService() + { + _git = new FakeGitProcess(); + _changes = new WorkspaceChanges(_git); + } + + [Test] + public async Task EveryGitCallStartsWithNoOptionalLocks() + { + _git.Result = new GitResult(0, BothStagedAndUnstaged, string.Empty); + + await _changes.GetChangesAsync(@"C:\wt"); + await _changes.GetDiffAsync(@"C:\wt", new FileChange("a.cs", null, FileChangeStatus.Modified, IsStaged: true, 1, 1, IsBinary: false)); + await _changes.GetDiffAsync(@"C:\wt", new FileChange("a.cs", null, FileChangeStatus.Modified, IsStaged: false, 1, 1, IsBinary: false)); + + Assert.That(_git.Runs, Has.Count.EqualTo(5), "status, two numstat runs and two diffs"); + Assert.That(_git.Runs.Select(run => run.Arguments[0]), Is.All.EqualTo("--no-optional-locks")); + Assert.That(_git.Runs.Select(run => run.WorkingDirectory), Is.All.EqualTo(@"C:\wt")); + } + + [Test] + public async Task AskForStatusWithNulSeparatedPorcelainV2AndEveryUntrackedFile() + { + await _changes.GetChangesAsync(@"C:\wt"); + + Assert.That(_git.Runs.Single().Arguments, Is.EqualTo(Expect.Seq("--no-optional-locks", "status", "--porcelain=v2", "-z", "--untracked-files=all"))); + } + + [Test] + public async Task DoesNotCountLinesWhenNothingChanged() + { + var changes = await _changes.GetChangesAsync(@"C:\wt"); + + Assert.That(changes, Is.EqualTo(WorkingTreeChanges.Empty)); + Assert.That(_git.Runs, Has.Count.EqualTo(1), "only the status call is needed for a clean tree"); + } + + [Test] + public async Task ThePathIsPassedAfterTheSeparatorAsOneArgument() + { + await _changes.GetDiffAsync(@"C:\wt", new FileChange("-weird file.txt", null, FileChangeStatus.Modified, IsStaged: false, 1, 0, IsBinary: false)); + + var arguments = _git.Runs.Single().Arguments; + Assert.That(arguments[^2..], Is.EqualTo(Expect.Seq("--", "-weird file.txt"))); + Assert.That(arguments, Does.Not.Contain("--cached")); + } + + [Test] + public async Task DoesNotRunGitForADiffTooLargeToPreview() + { + var diff = await _changes.GetDiffAsync(@"C:\wt", new FileChange("package-lock.json", null, FileChangeStatus.Modified, IsStaged: false, 15_000, 10_000, IsBinary: false)); + + Assert.That(_git.Runs, Is.Empty, "reading a huge diff into memory every poll would stall the app"); + Assert.That(diff.Lines, Is.Empty); + Assert.That(diff.Notice, Is.EqualTo("Diff too large to preview.")); + } + + [Test] + public async Task StillShowsADiffJustBelowTheLimit() + { + await _changes.GetDiffAsync(@"C:\wt", new FileChange("big.cs", null, FileChangeStatus.Modified, IsStaged: false, 10_000, 9_999, IsBinary: false)); + + Assert.That(_git.Runs, Has.Count.EqualTo(1)); + } + + [Test] + public async Task AStagedDiffUsesCachedAndBothPathsOfARename() + { + await _changes.GetDiffAsync(@"C:\wt", new FileChange("new.txt", "old.txt", FileChangeStatus.Renamed, IsStaged: true, 0, 0, IsBinary: false)); + + var arguments = _git.Runs.Single().Arguments; + Assert.That(arguments, Does.Contain("--cached")); + Assert.That(arguments[^3..], Is.EqualTo(Expect.Seq("--", "old.txt", "new.txt"))); + } +} diff --git a/tests/Kontext.Presentation.UnitTests/GitChangeRowTests.cs b/tests/Kontext.Presentation.UnitTests/GitChangeRowTests.cs new file mode 100644 index 0000000..d228487 --- /dev/null +++ b/tests/Kontext.Presentation.UnitTests/GitChangeRowTests.cs @@ -0,0 +1,61 @@ +using Kontext.Domain; + +namespace Kontext.Presentation.UnitTests; + +[TestFixture] +public class GitChangeRowTests +{ + private static GitChangeRow File(string path = "src/Views/Foo.cs", FileChangeStatus status = FileChangeStatus.Modified, int? additions = 12, int? deletions = 3) + => new(path, new FileChange(path, null, status, IsStaged: false, additions, deletions, IsBinary: false)); + + [TestCase(FileChangeStatus.Modified, "M")] + [TestCase(FileChangeStatus.Added, "A")] + [TestCase(FileChangeStatus.Deleted, "D")] + [TestCase(FileChangeStatus.Renamed, "R")] + [TestCase(FileChangeStatus.Untracked, "?")] + [TestCase(FileChangeStatus.Conflicted, "!")] + public void ShowsTheStatusLetterForEachStatus(FileChangeStatus status, string letter) + { + Assert.That(File(status: status).StatusLetter, Is.EqualTo(letter)); + } + + [Test] + public void SplitsTheFileNameFromItsFolder() + { + var nested = File("src/Views/Foo.cs"); + var root = File("README.md"); + + Assert.That(nested.FileName, Is.EqualTo("Foo.cs")); + Assert.That(nested.Folder, Is.EqualTo("src/Views")); + Assert.That(root.FileName, Is.EqualTo("README.md")); + Assert.That(root.Folder, Is.Empty); + } + + [Test] + public void HidesCountsWhenTheyAreUnknown() + { + Assert.That(File(additions: null, deletions: null).HasCounts, Is.False); + Assert.That(File(additions: 0, deletions: 0).HasCounts, Is.True); + } + + [Test] + public void FormatsTheCounts() + { + var row = File(additions: 12, deletions: 3); + + Assert.That(row.AdditionsText, Is.EqualTo("+12")); + Assert.That(row.DeletionsText, Is.EqualTo("−3")); + } + + [Test] + public void AHeaderRowHasNoFile() + { + var header = new GitChangeRow("Staged (1)", null); + + Assert.That(header.IsHeader, Is.True); + Assert.That(header.Text, Is.EqualTo("Staged (1)")); + Assert.That(header.StatusLetter, Is.Empty); + Assert.That(header.HasCounts, Is.False); + Assert.That(File().IsHeader, Is.False); + } +} diff --git a/tests/Kontext.Presentation.UnitTests/GitChangesViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/GitChangesViewModelTests.cs new file mode 100644 index 0000000..a29f378 --- /dev/null +++ b/tests/Kontext.Presentation.UnitTests/GitChangesViewModelTests.cs @@ -0,0 +1,465 @@ +using Kontext.Domain; +using Kontext.TestSupport; +using Microsoft.Extensions.Time.Testing; + +namespace Kontext.Presentation.UnitTests; + +[TestFixture] +public class GitChangesViewModelTests +{ + private const string Worktree = @"C:\wt\one"; + private static readonly TimeSpan Interval = TimeSpan.FromSeconds(3); + + private FakeWorkspaceChanges _fake = null!; + private FakeTimeProvider _clock = null!; + private GitChangesViewModel _vm = null!; + + [SetUp] + public void CreateViewModel() + { + _fake = new FakeWorkspaceChanges(); + _clock = new FakeTimeProvider(); + _vm = new GitChangesViewModel(_fake, _clock, new GitChangesOptions(Interval)); + } + + [TearDown] + public void DisposeViewModel() => _vm.Dispose(); + + private static FileChange Change(string path, bool staged = false, int? additions = 1, int? deletions = 0) + => new(path, null, FileChangeStatus.Modified, staged, additions, deletions, IsBinary: false); + + private static WorkingTreeChanges Tree(FileChange[] staged, FileChange[] unstaged) => new(staged, unstaged); + + private static WorkingTreeChanges Unstaged(params FileChange[] changes) => new([], changes); + + private void Activate(string path = Worktree) + { + _vm.WorktreePath = path; + _vm.IsActive = true; + } + + private static Task WaitFor(Func condition, string because) => AsyncAssert.EventuallyAsync(condition, because); + + private Task WaitForRequests(int count) => WaitFor(() => _fake.ChangeRequests.Count >= count, $"{count} request(s) for the changes"); + + private async Task WaitForLoaded() + { + await WaitFor(() => _vm.HasLoaded, "the first load to finish"); + await WaitFor(() => _vm.SelectedChange is null || _vm.SelectedDiff is not null, "the first diff to load"); + } + + private static async Task Settle() => await Task.Delay(100); + + [Test] + public async Task DoesNothingWhileInactive() + { + _vm.WorktreePath = Worktree; + _clock.Advance(Interval * 3); + await Settle(); + + Assert.That(_fake.ChangeRequests, Is.Empty); + } + + [Test] + public async Task AsksForChangesAndDiffsOffTheCallingThread() + { + var caller = Environment.CurrentManagedThreadId; + _fake.Changes = Unstaged(Change("a.cs")); + + Activate(); + await WaitForLoaded(); + + Assert.That(_fake.ThreadIds, Has.Count.GreaterThanOrEqualTo(2), "one call for the changes and one for the diff"); + Assert.That(_fake.ThreadIds, Has.None.EqualTo(caller), "git and file work must not run on the thread that owns the UI"); + } + + [Test] + public async Task DoesNothingWithoutAWorktree() + { + _vm.IsActive = true; + _clock.Advance(Interval * 3); + await Settle(); + + Assert.That(_fake.ChangeRequests, Is.Empty); + } + + [Test] + public async Task LoadsImmediatelyWhenActivatedWithAWorktree() + { + _fake.Changes = Unstaged(Change("a.cs")); + + Activate(); + await WaitForLoaded(); + + Assert.That(_fake.ChangeRequests, Is.EqualTo(Expect.Seq(Worktree))); + Assert.That(_vm.Rows, Has.Count.EqualTo(2), "a header and the file"); + } + + [Test] + public async Task PollsOnEveryIntervalWhileActive() + { + Activate(); + await WaitForRequests(1); + + _clock.Advance(Interval); + await WaitForRequests(2); + + _clock.Advance(Interval); + await WaitForRequests(3); + } + + [Test] + public async Task StopsPollingWhenDeactivated() + { + Activate(); + await WaitForRequests(1); + + _vm.IsActive = false; + _clock.Advance(Interval); + _clock.Advance(Interval); + await Settle(); + + Assert.That(_fake.ChangeRequests, Has.Count.EqualTo(1)); + } + + [Test] + public async Task LoadsAgainWhenReactivated() + { + Activate(); + await WaitForRequests(1); + _vm.IsActive = false; + + _vm.IsActive = true; + + await WaitForRequests(2); + } + + [Test] + public async Task SettingTheSameValuesAgainDoesNothing() + { + Activate(); + await WaitForRequests(1); + + _vm.WorktreePath = Worktree; + _vm.IsActive = true; + _clock.Advance(Interval); + await WaitForRequests(2); + await Settle(); + + Assert.That(_fake.ChangeRequests, Has.Count.EqualTo(2), "one immediate load and one poll, not a second loop"); + } + + [Test] + public async Task ResetsAndReloadsWhenTheWorktreeChanges() + { + _fake.Changes = Unstaged(Change("old.cs")); + Activate(); + await WaitForLoaded(); + + _fake.Changes = Unstaged(Change("new.cs")); + _vm.WorktreePath = @"C:\wt\two"; + + await WaitFor(() => _vm.SelectedChange?.Path == "new.cs", "the other worktree's files"); + Assert.That(_fake.ChangeRequests, Is.EqualTo(Expect.Seq(Worktree, @"C:\wt\two"))); + Assert.That(_vm.Rows.Select(row => row.Text), Does.Not.Contain("old.cs")); + } + + [Test] + public async Task StopsAndClearsWhenTheWorktreeIsRemoved() + { + _fake.Changes = Unstaged(Change("a.cs")); + Activate(); + await WaitForLoaded(); + + _vm.WorktreePath = null; + _clock.Advance(Interval); + await Settle(); + + Assert.That(_vm.Rows, Is.Empty); + Assert.That(_vm.HasLoaded, Is.False); + Assert.That(_vm.SelectedChange, Is.Null); + Assert.That(_vm.SelectedDiff, Is.Null); + Assert.That(_fake.ChangeRequests, Has.Count.EqualTo(1)); + } + + [Test] + public async Task GroupsFilesUnderStagedAndChangesHeaders() + { + _fake.Changes = Tree([Change("x.cs", staged: true)], [Change("a.cs"), Change("b.cs"), Change("c.cs")]); + + Activate(); + await WaitForLoaded(); + + Assert.That(_vm.Rows.Select(row => row.Text), Is.EqualTo(Expect.Seq("Staged (1)", "x.cs", "Changes (3)", "a.cs", "b.cs", "c.cs"))); + Assert.That(_vm.Rows.Select(row => row.IsHeader), Is.EqualTo(Expect.Seq(true, false, true, false, false, false))); + } + + [Test] + public async Task OmitsAHeaderWhoseGroupIsEmpty() + { + _fake.Changes = Unstaged(Change("a.cs")); + + Activate(); + await WaitForLoaded(); + + Assert.That(_vm.Rows.Select(row => row.Text), Is.EqualTo(Expect.Seq("Changes (1)", "a.cs"))); + } + + [Test] + public async Task SummarisesTheTotalsCountingAPartiallyStagedFileOnce() + { + _fake.Changes = Tree([Change("x.cs", staged: true, additions: 10, deletions: 2)], [Change("x.cs", additions: 1, deletions: 0), Change("y.cs", additions: 20, deletions: 7)]); + + Activate(); + await WaitForLoaded(); + + Assert.That(_vm.Summary, Is.EqualTo("2 files changed +31 \u22129")); + } + + [Test] + public async Task SummarisesASingleFileInTheSingular() + { + _fake.Changes = Unstaged(Change("a.cs", additions: null, deletions: null)); + + Activate(); + await WaitForLoaded(); + + Assert.That(_vm.Summary, Is.EqualTo("1 file changed +0 \u22120")); + } + + [Test] + public async Task SelectsTheFirstFileAndLoadsItsDiff() + { + var staged = Change("s.cs", staged: true); + var diff = new FileDiff("s.cs", [new DiffLine(DiffLineKind.Added, null, 1, "x")]); + _fake.Changes = Tree([staged], [Change("u.cs")]); + _fake.Diffs[("s.cs", true)] = diff; + + Activate(); + await WaitForLoaded(); + + Assert.That(_vm.SelectedChange, Is.EqualTo(staged)); + Assert.That(_vm.SelectedDiff, Is.EqualTo(diff)); + Assert.That(_vm.NeedsSelection, Is.False); + } + + [Test] + public async Task TellsWhetherAFileIsSelectedAndAnnouncesTheChange() + { + Assert.That(_vm.HasSelectedChange, Is.False); + var changed = new List(); + _vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + _fake.Changes = Unstaged(Change("a.cs")); + + Activate(); + await WaitForLoaded(); + + Assert.That(_vm.HasSelectedChange, Is.True); + Assert.That(changed, Does.Contain(nameof(GitChangesViewModel.HasSelectedChange))); + + _vm.WorktreePath = null; + Assert.That(_vm.HasSelectedChange, Is.False); + } + + [Test] + public async Task LoadsTheDiffWhenAnotherFileIsSelected() + { + var other = Change("u.cs"); + var diff = new FileDiff("u.cs", [new DiffLine(DiffLineKind.Added, null, 1, "y")]); + _fake.Changes = Unstaged(Change("a.cs"), other); + _fake.Diffs[("u.cs", false)] = diff; + Activate(); + await WaitForLoaded(); + + _vm.SelectedChange = other; + + await WaitFor(() => _vm.SelectedDiff == diff, "the diff of the newly selected file"); + } + + [Test] + public async Task KeepsTheSelectionAcrossPollsEvenWhenItsCountsChange() + { + _fake.Changes = Unstaged(Change("a.cs"), Change("b.cs", additions: 1)); + Activate(); + await WaitForLoaded(); + _vm.SelectedChange = _vm.Rows[^1].Change; + + _fake.Changes = Unstaged(Change("a.cs"), Change("b.cs", additions: 5)); + _clock.Advance(Interval); + + await WaitFor(() => _vm.SelectedChange?.Additions == 5, "the selected file to pick up its new count"); + Assert.That(_vm.SelectedChange?.Path, Is.EqualTo("b.cs")); + } + + [Test] + public async Task SelectsTheFirstFileWhenTheSelectedOneDisappears() + { + _fake.Changes = Unstaged(Change("a.cs"), Change("b.cs")); + Activate(); + await WaitForLoaded(); + _vm.SelectedChange = _vm.Rows[^1].Change; + + _fake.Changes = Unstaged(Change("a.cs")); + _clock.Advance(Interval); + + await WaitFor(() => _vm.SelectedChange?.Path == "a.cs", "the first file to be selected"); + } + + [Test] + public async Task DoesNotRaiseRowChangesWhenNothingChanged() + { + _fake.Changes = Unstaged(Change("a.cs")); + Activate(); + await WaitForLoaded(); + var changed = new List(); + _vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + _clock.Advance(Interval); + await WaitForRequests(2); + await Settle(); + + Assert.That(changed, Does.Not.Contain(nameof(GitChangesViewModel.Rows))); + Assert.That(changed, Does.Not.Contain(nameof(GitChangesViewModel.Summary))); + Assert.That(changed, Does.Not.Contain(nameof(GitChangesViewModel.SelectedDiff))); + } + + [Test] + public async Task RefreshesTheSelectedDiffWhenItsContentChanges() + { + var first = new FileDiff("a.cs", [new DiffLine(DiffLineKind.Added, null, 1, "one")]); + var second = new FileDiff("a.cs", [new DiffLine(DiffLineKind.Added, null, 1, "one"), new DiffLine(DiffLineKind.Added, null, 2, "two")]); + _fake.Changes = Unstaged(Change("a.cs")); + _fake.Diffs[("a.cs", false)] = first; + Activate(); + await WaitFor(() => _vm.SelectedDiff == first, "the first diff"); + + _fake.Diffs[("a.cs", false)] = second; + _clock.Advance(Interval); + + await WaitFor(() => _vm.SelectedDiff == second, "the updated diff"); + } + + [Test] + public async Task ShowsTheGitErrorAndKeepsTheLastData() + { + _fake.Changes = Unstaged(Change("a.cs")); + Activate(); + await WaitForLoaded(); + + _fake.FailWith = new InvalidOperationException("git exploded"); + _clock.Advance(Interval); + + await WaitFor(() => _vm.HasError, "the error to show"); + Assert.That(_vm.ErrorMessage, Is.EqualTo("git exploded")); + Assert.That(_vm.Rows, Has.Count.EqualTo(2), "the last good list stays"); + Assert.That(_vm.HasLoaded, Is.True); + Assert.That(_vm.IsEmpty, Is.False); + } + + [Test] + public async Task ClearsTheErrorOnRecovery() + { + _fake.Changes = Unstaged(Change("a.cs")); + Activate(); + await WaitForLoaded(); + _fake.FailWith = new InvalidOperationException("git exploded"); + _clock.Advance(Interval); + await WaitFor(() => _vm.HasError, "the error to show"); + + _fake.FailWith = null; + _clock.Advance(Interval); + + await WaitFor(() => !_vm.HasError, "the error to clear"); + Assert.That(_vm.ErrorMessage, Is.Null); + } + + [Test] + public async Task ShowsTheErrorEvenWhenTheFirstLoadFails() + { + _fake.FailWith = new InvalidOperationException("not a repository"); + + Activate(); + + await WaitFor(() => _vm.HasError, "the error to show"); + Assert.That(_vm.HasLoaded, Is.False); + Assert.That(_vm.IsEmpty, Is.False); + } + + [Test] + public async Task ShowsTheEmptyStateForACleanTree() + { + Activate(); + + await WaitFor(() => _vm.IsEmpty, "the empty state"); + Assert.That(_vm.HasChanges, Is.False); + Assert.That(_vm.NeedsSelection, Is.False); + Assert.That(_vm.Summary, Is.Empty); + } + + [Test] + public void ShowsTheUnifiedDiffUntilAskedForTheSplitView() + { + Assert.That(_vm.IsSplitView, Is.False); + Assert.That(_vm.SplitRows, Is.Empty); + } + + [Test] + public void TellsWhenTheSplitViewIsSwitchedOnOrOff() + { + var changed = new List(); + _vm.PropertyChanged += (_, args) => changed.Add(args.PropertyName); + + _vm.IsSplitView = true; + + Assert.That(changed, Does.Contain(nameof(GitChangesViewModel.IsSplitView))); + } + + [Test] + public async Task OffersTheSelectedDiffAsSideBySideRows() + { + var diff = new FileDiff("u.cs", [new DiffLine(DiffLineKind.Removed, 1, null, "old"), new DiffLine(DiffLineKind.Added, null, 1, "new")]); + _fake.Changes = Unstaged(Change("u.cs")); + _fake.Diffs[("u.cs", false)] = diff; + + Activate(); + await WaitForLoaded(); + + Assert.That(_vm.SplitRows, Has.Count.EqualTo(1)); + Assert.That(_vm.SplitRows[0].Left?.Text, Is.EqualTo("old")); + Assert.That(_vm.SplitRows[0].Right?.Text, Is.EqualTo("new")); + } + + [Test] + public async Task ClearsTheSideBySideRowsWhenTheSelectionIsLost() + { + _fake.Changes = Unstaged(Change("u.cs")); + _fake.Diffs[("u.cs", false)] = new FileDiff("u.cs", [new DiffLine(DiffLineKind.Added, null, 1, "new")]); + Activate(); + await WaitForLoaded(); + + _vm.WorktreePath = @"C:\wt\two"; + + Assert.That(_vm.SplitRows, Is.Empty); + } + + [Test] + public async Task IgnoresAStaleResultFromThePreviousWorktree() + { + _fake.Changes = Unstaged(Change("stale.cs")); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _fake.Gate = gate; + Activate(); + await WaitForRequests(1); + + _fake.Gate = null; + _fake.Changes = Unstaged(Change("fresh.cs")); + _vm.WorktreePath = @"C:\wt\two"; + await WaitFor(() => _vm.SelectedChange?.Path == "fresh.cs", "the new worktree's files"); + + gate.SetResult(true); + await Settle(); + + Assert.That(_vm.Rows.Select(row => row.Text), Does.Not.Contain("stale.cs")); + Assert.That(_vm.SelectedChange?.Path, Is.EqualTo("fresh.cs")); + } +} diff --git a/tests/Kontext.Presentation.UnitTests/IssueDetailViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/IssueDetailViewModelTests.cs index 3abe9dd..0256839 100644 --- a/tests/Kontext.Presentation.UnitTests/IssueDetailViewModelTests.cs +++ b/tests/Kontext.Presentation.UnitTests/IssueDetailViewModelTests.cs @@ -18,6 +18,7 @@ public class IssueDetailViewModelTests private InMemoryIssueWorkTracker _tracker = null!; private FakeTimeProvider _clock = null!; private SelectionAutoStart _autoStart = null!; + private FakeWorkspaceChanges _workspaceChanges = null!; private IssueDetailViewModel _detail = null!; [SetUp] @@ -31,7 +32,9 @@ public void CreateDetail() _tracker = new InMemoryIssueWorkTracker(); _clock = new FakeTimeProvider(); _autoStart = new SelectionAutoStart(_coordinator, _tracker, new InMemoryIssueSessionRegistry(), _clock, new AutoStartOptions(TimeSpan.FromSeconds(1))); - _detail = new IssueDetailViewModel(_coordinator, _clearer, _confirmation, _opener, _registry, _autoStart); + _workspaceChanges = new FakeWorkspaceChanges(); + var gitChanges = new GitChangesViewModel(_workspaceChanges, _clock, new GitChangesOptions(TimeSpan.FromSeconds(3))); + _detail = new IssueDetailViewModel(_coordinator, _clearer, _confirmation, _opener, _registry, _autoStart, gitChanges); } [TearDown] @@ -161,18 +164,39 @@ public void ShowsTheSessionOfWhicheverIssueIsSelected() } [Test] - public void ShowsTheTerminalTabByDefaultAndHidesTheTerminalWhileTheIssueTabIsShown() + public void StartsOnTheTerminalTab() { - var item = Item(); + Assert.That(_detail.SelectedTab, Is.EqualTo(DetailTab.Terminal)); + Assert.That(_detail.ShowTerminalTab, Is.True); + } + + [TestCase(DetailTab.Terminal)] + [TestCase(DetailTab.Git)] + [TestCase(DetailTab.Issue)] + public void OnlyTheSelectedTabIsShown(DetailTab tab) + { + _detail.Item = Item(path: @"C:\wt"); + + _detail.SelectedTab = tab; + + Assert.That(_detail.ShowTerminalTab, Is.EqualTo(tab == DetailTab.Terminal)); + Assert.That(_detail.ShowGitTab, Is.EqualTo(tab == DetailTab.Git)); + Assert.That(_detail.ShowIssueTab, Is.EqualTo(tab == DetailTab.Issue)); + } + + [TestCase(DetailTab.Issue)] + [TestCase(DetailTab.Git)] + public void HidesTheTerminalWhileAnotherTabIsShown(DetailTab other) + { + var item = Item(path: @"C:\wt"); _detail.Item = item; var session = new FakeTerminalSession(TerminalSessionId.New()); _registry.Register(item.Key, session); Assert.That(_detail.ShowTerminalTab, Is.True); - Assert.That(_detail.ShowIssueTab, Is.False); Assert.That(_detail.VisibleSession, Is.SameAs(session)); - _detail.ShowIssueTab = true; + _detail.SelectedTab = other; Assert.That(_detail.ShowTerminalTab, Is.False); Assert.That(_detail.VisibleSession, Is.Null, "the native terminal ignores an ancestor being collapsed, so it must be told to hide"); @@ -180,15 +204,70 @@ public void ShowsTheTerminalTabByDefaultAndHidesTheTerminalWhileTheIssueTabIsSho } [Test] - public void AnnouncesTheTerminalVisibilityChangeWhenTheTabChanges() + public void AnnouncesTheTabVisibilityChangesWhenTheTabChanges() { var changed = new List(); _detail.PropertyChanged += (_, e) => changed.Add(e.PropertyName); - _detail.ShowIssueTab = true; + _detail.SelectedTab = DetailTab.Git; Assert.That(changed, Does.Contain(nameof(IssueDetailViewModel.VisibleSession))); Assert.That(changed, Does.Contain(nameof(IssueDetailViewModel.ShowTerminalTab))); + Assert.That(changed, Does.Contain(nameof(IssueDetailViewModel.ShowGitTab))); + Assert.That(changed, Does.Contain(nameof(IssueDetailViewModel.ShowIssueTab))); + } + + [Test] + public void ActivatesGitChangesWithTheWorktreeOnlyOnTheGitTab() + { + _detail.Item = Item(path: @"C:\wt"); + + Assert.That(_detail.GitChanges.WorktreePath, Is.EqualTo(@"C:\wt")); + Assert.That(_detail.GitChanges.IsActive, Is.False, "nothing is polled while the terminal is shown"); + + _detail.SelectedTab = DetailTab.Git; + Assert.That(_detail.GitChanges.IsActive, Is.True); + + _detail.SelectedTab = DetailTab.Terminal; + Assert.That(_detail.GitChanges.IsActive, Is.False); + + _detail.SelectedTab = DetailTab.Issue; + Assert.That(_detail.GitChanges.IsActive, Is.False); + } + + [Test] + public void FallsBackToTheTerminalWhenTheSelectedIssueHasNoWorktree() + { + _detail.Item = Item(path: @"C:\wt"); + _detail.SelectedTab = DetailTab.Git; + + _detail.Item = Item(); + + Assert.That(_detail.SelectedTab, Is.EqualTo(DetailTab.Terminal)); + Assert.That(_detail.GitChanges.IsActive, Is.False); + } + + [Test] + public void GivesGitChangesNoWorktreeWhenTheSelectedIssueHasNone() + { + _detail.Item = Item(path: @"C:\wt"); + + _detail.Item = Item(); + + Assert.That(_detail.GitChanges.WorktreePath, Is.Null); + } + + [Test] + public void FollowsTheWorktreeOfTheNewlySelectedIssueWhileOnTheGitTab() + { + _detail.Item = Item(path: @"C:\wt\one"); + _detail.SelectedTab = DetailTab.Git; + + _detail.Item = Item(path: @"C:\wt\two"); + + Assert.That(_detail.SelectedTab, Is.EqualTo(DetailTab.Git)); + Assert.That(_detail.GitChanges.WorktreePath, Is.EqualTo(@"C:\wt\two")); + Assert.That(_detail.GitChanges.IsActive, Is.True); } [Test] diff --git a/tests/Kontext.Presentation.UnitTests/IssueItemViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/IssueItemViewModelTests.cs index 636b3fd..646ef8b 100644 --- a/tests/Kontext.Presentation.UnitTests/IssueItemViewModelTests.cs +++ b/tests/Kontext.Presentation.UnitTests/IssueItemViewModelTests.cs @@ -52,4 +52,54 @@ public void FollowsTheStateWhenItChanges() Assert.That(item.Status, Is.EqualTo(IssueWorkStatus.Running)); Assert.That(changed, Does.Contain(nameof(IssueItemViewModel.Status))); } + + [Test] + public void ShowsAnOpenReadyIssueWithASavedSessionAsNeedingInput() + { + var item = Item(IssueWorkStatus.Ready, closed: false); + + item.HasSavedSession = true; + + Assert.That(item.Status, Is.EqualTo(IssueWorkStatus.NeedsInput)); + Assert.That(item.StatusText, Is.EqualTo("Needs input")); + } + + [Test] + public void ShowsAReadyIssueWithoutASavedSessionAsReady() + { + var item = Item(IssueWorkStatus.Ready, closed: false); + + Assert.That(item.Status, Is.EqualTo(IssueWorkStatus.Ready)); + } + + [Test] + public void KeepsTheStateOfAnIssueWhoseSavedSessionHasEnded() + { + var issue = Issues.Create(4, "Add notifications", "acme/memento"); + var item = new IssueItemViewModel(issue, new IssueWorkState(IssueWorkStatus.Ready, "b", @"C:\wt", SessionEnded: true), Now) { HasSavedSession = true }; + + Assert.That(item.Status, Is.EqualTo(IssueWorkStatus.Ready)); + } + + [Test] + public void StillShowsAClosedIssueWithASavedSessionAsDone() + { + var item = Item(IssueWorkStatus.Ready, closed: true); + + item.HasSavedSession = true; + + Assert.That(item.Status, Is.EqualTo(IssueWorkStatus.Done)); + } + + [Test] + public void TellsWhenASavedSessionChangesWhatIsShown() + { + var item = Item(IssueWorkStatus.Ready, closed: false); + var changed = new List(); + item.PropertyChanged += (_, args) => changed.Add(args.PropertyName); + + item.HasSavedSession = true; + + Assert.That(changed, Does.Contain(nameof(IssueItemViewModel.Status)).And.Contain(nameof(IssueItemViewModel.StatusText))); + } } diff --git a/tests/Kontext.Presentation.UnitTests/IssueSidebarViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/IssueSidebarViewModelTests.cs index 8341344..950b4c8 100644 --- a/tests/Kontext.Presentation.UnitTests/IssueSidebarViewModelTests.cs +++ b/tests/Kontext.Presentation.UnitTests/IssueSidebarViewModelTests.cs @@ -13,13 +13,18 @@ public class IssueSidebarViewModelTests private InMemoryHiddenRepositoryStore _hiddenStore = null!; private IssueSidebarViewModel _sidebar = null!; - private IssueSidebarViewModel CreateSidebarWith(InMemoryHiddenRepositoryStore hiddenStore) => - new(_gitHub, _tracker, _finder, new WorkspaceIssues(_gitHub), hiddenStore, new FixedTimeProvider(DateTimeOffset.Parse("2025-03-01T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture))); + private InMemoryCollapsedRepositoryStore _collapsedStore = null!; + private FakeSessionStore _sessionStore = null!; + + private IssueSidebarViewModel CreateSidebarWith(InMemoryHiddenRepositoryStore hiddenStore, InMemoryCollapsedRepositoryStore? collapsedStore = null) => + new(_gitHub, _tracker, _finder, new WorkspaceIssues(_gitHub), hiddenStore, collapsedStore ?? _collapsedStore, _sessionStore, new FixedTimeProvider(DateTimeOffset.Parse("2025-03-01T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture))); [SetUp] public void CreateSidebar() { _hiddenStore = new InMemoryHiddenRepositoryStore(); + _collapsedStore = new InMemoryCollapsedRepositoryStore(); + _sessionStore = new FakeSessionStore(); _tracker = new InMemoryIssueWorkTracker(); _gitHub = new FakeGitHubService(); _finder = new FakeExistingWorkspaceFinder(); @@ -107,6 +112,69 @@ public async Task RemembersTheHiddenRepositoriesInTheStore() Assert.That(_hiddenStore.Hidden, Is.EquivalentTo(Expect.Seq("acme/gallery"))); } + [Test] + public async Task ShowsEveryRepositoryExpandedAtFirst() + { + await Refresh(); + + Assert.That(_sidebar.Groups.Select(group => group.IsExpanded), Has.All.True); + } + + [Test] + public async Task RemembersARepositoryThatWasCollapsed() + { + await Refresh(); + + _sidebar.Groups.Single(group => group.Name == "acme/memento").IsExpanded = false; + + Assert.That(_collapsedStore.Collapsed, Is.EquivalentTo(Expect.Seq("acme/memento"))); + } + + [Test] + public async Task ForgetsARepositoryThatWasExpandedAgain() + { + await Refresh(); + var group = _sidebar.Groups.Single(candidate => candidate.Name == "acme/memento"); + group.IsExpanded = false; + + group.IsExpanded = true; + + Assert.That(_collapsedStore.Collapsed, Is.Empty); + } + + [Test] + public async Task DoesNotSaveWhenTheStateDidNotChange() + { + await Refresh(); + + _sidebar.Groups.Single(group => group.Name == "acme/memento").IsExpanded = true; + + Assert.That(_collapsedStore.Saves, Is.Zero); + } + + [Test] + public async Task StartsWithTheRepositoriesTheStoreSaysAreCollapsed() + { + using var sidebar = CreateSidebarWith(_hiddenStore, new InMemoryCollapsedRepositoryStore("acme/memento")); + + await sidebar.RefreshCommand.ExecuteAsync(null); + + Assert.That(sidebar.Groups.Single(group => group.Name == "acme/memento").IsExpanded, Is.False); + Assert.That(sidebar.Groups.Single(group => group.Name == "acme/gallery").IsExpanded, Is.True); + } + + [Test] + public async Task KeepsARepositoryCollapsedWhileFilteringAndComingBack() + { + await Refresh(); + _sidebar.Groups.Single(group => group.Name == "acme/memento").IsExpanded = false; + + _sidebar.SearchText = "Galleries"; + _sidebar.SearchText = string.Empty; + + Assert.That(_sidebar.Groups.Single(group => group.Name == "acme/memento").IsExpanded, Is.False); + } + [Test] public async Task StartsWithTheRepositoriesTheStoreSaysAreHidden() { @@ -268,6 +336,36 @@ public async Task RemembersWhichGroupTheUserCollapsedAcrossSearches() Assert.That(_sidebar.Groups.Single(group => group.Name == "acme/memento").IsExpanded, Is.False); } + [Test] + public async Task ShowsAWorktreeWithASavedSessionAsNeedingInputButStillLetsItResume() + { + var key = new IssueKey("acme/memento", 4); + _finder.Found.Add(new ExistingWorkspace(new RepositoryRef("acme", "memento"), 4, new WorktreeInfo(@"C:\wt\4", "kontext/4-add-notifications", "abc", IsMain: false))); + _sessionStore.Save(new StoredSession(key, TerminalSessionId.New(), @"C:\wt\4", "kontext/4-add-notifications")); + + await Refresh(); + + var item = _sidebar.Groups.SelectMany(group => group.Items).Single(candidate => candidate.Issue.Number == 4); + Assert.That(item.Status, Is.EqualTo(IssueWorkStatus.NeedsInput)); + Assert.That(_tracker.StateOf(key).Status, Is.EqualTo(IssueWorkStatus.Ready), "the tracked state stays Ready so selecting the issue still resumes it"); + } + + [Test] + public async Task ShowsTheDotAsReadyAgainOnceTheSavedSessionIsCleared() + { + var key = new IssueKey("acme/memento", 4); + _finder.Found.Add(new ExistingWorkspace(new RepositoryRef("acme", "memento"), 4, new WorktreeInfo(@"C:\wt\4", "kontext/4-add-notifications", "abc", IsMain: false))); + _sessionStore.Save(new StoredSession(key, TerminalSessionId.New(), @"C:\wt\4", "kontext/4-add-notifications")); + await Refresh(); + + _sessionStore.Remove(key); + _tracker.Report(key, IssueWorkState.NotStarted); + _tracker.Report(key, new IssueWorkState(IssueWorkStatus.Ready, "kontext/4-add-notifications", @"C:\wt\4")); + + var item = _sidebar.Groups.SelectMany(group => group.Items).Single(candidate => candidate.Issue.Number == 4); + Assert.That(item.Status, Is.EqualTo(IssueWorkStatus.Ready)); + } + [Test] public async Task MarksWorktreesFromEarlierRunsAsReady() { diff --git a/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs b/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs index a358f85..f39f8d9 100644 --- a/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs +++ b/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs @@ -39,8 +39,8 @@ protected void Build(string? savedToken, string? savedEnterpriseToken = null) EnterprisePrompt = new FakeEnterprisePrompt(); DeviceLogin = new FakeGitHubDeviceLogin(); _autoStart = new SelectionAutoStart(coordinator, tracker, new InMemoryIssueSessionRegistry(), TimeProvider.System, AutoStartOptions.Default); - Sidebar = new IssueSidebarViewModel(GitHub, tracker, new FakeExistingWorkspaceFinder(), new WorkspaceIssues(GitHub), new InMemoryHiddenRepositoryStore(), TimeProvider.System); - _detail = new IssueDetailViewModel(coordinator, new FakeSessionClearer(), new FakeConfirmation(), new FakeWorkspaceOpener(), new InMemoryIssueSessionRegistry(), _autoStart); + Sidebar = new IssueSidebarViewModel(GitHub, tracker, new FakeExistingWorkspaceFinder(), new WorkspaceIssues(GitHub), new InMemoryHiddenRepositoryStore(), new InMemoryCollapsedRepositoryStore(), new FakeSessionStore(), TimeProvider.System); + _detail = new IssueDetailViewModel(coordinator, new FakeSessionClearer(), new FakeConfirmation(), new FakeWorkspaceOpener(), new InMemoryIssueSessionRegistry(), _autoStart, new GitChangesViewModel(new FakeWorkspaceChanges(), TimeProvider.System, GitChangesOptions.Default)); Shell = new ShellViewModel(GitHub, DeviceLogin, Tokens, EnterprisePrompt, new WorkspaceViewModel(Sidebar, _detail, _autoStart)); } } diff --git a/tests/Kontext.Presentation.UnitTests/ShellViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/ShellViewModelTests.cs index 794f92e..21fee88 100644 --- a/tests/Kontext.Presentation.UnitTests/ShellViewModelTests.cs +++ b/tests/Kontext.Presentation.UnitTests/ShellViewModelTests.cs @@ -14,6 +14,46 @@ public void StartsInTheStartingStateSoTheSignInFormNeverFlashes() Assert.That(Shell.IsSignedIn, Is.False); } + [Test] + public void ShowsTheWorkspaceLayoutWhileStartingSoNothingJumpsWhenSigningInIsDone() + { + Assert.That(Shell.IsStarting, Is.True); + Assert.That(Shell.ShowWorkspace, Is.True); + } + + [Test] + public async Task KeepsTheWorkspaceLayoutOnceASavedTokenSignsIn() + { + Build(savedToken: "ghp_saved"); + var hidden = false; + Shell.PropertyChanged += (_, _) => hidden |= !Shell.ShowWorkspace; + + await Shell.InitializeCommand.ExecuteAsync(null); + + Assert.That(hidden, Is.False, "the layout must never disappear between starting and signed in"); + Assert.That(Shell.ShowWorkspace, Is.True); + } + + [Test] + public async Task ReplacesTheWorkspaceLayoutWithTheSignInFormWhenThereIsNoAccount() + { + await Shell.InitializeCommand.ExecuteAsync(null); + + Assert.That(Shell.ShowWorkspace, Is.False); + Assert.That(Shell.ShowSignIn, Is.True); + } + + [Test] + public void TellsWhenTheWorkspaceLayoutStopsBeingShown() + { + var changed = new List(); + Shell.PropertyChanged += (_, args) => changed.Add(args.PropertyName); + + Shell.IsStarting = false; + + Assert.That(changed, Does.Contain(nameof(ShellViewModel.ShowWorkspace))); + } + [Test] public async Task ShowsTheSignInFormOnceItIsKnownThereIsNoSavedToken() { diff --git a/tests/Kontext.Presentation.UnitTests/SplitDiffRowsTests.cs b/tests/Kontext.Presentation.UnitTests/SplitDiffRowsTests.cs new file mode 100644 index 0000000..f47faf1 --- /dev/null +++ b/tests/Kontext.Presentation.UnitTests/SplitDiffRowsTests.cs @@ -0,0 +1,112 @@ +using Kontext.Domain; + +namespace Kontext.Presentation.UnitTests; + +[TestFixture] +public class SplitDiffRowsTests +{ + private static DiffLine Context(int oldNumber, int newNumber, string text) => new(DiffLineKind.Context, oldNumber, newNumber, text); + + private static DiffLine Removed(int oldNumber, string text) => new(DiffLineKind.Removed, oldNumber, null, text); + + private static DiffLine Added(int newNumber, string text) => new(DiffLineKind.Added, null, newNumber, text); + + private static DiffLine Hunk(string text) => new(DiffLineKind.HunkHeader, null, null, text); + + [Test] + public void NoLinesGiveNoRows() => Assert.That(SplitDiffRows.Build([]), Is.Empty); + + [Test] + public void AContextLineShowsOnBothSidesWithItsOwnNumbers() + { + var rows = SplitDiffRows.Build([Context(4, 6, "same")]); + + Assert.That(rows, Has.Count.EqualTo(1)); + Assert.That(rows[0].Left, Is.EqualTo(new SplitCell(DiffLineKind.Context, 4, "same"))); + Assert.That(rows[0].Right, Is.EqualTo(new SplitCell(DiffLineKind.Context, 6, "same"))); + Assert.That(rows[0].Spanning, Is.Null); + } + + [Test] + public void ARemovedLineFollowedByAnAddedLineSitsOnTheSameRow() + { + var rows = SplitDiffRows.Build([Removed(3, "old"), Added(3, "new")]); + + Assert.That(rows, Has.Count.EqualTo(1)); + Assert.That(rows[0].Left, Is.EqualTo(new SplitCell(DiffLineKind.Removed, 3, "old"))); + Assert.That(rows[0].Right, Is.EqualTo(new SplitCell(DiffLineKind.Added, 3, "new"))); + } + + [Test] + public void ARunOfRemovedLinesIsPairedWithTheFollowingAddedLinesInOrder() + { + var rows = SplitDiffRows.Build([Removed(1, "a"), Removed(2, "b"), Added(1, "x"), Added(2, "y")]); + + Assert.That(rows.Select(row => (row.Left?.Text, row.Right?.Text)), Is.EqualTo(new[] { ("a", "x"), ("b", "y") })); + } + + [Test] + public void ExtraRemovedLinesHaveNothingOnTheRight() + { + var rows = SplitDiffRows.Build([Removed(1, "a"), Removed(2, "b"), Added(1, "x")]); + + Assert.That(rows.Select(row => (row.Left?.Text, row.Right?.Text)), Is.EqualTo(new (string?, string?)[] { ("a", "x"), ("b", null) })); + } + + [Test] + public void ExtraAddedLinesHaveNothingOnTheLeft() + { + var rows = SplitDiffRows.Build([Removed(1, "a"), Added(1, "x"), Added(2, "y")]); + + Assert.That(rows.Select(row => (row.Left?.Text, row.Right?.Text)), Is.EqualTo(new (string?, string?)[] { ("a", "x"), (null, "y") })); + } + + [Test] + public void AddedLinesWithoutRemovedLinesStayOnTheRight() + { + var rows = SplitDiffRows.Build([Added(1, "x")]); + + Assert.That(rows[0].Left, Is.Null); + Assert.That(rows[0].Right, Is.EqualTo(new SplitCell(DiffLineKind.Added, 1, "x"))); + } + + [Test] + public void RemovedLinesWithoutAddedLinesStayOnTheLeft() + { + var rows = SplitDiffRows.Build([Removed(1, "a")]); + + Assert.That(rows[0].Left, Is.EqualTo(new SplitCell(DiffLineKind.Removed, 1, "a"))); + Assert.That(rows[0].Right, Is.Null); + } + + [Test] + public void ChangesOnEitherSideOfAContextLineAreNotPairedAcrossIt() + { + var rows = SplitDiffRows.Build([Removed(1, "a"), Context(2, 1, "mid"), Added(2, "z")]); + + Assert.That(rows.Select(row => (row.Left?.Text, row.Right?.Text)), Is.EqualTo(new (string?, string?)[] { ("a", null), ("mid", "mid"), (null, "z") })); + } + + [Test] + public void AHunkHeaderSpansBothSidesAndEndsAPairingRun() + { + var header = Hunk("@@ -1 +1 @@"); + var rows = SplitDiffRows.Build([Removed(1, "a"), header, Added(1, "x")]); + + Assert.That(rows, Has.Count.EqualTo(3)); + Assert.That(rows[1].Spanning, Is.EqualTo(header)); + Assert.That(rows[1].Left, Is.Null); + Assert.That(rows[1].Right, Is.Null); + Assert.That(rows[2].Left, Is.Null, "the added line after the header has nothing to pair with"); + } + + [Test] + public void ANoteSpansBothSides() + { + var note = new DiffLine(DiffLineKind.Note, null, null, @"\ No newline at end of file"); + + var rows = SplitDiffRows.Build([Added(1, "x"), note]); + + Assert.That(rows[1].Spanning, Is.EqualTo(note)); + } +} diff --git a/tests/Kontext.Presentation.UnitTests/WorkingTreeChangesTests.cs b/tests/Kontext.Presentation.UnitTests/WorkingTreeChangesTests.cs new file mode 100644 index 0000000..3fa9b43 --- /dev/null +++ b/tests/Kontext.Presentation.UnitTests/WorkingTreeChangesTests.cs @@ -0,0 +1,51 @@ +using Kontext.Domain; + +namespace Kontext.Presentation.UnitTests; + +[TestFixture] +public class WorkingTreeChangesTests +{ + private static FileChange Change(string path = "src/Foo.cs", int additions = 3, bool staged = false) + => new(path, null, FileChangeStatus.Modified, staged, additions, 1, IsBinary: false); + + private static WorkingTreeChanges Tree(params FileChange[] unstaged) + => new([Change("README.md", staged: true)], unstaged); + + [Test] + public void TwoResultsWithTheSameFilesAreEqual() + { + var first = Tree(Change("a.cs"), Change("b.cs")); + var second = Tree(Change("a.cs"), Change("b.cs")); + + Assert.That(first, Is.EqualTo(second)); + Assert.That(first.GetHashCode(), Is.EqualTo(second.GetHashCode())); + } + + [Test] + public void AResultWithADifferentCountIsNotEqual() + { + var first = Tree(Change("a.cs", additions: 3)); + var second = Tree(Change("a.cs", additions: 4)); + + Assert.That(first, Is.Not.EqualTo(second)); + } + + [Test] + public void EmptyHasNoFiles() + { + Assert.That(WorkingTreeChanges.Empty.Staged, Is.Empty); + Assert.That(WorkingTreeChanges.Empty.Unstaged, Is.Empty); + } + + [Test] + public void TwoDiffsWithTheSameLinesAreEqual() + { + var first = new FileDiff("a.cs", [new DiffLine(DiffLineKind.Added, null, 1, "x")]); + var second = new FileDiff("a.cs", [new DiffLine(DiffLineKind.Added, null, 1, "x")]); + var other = new FileDiff("a.cs", [new DiffLine(DiffLineKind.Added, null, 1, "y")]); + + Assert.That(first, Is.EqualTo(second)); + Assert.That(first.GetHashCode(), Is.EqualTo(second.GetHashCode())); + Assert.That(first, Is.Not.EqualTo(other)); + } +} diff --git a/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs index 79bd266..5d196b9 100644 --- a/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs +++ b/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs @@ -29,8 +29,8 @@ public async Task CreateWorkspace() Issues = [Issues.Create(4, "Add notifications", "acme/memento"), Issues.Create(3, "Bring project under UI test", "acme/memento")], }; - _sidebar = new IssueSidebarViewModel(gitHub, _tracker, new FakeExistingWorkspaceFinder(), new WorkspaceIssues(gitHub), new InMemoryHiddenRepositoryStore(), _clock); - _detail = new IssueDetailViewModel(_coordinator, new FakeSessionClearer(), new FakeConfirmation(), new FakeWorkspaceOpener(), new InMemoryIssueSessionRegistry(), _autoStart); + _sidebar = new IssueSidebarViewModel(gitHub, _tracker, new FakeExistingWorkspaceFinder(), new WorkspaceIssues(gitHub), new InMemoryHiddenRepositoryStore(), new InMemoryCollapsedRepositoryStore(), new FakeSessionStore(), _clock); + _detail = new IssueDetailViewModel(_coordinator, new FakeSessionClearer(), new FakeConfirmation(), new FakeWorkspaceOpener(), new InMemoryIssueSessionRegistry(), _autoStart, new GitChangesViewModel(new FakeWorkspaceChanges(), _clock, GitChangesOptions.Default)); _workspace = new WorkspaceViewModel(_sidebar, _detail, _autoStart); await _sidebar.RefreshCommand.ExecuteAsync(null); diff --git a/tests/Kontext.Sessions.IntegrationTests/JsonCollapsedRepositoryStoreTests.cs b/tests/Kontext.Sessions.IntegrationTests/JsonCollapsedRepositoryStoreTests.cs new file mode 100644 index 0000000..488e214 --- /dev/null +++ b/tests/Kontext.Sessions.IntegrationTests/JsonCollapsedRepositoryStoreTests.cs @@ -0,0 +1,58 @@ +using Kontext.TestSupport; + +namespace Kontext.Sessions.IntegrationTests; + +[TestFixture] +public class JsonCollapsedRepositoryStoreTests +{ + private string _root = null!; + + [SetUp] + public void CreateScratchDirectory() + { + _root = Path.Combine(Path.GetTempPath(), "kontext-tests", Guid.NewGuid().ToString("N")[..12]); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void DeleteScratchDirectory() => Directory.Delete(_root, recursive: true); + + private JsonCollapsedRepositoryStore CreateStore() => new(new SessionsOptions(_root)); + + [Test] + public void CollapsesNothingBeforeAnythingIsSaved() => Assert.That(CreateStore().Load(), Is.Empty); + + [Test] + public void RoundTripsTheCollapsedRepositoriesIgnoringCase() + { + CreateStore().Save(new HashSet { "Acme/Memento", "acme/gallery" }); + + var loaded = CreateStore().Load(); + + Assert.That(loaded, Is.EquivalentTo(Expect.Seq("acme/gallery", "Acme/Memento"))); + Assert.That(loaded.Contains("acme/memento"), Is.True); + } + + [Test] + public void KeepsItsOwnFileApartFromTheHiddenRepositories() + { + new JsonHiddenRepositoryStore(new SessionsOptions(_root)).Save(new HashSet { "acme/hidden" }); + + CreateStore().Save(new HashSet { "acme/collapsed" }); + + Assert.That(new JsonHiddenRepositoryStore(new SessionsOptions(_root)).Load(), Is.EquivalentTo(Expect.Seq("acme/hidden"))); + Assert.That(CreateStore().Load(), Is.EquivalentTo(Expect.Seq("acme/collapsed"))); + } + + [Test] + public void TreatsACorruptFileAsEmptyAndRecoversOnNextSave() + { + File.WriteAllText(Path.Combine(_root, "collapsed-repositories.json"), "[ not json"); + var store = CreateStore(); + + Assert.That(store.Load(), Is.Empty); + + store.Save(new HashSet { "acme/memento" }); + Assert.That(store.Load(), Is.EquivalentTo(Expect.Seq("acme/memento"))); + } +} diff --git a/tests/Kontext.TerminalHost.UnitTests/TerminalShortcutReaderTests.cs b/tests/Kontext.TerminalHost.UnitTests/TerminalShortcutReaderTests.cs index 9b8375c..6f15823 100644 --- a/tests/Kontext.TerminalHost.UnitTests/TerminalShortcutReaderTests.cs +++ b/tests/Kontext.TerminalHost.UnitTests/TerminalShortcutReaderTests.cs @@ -6,6 +6,7 @@ public class TerminalShortcutReaderTests private const int VkC = 0x43; private const int VkV = 0x56; private const int VkInsert = 0x2D; + private const int VkReturn = 0x0D; [TestCase(VkV, true, false, TerminalShortcut.Paste, Description = "Ctrl+V")] [TestCase(VkV, true, true, TerminalShortcut.Paste, Description = "Ctrl+Shift+V")] @@ -61,4 +62,26 @@ public void OtherKeysAreLeftAlone() Assert.That(TerminalShortcutReader.FromKey('A', control: true, shift: true, alt: false), Is.Null); Assert.That(TerminalShortcutReader.FromKey(0x0D, control: true, shift: true, alt: false), Is.Null, "Enter"); } + + [Test] + public void ShiftEnterIsANewLineBecauseThePlainEnterWouldSubmit() + { + Assert.That(TerminalShortcutReader.FromKey(VkReturn, control: false, shift: true, alt: false), Is.EqualTo(TerminalShortcut.NewLine)); + } + + [Test] + public void ShiftEnterSendsEscapeThenCarriageReturnWhichClaudeReadsAsANewLine() + { + Assert.That(TerminalShortcutReader.NewLineInput, Is.EqualTo("\u001b\r")); + } + + [TestCase(false, false, false, Description = "Enter")] + [TestCase(true, false, false, Description = "Ctrl+Enter")] + [TestCase(true, true, false, Description = "Ctrl+Shift+Enter")] + [TestCase(false, true, true, Description = "Alt+Shift+Enter")] + [TestCase(false, false, true, Description = "Alt+Enter")] + public void OtherEnterCombinationsAreLeftAlone(bool control, bool shift, bool alt) + { + Assert.That(TerminalShortcutReader.FromKey(VkReturn, control, shift, alt), Is.Null); + } } diff --git a/tests/Kontext.TestSupport/InMemoryFakes.cs b/tests/Kontext.TestSupport/InMemoryFakes.cs index 8479c18..27a28a3 100644 --- a/tests/Kontext.TestSupport/InMemoryFakes.cs +++ b/tests/Kontext.TestSupport/InMemoryFakes.cs @@ -86,3 +86,18 @@ public ClaudeLaunch Prepare(StoredSession? earlier, WorktreeInfo worktree) Resumed); } } + +public sealed class InMemoryCollapsedRepositoryStore(params string[] collapsed) : ICollapsedRepositoryStore +{ + public HashSet Collapsed { get; private set; } = new(collapsed, StringComparer.OrdinalIgnoreCase); + + public int Saves { get; private set; } + + public IReadOnlySet Load() => new HashSet(Collapsed, StringComparer.OrdinalIgnoreCase); + + public void Save(IReadOnlySet collapsed) + { + Collapsed = new HashSet(collapsed, StringComparer.OrdinalIgnoreCase); + Saves++; + } +} diff --git a/tests/Kontext.TestSupport/WorkspaceChangesFakes.cs b/tests/Kontext.TestSupport/WorkspaceChangesFakes.cs new file mode 100644 index 0000000..4ec55f3 --- /dev/null +++ b/tests/Kontext.TestSupport/WorkspaceChangesFakes.cs @@ -0,0 +1,88 @@ +using Kontext.Contracts; +using Kontext.Domain; + +namespace Kontext.TestSupport; + +public sealed class FakeWorkspaceChanges : IWorkspaceChanges +{ + private readonly object _lock = new(); + private readonly List _changeRequests = []; + private readonly List _diffRequests = []; + private readonly List _threadIds = []; + + public WorkingTreeChanges Changes { get; set; } = WorkingTreeChanges.Empty; + + public Dictionary<(string Path, bool IsStaged), FileDiff> Diffs { get; } = []; + + public Exception? FailWith { get; set; } + + /// When set, takes its answer first and then waits for the gate. The wait ignores cancellation, like a git process that cannot be stopped in time. + public TaskCompletionSource? Gate { get; set; } + + public IReadOnlyList ChangeRequests + { + get + { + lock (_lock) + { + return [.. _changeRequests]; + } + } + } + + public IReadOnlyList DiffRequests + { + get + { + lock (_lock) + { + return [.. _diffRequests]; + } + } + } + + /// The managed thread id each call (changes or diff) ran on when it started. + public IReadOnlyList ThreadIds + { + get + { + lock (_lock) + { + return [.. _threadIds]; + } + } + } + + public async Task GetChangesAsync(string worktreePath, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _changeRequests.Add(worktreePath); + _threadIds.Add(Environment.CurrentManagedThreadId); + } + + var answer = Changes; + var failure = FailWith; + var gate = Gate; + + if (gate is not null) + { + await gate.Task; + } + + return failure is null ? answer : throw failure; + } + + public Task GetDiffAsync(string worktreePath, FileChange change, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _diffRequests.Add(change); + _threadIds.Add(Environment.CurrentManagedThreadId); + } + + return FailWith is { } failure + ? Task.FromException(failure) + : Task.FromResult(Diffs.TryGetValue((change.Path, change.IsStaged), out var diff) ? diff : new FileDiff(change.Path, [])); + } +} From 4f0de580bca71df3e42a1a060ab5064f601b8274 Mon Sep 17 00:00:00 2001 From: Jelle Buning Date: Wed, 4 Nov 2026 15:29:27 +0100 Subject: [PATCH 2/2] Sign release MSIX from certificate store --- .github/README.md | 2 +- .github/workflows/release.yaml | 33 ++++++++++++++++++--------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/README.md b/.github/README.md index 9763d2c..1f1f17e 100644 --- a/.github/README.md +++ b/.github/README.md @@ -117,7 +117,7 @@ Get-ChildItem tests -Recurse -Filter *.UnitTests.csproj | ForEach-Object { dotne ### 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. +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. The workflow imports the certificate into the runner's certificate store and signs by thumbprint, which avoids the APPX0105 and APPX0107 warnings that come from pointing MSBuild at the `.pfx`. The signature is timestamped (DigiCert), so the package keeps verifying after the certificate expires. ARM64 is not built because the embedded terminal control only ships x64 binaries. One-time setup: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b326a76..1497bdd 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,7 +16,6 @@ jobs: runs-on: windows-latest strategy: matrix: - # ARM64 is not built yet: EasyWindowsTerminalControl.WinUI only ships x64 binaries. platform: [x64] include: - platform: x64 @@ -46,7 +45,6 @@ jobs: 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 @@ -77,7 +75,6 @@ jobs: 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 @@ -87,18 +84,22 @@ jobs: $settings.GitHub.ClientId = $env:CLIENT_ID $settings | ConvertTo-Json -Depth 5 | Set-Content $path - - name: Decode signing certificate + - name: Install signing certificate + id: certificate shell: pwsh env: CERTIFICATE_BASE64: ${{ secrets.CERTIFICATE_BASE64 }} + CERT_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} run: | - $bytes = [Convert]::FromBase64String($env:CERTIFICATE_BASE64) - [IO.File]::WriteAllBytes("${{ github.workspace }}\cert.pfx", $bytes) + $pfx = "${{ github.workspace }}\cert.pfx" + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:CERTIFICATE_BASE64)) + $securePassword = ConvertTo-SecureString -String $env:CERT_PASSWORD -AsPlainText -Force + $cert = Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $securePassword + Remove-Item -Path $pfx -Force + "THUMBPRINT=$($cert.Thumbprint)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - name: Build and Package MSIX shell: pwsh - env: - CERT_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} run: | dotnet publish src\Kontext\Kontext.csproj ` -c Release ` @@ -108,24 +109,26 @@ jobs: -p:GenerateAppxPackageOnBuild=true ` -p:AppxPackageDir="${{ github.workspace }}\AppxPackages\" ` -p:AppxPackageSigningEnabled=true ` - -p:PackageCertificateKeyFile="${{ github.workspace }}\cert.pfx" ` - -p:PackageCertificatePassword=$env:CERT_PASSWORD ` + -p:PackageCertificateThumbprint=${{ steps.certificate.outputs.THUMBPRINT }} ` + -p:AppxPackageSigningTimestampDigestAlgorithm=SHA256 ` + -p:AppxPackageSigningTimestampServerUrl=http://timestamp.digicert.com ` -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 + $cert = Get-Item "Cert:\CurrentUser\My\${{ steps.certificate.outputs.THUMBPRINT }}" 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 + run: | + Remove-Item -Path "${{ github.workspace }}\cert.pfx" -Force -ErrorAction SilentlyContinue + if ("${{ steps.certificate.outputs.THUMBPRINT }}") { + Remove-Item -Path "Cert:\CurrentUser\My\${{ steps.certificate.outputs.THUMBPRINT }}" -Force -ErrorAction SilentlyContinue + } - name: Rename MSIX with platform suffix shell: pwsh