feat(attachments): drag-and-drop file attachments on the detail pane
Drop a file anywhere on the detail pane to attach it: pane-wide drop target with a 'Drop to attach' hover overlay (Copy cursor, gated on an idle selected task), an explicit lingering confirmation/error line, plus an Attachments list with size, remove, and an Add file… picker in the DETAILS card. ComposedPreview now shows the reference files too. en/de keys added.
This commit is contained in:
@@ -12,6 +12,7 @@ using ClaudeDo.Ui.Services.Interfaces;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.IO;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
@@ -138,7 +139,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public string ComposedPreview =>
|
||||
ClaudeDo.Data.TaskPromptComposer.Compose(
|
||||
EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)));
|
||||
EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)),
|
||||
Task is not null
|
||||
? Attachments.Select(a => Path.Combine(new AttachmentStore().TaskDir(Task.Id), a.FileName))
|
||||
: null);
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
|
||||
@@ -257,6 +261,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
OnPropertyChanged(nameof(ShowRoadblockCard));
|
||||
AgentSettings.IsRunning = IsRunning;
|
||||
NotifySessionSections();
|
||||
OnPropertyChanged(nameof(CanAcceptDrop));
|
||||
}
|
||||
|
||||
[ObservableProperty] private string? _model;
|
||||
@@ -293,6 +298,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
public ObservableCollection<LogLineViewModel> Log { get; } = new();
|
||||
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
|
||||
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
|
||||
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new();
|
||||
|
||||
[ObservableProperty] private bool _isDragOver;
|
||||
[ObservableProperty] private string? _dropStatus;
|
||||
|
||||
public bool CanAcceptDrop => Task is not null && !Task.IsRunning;
|
||||
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
|
||||
|
||||
public int ChildrenNeedingAttention => ChildOutcomes.Count(c =>
|
||||
@@ -601,6 +612,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Log.Clear();
|
||||
Subtasks.Clear();
|
||||
ChildOutcomes.Clear();
|
||||
Attachments.Clear();
|
||||
DropStatus = null;
|
||||
OnPropertyChanged(nameof(HasChildOutcomes));
|
||||
SessionOutcome = null;
|
||||
Roadblocks = null;
|
||||
@@ -683,6 +696,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
foreach (var s in subs)
|
||||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||||
|
||||
var attachmentRepo = new TaskAttachmentRepository(ctx);
|
||||
var attachments = await attachmentRepo.ListByTaskIdAsync(row.Id, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
foreach (var a in attachments)
|
||||
Attachments.Add(new AttachmentRowViewModel { FileName = a.FileName, ByteSize = a.ByteSize });
|
||||
|
||||
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
|
||||
await LoadPlanningChildrenAsync(row.Id, ct);
|
||||
await LoadChildOutcomesAsync(row.Id, ct);
|
||||
@@ -931,6 +950,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
|
||||
NotifySessionSections();
|
||||
OnPropertyChanged(nameof(CanAcceptDrop));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -1196,6 +1216,106 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
catch { /* stale review action; broadcast reconciles */ }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task ReloadAttachmentsAsync()
|
||||
{
|
||||
if (Task is null) return;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var attachments = await new TaskAttachmentRepository(ctx).ListByTaskIdAsync(Task.Id);
|
||||
Attachments.Clear();
|
||||
foreach (var a in attachments)
|
||||
Attachments.Add(new AttachmentRowViewModel { FileName = a.FileName, ByteSize = a.ByteSize });
|
||||
OnPropertyChanged(nameof(ComposedPreview));
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
public async System.Threading.Tasks.Task AddFilesAsync(IReadOnlyList<(string FileName, Stream Content)> files)
|
||||
{
|
||||
if (Task is null || Task.IsRunning)
|
||||
{
|
||||
DropStatus = Loc.T("details.attachments.selectIdleTask");
|
||||
return;
|
||||
}
|
||||
|
||||
var store = new AttachmentStore();
|
||||
var successes = new List<string>();
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var (fileName, content) in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var byteSize = await store.SaveAsync(Task.Id, fileName, content);
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var repo = new TaskAttachmentRepository(ctx);
|
||||
var existing = await repo.GetAsync(Task.Id, fileName);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.ByteSize = byteSize;
|
||||
await repo.UpdateAsync(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
await repo.AddAsync(new ClaudeDo.Data.Models.TaskAttachmentEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
TaskId = Task.Id,
|
||||
FileName = fileName,
|
||||
ByteSize = byteSize,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
successes.Add(fileName);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
failures.Add(string.Format(Loc.T("details.attachments.overLimitError"), fileName, ex.Message));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
failures.Add(string.Format(Loc.T("details.attachments.invalidNameError"), fileName, ex.Message));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add($"{fileName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await ReloadAttachmentsAsync();
|
||||
|
||||
if (failures.Count == 0)
|
||||
{
|
||||
var names = string.Join(", ", successes);
|
||||
DropStatus = string.Format(Loc.T("details.attachments.addedSummary"), names, successes.Count);
|
||||
}
|
||||
else if (successes.Count == 0)
|
||||
{
|
||||
DropStatus = string.Join(" · ", failures);
|
||||
}
|
||||
else
|
||||
{
|
||||
var names = string.Join(", ", successes);
|
||||
var addedPart = string.Format(Loc.T("details.attachments.addedSummary"), names, successes.Count);
|
||||
DropStatus = addedPart + " · " + string.Join(" · ", failures);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task RemoveAttachment(AttachmentRowViewModel? row)
|
||||
{
|
||||
if (row is null || Task is null) return;
|
||||
try
|
||||
{
|
||||
new AttachmentStore().DeleteFile(Task.Id, row.FileName);
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
await new TaskAttachmentRepository(ctx).DeleteAsync(Task.Id, row.FileName);
|
||||
await ReloadAttachmentsAsync();
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
internal static (int Additions, int Deletions) ParseDiffStat(string? stat)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stat)) return (0, 0);
|
||||
@@ -1208,6 +1328,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AttachmentRowViewModel
|
||||
{
|
||||
public required string FileName { get; init; }
|
||||
public required long ByteSize { get; init; }
|
||||
public string SizeText => ByteSize switch
|
||||
{
|
||||
>= 1024 * 1024 => $"{ByteSize / (1024.0 * 1024.0):F1} MB",
|
||||
>= 1024 => $"{ByteSize / 1024.0:F1} KB",
|
||||
_ => $"{ByteSize} B",
|
||||
};
|
||||
}
|
||||
|
||||
public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
Reference in New Issue
Block a user