feat(ui): richer diff viewer + surface child roadblocks on parents
- UnifiedDiffParser detects added/deleted/renamed/binary files; diff modal shows a file list, binary/empty placeholders, and can diff a merged task by commit range after its worktree is gone - DetailsIslandViewModel flags children needing attention (failed, cancelled, awaiting review, or with roadblocks) on the parent - GitService gains worktree head-commit/range support; planning chain, merge orchestration, and session manager tweaks with updated tests - refresh app/installer/worker icons Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -574,6 +574,13 @@
|
||||
<Style Selector="Border[Tag=?] > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
|
||||
</Style>
|
||||
<!-- R → rename (sage) -->
|
||||
<Style Selector="Border[Tag=R]">
|
||||
<Setter Property="Background" Value="#268B9D7A"/>
|
||||
</Style>
|
||||
<Style Selector="Border[Tag=R] > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource SageBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- LIST NAV ITEM -->
|
||||
|
||||
@@ -165,6 +165,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
OnPropertyChanged(nameof(HasChildOutcomes));
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
NotifyAttention();
|
||||
|
||||
// The Session tab is only visible when it has outcomes; if it just
|
||||
// emptied while selected, fall back to Output so the body isn't blank.
|
||||
@@ -354,7 +355,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
[ObservableProperty] private string? _worktreePath;
|
||||
[ObservableProperty] private string? _worktreeBaseCommit;
|
||||
[ObservableProperty] private string? _worktreeHeadCommit;
|
||||
[ObservableProperty] private string? _worktreeStateLabel;
|
||||
// Repo working dir of the selected task's list — used to diff a merged task's
|
||||
// commit range after its worktree directory is gone.
|
||||
private string? _listWorkingDir;
|
||||
[ObservableProperty] private string? _branchLine;
|
||||
[ObservableProperty] private int _turns;
|
||||
[ObservableProperty] private int _tokens;
|
||||
@@ -388,6 +393,27 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
|
||||
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
|
||||
|
||||
// Children that need the user's attention before the parent can be approved:
|
||||
// failed, cancelled, still awaiting their own review, or that reported roadblocks.
|
||||
// The parent deliberately stays in WaitingForChildren until these are resolved;
|
||||
// this surfaces a flag so the roadblock is visible on the parent.
|
||||
public int ChildrenNeedingAttention => ChildOutcomes.Count(c =>
|
||||
c.Status == ClaudeDo.Data.Models.TaskStatus.Failed
|
||||
|| c.Status == ClaudeDo.Data.Models.TaskStatus.Cancelled
|
||||
|| c.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|
||||
|| c.RoadblockCount > 0);
|
||||
public bool HasChildrenNeedingAttention => ChildrenNeedingAttention > 0;
|
||||
public string ChildrenAttentionText => ChildrenNeedingAttention == 1
|
||||
? "1 child needs attention"
|
||||
: $"{ChildrenNeedingAttention} children need attention";
|
||||
|
||||
private void NotifyAttention()
|
||||
{
|
||||
OnPropertyChanged(nameof(ChildrenNeedingAttention));
|
||||
OnPropertyChanged(nameof(HasChildrenNeedingAttention));
|
||||
OnPropertyChanged(nameof(ChildrenAttentionText));
|
||||
}
|
||||
|
||||
[ObservableProperty] private string _newSubtaskTitle = "";
|
||||
|
||||
// Planning merge controls
|
||||
@@ -840,6 +866,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
EditableDescription = "";
|
||||
Model = null;
|
||||
WorktreePath = null;
|
||||
WorktreeHeadCommit = null;
|
||||
_listWorkingDir = null;
|
||||
WorktreeStateLabel = null;
|
||||
BranchLine = null;
|
||||
DiffAdditions = 0;
|
||||
@@ -876,6 +904,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
var entity = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Worktree)
|
||||
.Include(t => t.List)
|
||||
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (entity == null) return;
|
||||
@@ -885,8 +914,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
try { EditableDescription = entity.Description ?? ""; }
|
||||
finally { _suppressDescSave = false; }
|
||||
Model = entity.Model;
|
||||
_listWorkingDir = entity.List?.WorkingDir;
|
||||
WorktreePath = entity.Worktree?.Path;
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||||
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
|
||||
@@ -914,13 +945,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||||
|
||||
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
|
||||
{
|
||||
await LoadPlanningChildrenAsync(row.Id, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await LoadChildOutcomesAsync(row.Id, ct);
|
||||
}
|
||||
// Surface every parent's children — planning or improvement — in the
|
||||
// Session tab with their live status + roadblock count. This is what
|
||||
// makes the Session tab appear for planning parents and lets a child's
|
||||
// roadblock register on the parent.
|
||||
await LoadChildOutcomesAsync(row.Id, ct);
|
||||
|
||||
if (entity.Worktree != null
|
||||
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
||||
@@ -1125,6 +1155,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
row.RoadblockCount = child.RoadblockCount;
|
||||
row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||
NotifyAttention();
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
@@ -1148,11 +1179,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
var entity = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Worktree)
|
||||
.Include(t => t.List)
|
||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity == null || Task?.Id != taskId) return;
|
||||
|
||||
_listWorkingDir = entity.List?.WorkingDir;
|
||||
WorktreePath = entity.Worktree?.Path;
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||||
AgentState = StatusToStateKey(entity.Status);
|
||||
@@ -1190,21 +1224,53 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||
{
|
||||
if (WorktreePath == null || ShowDiffModal == null) return;
|
||||
var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
|
||||
if (ShowDiffModal == null) return;
|
||||
var git = _services.GetRequiredService<ClaudeDo.Data.Git.GitService>();
|
||||
|
||||
// Active worktree on disk → diff the worktree live (and allow merging from it).
|
||||
var hasLiveWorktree =
|
||||
WorktreePath != null
|
||||
&& WorktreeStateLabel == "Active"
|
||||
&& System.IO.Directory.Exists(WorktreePath);
|
||||
|
||||
DiffModalViewModel diffVm;
|
||||
if (hasLiveWorktree)
|
||||
{
|
||||
WorktreePath = WorktreePath,
|
||||
BaseRef = WorktreeBaseCommit,
|
||||
TaskId = Task?.Id,
|
||||
TaskTitle = Task?.Title ?? "",
|
||||
ShowMergeModal = ShowMergeModal,
|
||||
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||||
};
|
||||
diffVm = new DiffModalViewModel(git)
|
||||
{
|
||||
WorktreePath = WorktreePath!,
|
||||
BaseRef = WorktreeBaseCommit,
|
||||
TaskId = Task?.Id,
|
||||
TaskTitle = Task?.Title ?? "",
|
||||
ShowMergeModal = ShowMergeModal,
|
||||
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||||
};
|
||||
}
|
||||
else if (CanDiffMergedRange)
|
||||
{
|
||||
// Worktree is gone (merged/discarded) but the commits survive on the
|
||||
// target branch — diff the captured base..head range in the repo. No
|
||||
// merge action: the work is already integrated.
|
||||
diffVm = new DiffModalViewModel(git)
|
||||
{
|
||||
WorktreePath = _listWorkingDir!,
|
||||
BaseRef = WorktreeBaseCommit,
|
||||
HeadCommit = WorktreeHeadCommit,
|
||||
FromCommitRange = true,
|
||||
TaskId = Task?.Id,
|
||||
TaskTitle = Task?.Title ?? "",
|
||||
};
|
||||
}
|
||||
else return;
|
||||
|
||||
await diffVm.LoadAsync();
|
||||
await ShowDiffModal(diffVm);
|
||||
}
|
||||
|
||||
private bool CanOpenDiff() => WorktreePath != null;
|
||||
private bool CanDiffMergedRange =>
|
||||
WorktreeBaseCommit != null && WorktreeHeadCommit != null && _listWorkingDir != null;
|
||||
|
||||
private bool CanOpenDiff() => WorktreePath != null || CanDiffMergedRange;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
||||
private void OpenWorktree()
|
||||
@@ -1235,6 +1301,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
NotifySessionSections();
|
||||
}
|
||||
|
||||
partial void OnWorktreeHeadCommitChanged(string? value) =>
|
||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||
|
||||
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
|
||||
|
||||
[RelayCommand]
|
||||
@@ -1473,7 +1542,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
// hasChildren: conflicts arrive via PlanningMergeConflictEvent → conflict dialog
|
||||
}
|
||||
catch { /* stale review action; broadcast reconciles */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A real failure (e.g. a child still needs attention, so the unit can't
|
||||
// be approved yet) must not vanish — tell the user why nothing happened.
|
||||
if (ShowErrorAsync != null)
|
||||
await ShowErrorAsync(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -154,21 +154,23 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
if (ShowConflictDialog == null || _dbFactory == null) return;
|
||||
|
||||
string subtaskTitle = subtaskId;
|
||||
string worktreePath = System.Environment.CurrentDirectory;
|
||||
// The conflict lives in the list's working dir (the repo being merged into),
|
||||
// not the subtask worktree. VS Code must open this folder to show the merge UI.
|
||||
string repoDirectory = System.Environment.CurrentDirectory;
|
||||
string targetBranch = Worker?.LastApproveTarget ?? "main";
|
||||
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks
|
||||
.Include(t => t.Worktree)
|
||||
.Include(t => t.List)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == subtaskId);
|
||||
if (entity != null)
|
||||
{
|
||||
subtaskTitle = entity.Title;
|
||||
if (entity.Worktree?.Path is { } p)
|
||||
worktreePath = p;
|
||||
if (entity.List?.WorkingDir is { } dir && !string.IsNullOrWhiteSpace(dir))
|
||||
repoDirectory = dir;
|
||||
}
|
||||
}
|
||||
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
|
||||
@@ -179,7 +181,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
subtaskTitle,
|
||||
targetBranch,
|
||||
conflictedFiles,
|
||||
worktreePath);
|
||||
repoDirectory);
|
||||
|
||||
await ShowConflictDialog(vm);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public enum DiffLineKind { Add, Del, Ctx, File }
|
||||
|
||||
public enum DiffFileStatus { Modified, Added, Deleted, Renamed }
|
||||
|
||||
public sealed class DiffLineViewModel
|
||||
{
|
||||
public required DiffLineKind Kind { get; init; }
|
||||
@@ -32,10 +34,27 @@ public sealed class DiffLineViewModel
|
||||
|
||||
public sealed class DiffFileViewModel
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required string Path { get; set; }
|
||||
public string? OldPath { get; set; }
|
||||
public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified;
|
||||
public bool IsBinary { get; set; }
|
||||
public int Additions { get; set; }
|
||||
public int Deletions { get; set; }
|
||||
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
|
||||
|
||||
/// Single-letter badge for the file's change kind (A/M/D/R).
|
||||
public string StatusCode => Status switch
|
||||
{
|
||||
DiffFileStatus.Added => "A",
|
||||
DiffFileStatus.Deleted => "D",
|
||||
DiffFileStatus.Renamed => "R",
|
||||
_ => "M",
|
||||
};
|
||||
|
||||
public bool HasLines => Lines.Count > 0;
|
||||
|
||||
/// A text file that produced no diff hunks (e.g. a newly added empty file).
|
||||
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
|
||||
}
|
||||
|
||||
public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
@@ -44,6 +63,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
|
||||
public required string WorktreePath { get; init; }
|
||||
public string? BaseRef { get; init; }
|
||||
/// When set together with <see cref="FromCommitRange"/>, the diff is computed as
|
||||
/// <c>BaseRef..HeadCommit</c> inside <see cref="WorktreePath"/> (used as the repo
|
||||
/// dir) — lets a merged task's diff be viewed after its worktree is gone.
|
||||
public string? HeadCommit { get; init; }
|
||||
public bool FromCommitRange { get; init; }
|
||||
public string? TaskId { get; init; }
|
||||
public string TaskTitle { get; init; } = "";
|
||||
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
|
||||
@@ -77,6 +101,8 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
var vm = ResolveMergeVm();
|
||||
await vm.InitializeAsync(TaskId, TaskTitle);
|
||||
await ShowMergeModal(vm);
|
||||
// The diff is stale once the worktree has been merged away — close it too.
|
||||
if (vm.Merged) CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
@@ -87,9 +113,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
string raw;
|
||||
try
|
||||
{
|
||||
raw = BaseRef is not null
|
||||
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
||||
: await _git.GetDiffAsync(WorktreePath, ct);
|
||||
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
|
||||
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct)
|
||||
: BaseRef is not null
|
||||
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
||||
: await _git.GetDiffAsync(WorktreePath, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -28,6 +28,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
|
||||
/// close itself after this modal closes.
|
||||
public bool Merged { get; private set; }
|
||||
|
||||
public MergeModalViewModel(WorkerClient worker)
|
||||
{
|
||||
_worker = worker;
|
||||
@@ -80,6 +84,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
switch (result.Status)
|
||||
{
|
||||
case "merged":
|
||||
Merged = true;
|
||||
SuccessMessage = result.ErrorMessage is not null
|
||||
? $"Merged with warning: {result.ErrorMessage}"
|
||||
: Loc.T("vm.merge.merged");
|
||||
|
||||
@@ -27,6 +27,36 @@ public static class UnifiedDiffParser
|
||||
|
||||
if (current == null) continue;
|
||||
|
||||
// File-level metadata that carries the change kind.
|
||||
if (line.StartsWith("new file", StringComparison.Ordinal))
|
||||
{
|
||||
current.Status = DiffFileStatus.Added;
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("deleted file", StringComparison.Ordinal))
|
||||
{
|
||||
current.Status = DiffFileStatus.Deleted;
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("rename from ", StringComparison.Ordinal))
|
||||
{
|
||||
current.Status = DiffFileStatus.Renamed;
|
||||
current.OldPath = line["rename from ".Length..];
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("rename to ", StringComparison.Ordinal))
|
||||
{
|
||||
current.Status = DiffFileStatus.Renamed;
|
||||
current.Path = line["rename to ".Length..];
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("Binary files", StringComparison.Ordinal) ||
|
||||
line.StartsWith("GIT binary patch", StringComparison.Ordinal))
|
||||
{
|
||||
current.IsBinary = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("@@ ", StringComparison.Ordinal))
|
||||
{
|
||||
// e.g. "@@ -10,7 +10,9 @@"
|
||||
@@ -34,13 +64,15 @@ public static class UnifiedDiffParser
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip diff metadata lines
|
||||
// Skip remaining diff metadata lines
|
||||
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
|
||||
line.StartsWith("+++ ", StringComparison.Ordinal) ||
|
||||
line.StartsWith("index ", StringComparison.Ordinal) ||
|
||||
line.StartsWith("new file", StringComparison.Ordinal) ||
|
||||
line.StartsWith("deleted file", StringComparison.Ordinal) ||
|
||||
line.StartsWith("Binary ", StringComparison.Ordinal))
|
||||
line.StartsWith("old mode", StringComparison.Ordinal) ||
|
||||
line.StartsWith("new mode", StringComparison.Ordinal) ||
|
||||
line.StartsWith("similarity index", StringComparison.Ordinal) ||
|
||||
line.StartsWith("copy from", StringComparison.Ordinal) ||
|
||||
line.StartsWith("copy to", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
if (line.StartsWith('+'))
|
||||
|
||||
@@ -10,7 +10,10 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly string _planningTaskId;
|
||||
private readonly string _worktreePath;
|
||||
// The repository directory that is currently mid-merge (the list's working dir),
|
||||
// NOT the subtask worktree. Opening this folder is what makes VS Code show its
|
||||
// merge-conflict resolution UI.
|
||||
private readonly string _repoDirectory;
|
||||
|
||||
public string SubtaskTitle { get; }
|
||||
public string TargetBranch { get; }
|
||||
@@ -29,11 +32,11 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
||||
string subtaskTitle,
|
||||
string targetBranch,
|
||||
IReadOnlyList<string> conflictedFiles,
|
||||
string worktreePath)
|
||||
string repoDirectory)
|
||||
{
|
||||
_worker = worker;
|
||||
_planningTaskId = planningTaskId;
|
||||
_worktreePath = worktreePath;
|
||||
_repoDirectory = repoDirectory;
|
||||
SubtaskTitle = subtaskTitle;
|
||||
TargetBranch = targetBranch;
|
||||
ConflictedFiles = conflictedFiles;
|
||||
@@ -44,12 +47,13 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
||||
{
|
||||
try
|
||||
{
|
||||
var args = string.Join(" ", ConflictedFiles.Select(f => $"\"{f}\""));
|
||||
// Open the folder that is mid-merge so VS Code shows the Source Control
|
||||
// merge-conflict UI for every conflicted file. Opening individual files
|
||||
// gives only a plain editor with no conflict resolution affordances.
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "code",
|
||||
Arguments = args,
|
||||
WorkingDirectory = _worktreePath,
|
||||
Arguments = $"\"{_repoDirectory}\"",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
VsCodeError = null;
|
||||
|
||||
@@ -313,6 +313,22 @@
|
||||
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10">
|
||||
<StackPanel Spacing="14">
|
||||
|
||||
<!-- Attention band: a child failed, was cancelled, still needs its own
|
||||
review, or reported roadblocks. The parent stays waiting until resolved. -->
|
||||
<Border IsVisible="{Binding HasChildrenNeedingAttention}"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource BloodBrush}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10,8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Data="{StaticResource Icon.Warning}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
Width="14" Height="14" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="meta" Text="{Binding ChildrenAttentionText}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Child outcomes -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
|
||||
<TextBlock Classes="section-label" Text="OUTCOMES" />
|
||||
|
||||
@@ -26,51 +26,100 @@
|
||||
</StackPanel>
|
||||
</ctl:ModalShell.Footer>
|
||||
|
||||
<!-- Body: sidebar + diff content -->
|
||||
<Grid ColumnDefinitions="240,*">
|
||||
<!-- Body: two islands — file list | diff content -->
|
||||
<Grid ColumnDefinitions="280,12,*" Margin="16">
|
||||
|
||||
<!-- File sidebar -->
|
||||
<Border Grid.Column="0"
|
||||
Classes="sidebar-pane">
|
||||
<ListBox ItemsSource="{Binding Files}"
|
||||
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DiffFileViewModel">
|
||||
<Border Padding="10,8" Background="Transparent">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="path-mono" Text="{Binding Path}"
|
||||
TextTrimming="PrefixCharacterEllipsis"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Border Classes="chip" Padding="5,2">
|
||||
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
|
||||
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
||||
</Border>
|
||||
<Border Classes="chip" Padding="5,2">
|
||||
<TextBlock Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{Binding Deletions, StringFormat='−{0}'}"/>
|
||||
</Border>
|
||||
<!-- Files island -->
|
||||
<Border Grid.Column="0" Classes="island">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.filesHeader}"/>
|
||||
</Border>
|
||||
<ListBox ItemsSource="{Binding Files}"
|
||||
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DiffFileViewModel">
|
||||
<Border Padding="10,8" Background="Transparent">
|
||||
<StackPanel Spacing="4">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Border Grid.Column="0" Tag="{Binding StatusCode}"
|
||||
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding StatusCode}"
|
||||
FontFamily="{DynamicResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeEyebrow}"
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding Path}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="PrefixCharacterEllipsis"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding !IsBinary}">
|
||||
<Border Classes="chip" Padding="5,2">
|
||||
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
|
||||
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
||||
</Border>
|
||||
<Border Classes="chip" Padding="5,2">
|
||||
<TextBlock Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{Binding Deletions, StringFormat='−{0}'}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Diff content -->
|
||||
<Grid Grid.Column="1" Background="{DynamicResource VoidBrush}">
|
||||
<TextBlock Classes="body" Text="{Binding StatusMessage}"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
<!-- Diff content island -->
|
||||
<Border Grid.Column="2" Classes="island">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Classes="island-header"
|
||||
IsVisible="{Binding SelectedFile, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Border Grid.Column="0" Tag="{Binding SelectedFile.StatusCode}"
|
||||
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding SelectedFile.StatusCode}"
|
||||
FontFamily="{DynamicResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeEyebrow}"
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.Path}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="PrefixCharacterEllipsis"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Background="{DynamicResource VoidBrush}">
|
||||
<!-- Load / no-changes message -->
|
||||
<TextBlock Classes="body" Text="{Binding StatusMessage}"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<!-- Binary file -->
|
||||
<TextBlock Classes="body" Text="{loc:Tr modals.diff.binary}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
IsVisible="{Binding SelectedFile.IsBinary}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<!-- Empty / no-content file -->
|
||||
<TextBlock Classes="body" Text="{loc:Tr modals.diff.empty}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
IsVisible="{Binding SelectedFile.IsEmptyContent}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<!-- Diff content -->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
IsVisible="{Binding SelectedFile.HasLines}">
|
||||
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
|
||||
Reference in New Issue
Block a user