fix(ui): resizable modal, drop branch column, show committed diff

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-19 11:08:52 +02:00
parent ca71275fc4
commit bc15c16e44
8 changed files with 55 additions and 26 deletions

View File

@@ -35,6 +35,15 @@ public sealed class GitService
return stdout; return stdout;
} }
public async Task<string> GetCommittedFilesAsync(string worktreePath, string baseCommit, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
["diff", "--name-status", $"{baseCommit}..HEAD"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff --name-status failed (exit {exitCode}): {stderr}");
return stdout;
}
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct); var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);

View File

@@ -569,6 +569,7 @@ public sealed record WorktreeOverviewDto(
string ListName, string ListName,
string Path, string Path,
string BranchName, string BranchName,
string BaseCommit,
WorktreeState State, WorktreeState State,
string? DiffStat, string? DiffStat,
DateTime CreatedAt, DateTime CreatedAt,

View File

@@ -20,6 +20,7 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new(); public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
[ObservableProperty] private string _worktreePath = ""; [ObservableProperty] private string _worktreePath = "";
[ObservableProperty] private string? _baseCommit;
// Set by the view (same pattern as DiffModalViewModel.CloseAction) // Set by the view (same pattern as DiffModalViewModel.CloseAction)
public Action? CloseAction { get; set; } public Action? CloseAction { get; set; }
@@ -37,7 +38,13 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
Root.Clear(); Root.Clear();
string stdout; string stdout;
try { stdout = await _git.GetStatusPorcelainAsync(WorktreePath, ct); } bool committedMode = !string.IsNullOrEmpty(BaseCommit);
try
{
stdout = committedMode
? await _git.GetCommittedFilesAsync(WorktreePath, BaseCommit!, ct)
: await _git.GetStatusPorcelainAsync(WorktreePath, ct);
}
catch { return; } catch { return; }
if (string.IsNullOrWhiteSpace(stdout)) return; if (string.IsNullOrWhiteSpace(stdout)) return;
@@ -46,14 +53,27 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)) foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{ {
if (line.Length < 4) continue; string? path;
string? status;
// porcelain format: XY<space>path (XY = two-char status) if (committedMode)
{
// diff --name-status format: <status>\t<path>
var tab = line.IndexOf('\t');
if (tab < 0) continue;
var statusChar = line[0];
status = statusChar != ' ' ? statusChar.ToString() : null;
path = line[(tab + 1)..].Trim().Replace('\\', '/');
}
else
{
// porcelain format: XY<space>path
if (line.Length < 4) continue;
var xy = line[..2]; var xy = line[..2];
// Pick staged char first, fall back to unstaged
var statusChar = xy[0] != ' ' ? xy[0] : xy[1]; var statusChar = xy[0] != ' ' ? xy[0] : xy[1];
var status = statusChar != ' ' ? statusChar.ToString() : null; status = statusChar != ' ' ? statusChar.ToString() : null;
var path = line[3..].Trim().Replace('\\', '/'); path = line[3..].Trim().Replace('\\', '/');
}
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0) continue; if (segments.Length == 0) continue;

View File

@@ -20,6 +20,7 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
[ObservableProperty] private string _listName = ""; [ObservableProperty] private string _listName = "";
[ObservableProperty] private string _path = ""; [ObservableProperty] private string _path = "";
[ObservableProperty] private string _branchName = ""; [ObservableProperty] private string _branchName = "";
[ObservableProperty] private string _baseCommit = "";
[ObservableProperty] private WorktreeState _state; [ObservableProperty] private WorktreeState _state;
[ObservableProperty] private string? _diffStat; [ObservableProperty] private string? _diffStat;
[ObservableProperty] private DateTime _createdAt; [ObservableProperty] private DateTime _createdAt;
@@ -149,6 +150,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
if (row is null) return; if (row is null) return;
var diffVm = _diffVmFactory(); var diffVm = _diffVmFactory();
diffVm.WorktreePath = row.Path; diffVm.WorktreePath = row.Path;
diffVm.BaseCommit = string.IsNullOrEmpty(row.BaseCommit) ? null : row.BaseCommit;
ShowDiffAction?.Invoke(diffVm); ShowDiffAction?.Invoke(diffVm);
} }
@@ -231,7 +233,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
{ {
TaskId = d.TaskId, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus, TaskId = d.TaskId, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
ListId = d.ListId, ListName = d.ListName, ListId = d.ListId, ListName = d.ListName,
Path = d.Path, BranchName = d.BranchName, State = d.State, Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk, DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
}; };
} }

View File

@@ -6,6 +6,7 @@
x:DataType="vm:WorktreesOverviewModalViewModel" x:DataType="vm:WorktreesOverviewModalViewModel"
Title="{Binding Title}" Title="{Binding Title}"
Width="900" Height="560" MinWidth="640" MinHeight="360" Width="900" Height="560" MinWidth="640" MinHeight="360"
CanResize="True"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource VoidBrush}" Background="{DynamicResource VoidBrush}"
SystemDecorations="None" SystemDecorations="None"
@@ -52,7 +53,7 @@
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
</ContextMenu> </ContextMenu>
</Border.ContextMenu> </Border.ContextMenu>
<Grid ColumnDefinitions="*,200,90,80,80"> <Grid ColumnDefinitions="*,90,80,80">
<StackPanel Grid.Column="0" Orientation="Vertical" Spacing="2"> <StackPanel Grid.Column="0" Orientation="Vertical" Spacing="2">
<TextBlock Text="{Binding TaskTitle}" FontWeight="SemiBold"/> <TextBlock Text="{Binding TaskTitle}" FontWeight="SemiBold"/>
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4">
@@ -65,18 +66,15 @@
ToolTip.Tip="Directory missing on disk"/> ToolTip.Tip="Directory missing on disk"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<TextBlock Grid.Column="1" Text="{Binding BranchName}" <Border Grid.Column="1" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
FontFamily="{DynamicResource MonoFont}" FontSize="11"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<Border Grid.Column="2" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}"> Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
<TextBlock Text="{Binding State}" FontSize="10" Foreground="White" <TextBlock Text="{Binding State}" FontSize="10" Foreground="White"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
</Border> </Border>
<TextBlock Grid.Column="3" Text="{Binding DiffStat}" VerticalAlignment="Center" <TextBlock Grid.Column="2" Text="{Binding DiffStat}" VerticalAlignment="Center"
FontFamily="{DynamicResource MonoFont}" FontSize="11" FontFamily="{DynamicResource MonoFont}" FontSize="11"
Foreground="{DynamicResource TextDimBrush}"/> Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Grid.Column="4" Text="{Binding AgeText}" VerticalAlignment="Center" <TextBlock Grid.Column="3" Text="{Binding AgeText}" VerticalAlignment="Center"
FontSize="11" Foreground="{DynamicResource TextDimBrush}"/> FontSize="11" Foreground="{DynamicResource TextDimBrush}"/>
</Grid> </Grid>
</Border> </Border>
@@ -154,20 +152,17 @@
<ScrollViewer Grid.Row="2" Padding="12,8"> <ScrollViewer Grid.Row="2" Padding="12,8">
<StackPanel> <StackPanel>
<!-- Column headers --> <!-- Column headers -->
<Grid ColumnDefinitions="*,200,90,80,80" Margin="12,0,12,4"> <Grid ColumnDefinitions="*,90,80,80" Margin="12,0,12,4">
<TextBlock Grid.Column="0" Text="TASK" <TextBlock Grid.Column="0" Text="TASK"
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4" FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"/> Foreground="{DynamicResource TextFaintBrush}"/>
<TextBlock Grid.Column="1" Text="BRANCH" <TextBlock Grid.Column="1" Text="STATE"
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4" FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"/> Foreground="{DynamicResource TextFaintBrush}"/>
<TextBlock Grid.Column="2" Text="STATE" <TextBlock Grid.Column="2" Text="DIFF"
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4" FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"/> Foreground="{DynamicResource TextFaintBrush}"/>
<TextBlock Grid.Column="3" Text="DIFF" <TextBlock Grid.Column="3" Text="AGE"
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"/>
<TextBlock Grid.Column="4" Text="AGE"
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4" FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"/> Foreground="{DynamicResource TextFaintBrush}"/>
</Grid> </Grid>

View File

@@ -38,6 +38,7 @@ public record WorktreeOverviewDto(
string ListName, string ListName,
string Path, string Path,
string BranchName, string BranchName,
string BaseCommit,
WorktreeState State, WorktreeState State,
string? DiffStat, string? DiffStat,
DateTime CreatedAt, DateTime CreatedAt,
@@ -252,7 +253,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted); var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted);
return rows.Select(r => new WorktreeOverviewDto( return rows.Select(r => new WorktreeOverviewDto(
r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName, r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
r.Path, r.BranchName, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList(); r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList();
} }
public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState) public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)

View File

@@ -82,7 +82,7 @@ public sealed class WorktreeMaintenanceService
select new select new
{ {
w.TaskId, t.Title, t.Status, ListId = l.Id, ListName = l.Name, w.TaskId, t.Title, t.Status, ListId = l.Id, ListName = l.Name,
w.Path, w.BranchName, w.State, w.DiffStat, w.CreatedAt, w.Path, w.BranchName, w.BaseCommit, w.State, w.DiffStat, w.CreatedAt,
}; };
if (!string.IsNullOrEmpty(listId)) if (!string.IsNullOrEmpty(listId))
@@ -92,7 +92,7 @@ public sealed class WorktreeMaintenanceService
return rows.Select(x => new WorktreeOverviewRow( return rows.Select(x => new WorktreeOverviewRow(
x.TaskId, x.Title, x.Status, x.ListId, x.ListName, x.TaskId, x.Title, x.Status, x.ListId, x.ListName,
x.Path, x.BranchName, x.State, x.DiffStat, x.CreatedAt, x.Path, x.BranchName, x.BaseCommit ?? "", x.State, x.DiffStat, x.CreatedAt,
PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList(); PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList();
} }

View File

@@ -11,6 +11,7 @@ public sealed record WorktreeOverviewRow(
string ListName, string ListName,
string Path, string Path,
string BranchName, string BranchName,
string BaseCommit,
WorktreeState State, WorktreeState State,
string? DiffStat, string? DiffStat,
DateTime CreatedAt, DateTime CreatedAt,