diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 307e630..8e0bb45 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -182,7 +182,17 @@ "descriptionPlaceholder": "Aufgabendetails hinzufügen (Markdown unterstützt)...", "prepTitle": "Tagesvorbereitung", "planDay": "Tag planen", - "prepEmpty": "Heute noch keine Vorbereitung — klick Tag planen" + "prepEmpty": "Heute noch keine Vorbereitung — klick Tag planen", + "attachments": { + "sectionLabel": "ANHÄNGE", + "dropToAttach": "Zum Anhängen ablegen", + "addFile": "Datei hinzufügen…", + "removeTip": "Anhang entfernen", + "addedSummary": "✓ Hinzugefügt: {0} ({1} Datei(en))", + "overLimitError": "Konnte {0} nicht hinzufügen: {1}", + "invalidNameError": "Konnte {0} nicht hinzufügen: {1}", + "selectIdleTask": "Zuerst eine inaktive Aufgabe auswählen" + } }, "agent": { "stopTip": "Agent stoppen", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 430b947..16f16c0 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -182,7 +182,17 @@ "descriptionPlaceholder": "Add task details (markdown supported)...", "prepTitle": "Daily prep", "planDay": "Plan day", - "prepEmpty": "No prep run today yet — click Plan day" + "prepEmpty": "No prep run today yet — click Plan day", + "attachments": { + "sectionLabel": "ATTACHMENTS", + "dropToAttach": "Drop to attach", + "addFile": "Add file…", + "removeTip": "Remove attachment", + "addedSummary": "✓ Added {0} ({1} file(s))", + "overLimitError": "Could not add {0}: {1}", + "invalidNameError": "Could not add {0}: {1}", + "selectIdleTask": "Select an idle task first" + } }, "agent": { "stopTip": "Stop agent", diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 3fa64be..6a1e613 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -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 Log { get; } = new(); public ObservableCollection Subtasks { get; } = new(); public ObservableCollection ChildOutcomes { get; } = new(); + public ObservableCollection 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(); + var failures = new List(); + + 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; } diff --git a/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml b/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml index 76f69e7..78bc9d1 100644 --- a/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml @@ -161,6 +161,58 @@ + + + + + + + + + + + + + + + + + + + +