feat(ui): worktree modal with tree view and M/A badges

Adds WorktreeModalView/ViewModel showing git status --porcelain as a
recursive file tree with M/A/D/? status badges. Wires the Worktree
button in AgentStripView to OpenWorktreeCommand on DetailsIslandViewModel.
Adds GetStatusPorcelainAsync to GitService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-20 10:31:12 +02:00
parent 4d68543cf2
commit abd7733c90
6 changed files with 209 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ using ClaudeDo.Ui;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
@@ -78,6 +79,7 @@ sealed class Program
// ViewModels
sc.AddTransient<ListEditorViewModel>();
sc.AddTransient<TaskEditorViewModel>();
sc.AddTransient<WorktreeModalViewModel>();
sc.AddSingleton<StatusBarViewModel>();
sc.AddSingleton<TaskDetailViewModel>();
sc.AddSingleton<TaskListViewModel>(sp =>

View File

@@ -260,6 +260,35 @@
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<!-- ============================================================ -->
<!-- WORKTREE MODAL STATUS BADGES -->
<!-- Tag="M" → peat, "A" → moss, "D" → blood, "?" → faint -->
<!-- ============================================================ -->
<Style Selector="Border[Tag=M]">
<Setter Property="Background" Value="#26A06040"/>
</Style>
<Style Selector="Border[Tag=M] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource PeatBrush}"/>
</Style>
<Style Selector="Border[Tag=A]">
<Setter Property="Background" Value="#267C9166"/>
</Style>
<Style Selector="Border[Tag=A] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource MossBrush}"/>
</Style>
<Style Selector="Border[Tag=D]">
<Setter Property="Background" Value="#26C87060"/>
</Style>
<Style Selector="Border[Tag=D] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}"/>
</Style>
<Style Selector="Border[Tag=?]">
<Setter Property="Background" Value="#1A888888"/>
</Style>
<Style Selector="Border[Tag=?] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
</Style>
<!-- ============================================================ -->
<!-- LIST NAV ITEM -->
<!-- ============================================================ -->

View File

@@ -0,0 +1,86 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Git;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class WorktreeNodeViewModel : ViewModelBase
{
public required string Name { get; init; }
public string? Status { get; init; }
public bool IsDirectory { get; init; }
public ObservableCollection<WorktreeNodeViewModel> Children { get; } = new();
}
public sealed partial class WorktreeModalViewModel : ViewModelBase
{
private readonly GitService _git;
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
[ObservableProperty] private string _worktreePath = "";
// Set by the view (same pattern as DiffModalViewModel.CloseAction)
public Action? CloseAction { get; set; }
public WorktreeModalViewModel(GitService git)
{
_git = git;
}
[RelayCommand]
private void Close() => CloseAction?.Invoke();
public async Task LoadAsync(CancellationToken ct = default)
{
Root.Clear();
string stdout;
try { stdout = await _git.GetStatusPorcelainAsync(WorktreePath, ct); }
catch { return; }
if (string.IsNullOrWhiteSpace(stdout)) return;
var dirs = new Dictionary<string, WorktreeNodeViewModel>(StringComparer.Ordinal);
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (line.Length < 4) continue;
// porcelain format: XY<space>path (XY = two-char status)
var xy = line[..2];
// Pick staged char first, fall back to unstaged
var statusChar = xy[0] != ' ' ? xy[0] : xy[1];
var status = statusChar != ' ' ? statusChar.ToString() : null;
var path = line[3..].Trim().Replace('\\', '/');
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0) continue;
WorktreeNodeViewModel? parent = null;
var accumulated = "";
for (var i = 0; i < segments.Length - 1; i++)
{
accumulated = accumulated.Length == 0 ? segments[i] : accumulated + "/" + segments[i];
if (!dirs.TryGetValue(accumulated, out var dir))
{
dir = new WorktreeNodeViewModel { Name = segments[i], IsDirectory = true };
dirs[accumulated] = dir;
if (parent == null) Root.Add(dir);
else parent.Children.Add(dir);
}
parent = dir;
}
var leaf = new WorktreeNodeViewModel
{
Name = segments[^1],
Status = status,
IsDirectory = false
};
if (parent == null) Root.Add(leaf);
else parent.Children.Add(leaf);
}
}
}

View File

@@ -30,7 +30,6 @@ public partial class AgentStripView : UserControl
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var modal = new WorktreeModalView { DataContext = worktreeVm };
worktreeVm.CloseCommand.Subscribe(_ => modal.Close());
await modal.ShowDialog(owner);
};
}

View File

@@ -0,0 +1,66 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.WorktreeModalView"
x:DataType="vm:WorktreeModalViewModel"
Title="Worktree"
Width="640" Height="720"
WindowStartupLocation="CenterOwner"
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
Background="Transparent"
CanResize="False"
TransparencyLevelHint="AcrylicBlur">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<Border Classes="island" Margin="12">
<DockPanel>
<!-- Title strip -->
<Border DockPanel.Dock="Top" Height="36"
PointerPressed="OnTitleBarPressed">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Grid.Column="0" Text="Worktree" VerticalAlignment="Center"
FontFamily="{DynamicResource MonoFont}" FontSize="12"
Foreground="{DynamicResource TextMuteBrush}"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕"
Command="{Binding CloseCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Path strip -->
<Border DockPanel.Dock="Top" Padding="14,0,14,8">
<TextBlock Text="{Binding WorktreePath}"
FontFamily="{DynamicResource MonoFont}" FontSize="11"
Foreground="{DynamicResource TextFaintBrush}"
TextTrimming="CharacterEllipsis"/>
</Border>
<!-- File tree -->
<TreeView DockPanel.Dock="Top" ItemsSource="{Binding Root}"
Background="Transparent" Margin="8,0,8,8">
<TreeView.ItemTemplate>
<TreeDataTemplate DataType="vm:WorktreeNodeViewModel"
ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Name}"
FontFamily="{DynamicResource MonoFont}" FontSize="12"
Foreground="{DynamicResource TextBrush}"/>
<Border Tag="{Binding Status}" CornerRadius="3" Padding="4,0"
VerticalAlignment="Center"
IsVisible="{Binding Status, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Status}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
Foreground="{DynamicResource TextBrush}"/>
</Border>
</StackPanel>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</DockPanel>
</Border>
</Window>

View File

@@ -0,0 +1,26 @@
using Avalonia.Controls;
using Avalonia.Input;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class WorktreeModalView : Window
{
public WorktreeModalView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is WorktreeModalViewModel vm)
vm.CloseAction = Close;
}
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
}