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:
@@ -182,7 +182,17 @@
|
|||||||
"descriptionPlaceholder": "Aufgabendetails hinzufügen (Markdown unterstützt)...",
|
"descriptionPlaceholder": "Aufgabendetails hinzufügen (Markdown unterstützt)...",
|
||||||
"prepTitle": "Tagesvorbereitung",
|
"prepTitle": "Tagesvorbereitung",
|
||||||
"planDay": "Tag planen",
|
"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": {
|
"agent": {
|
||||||
"stopTip": "Agent stoppen",
|
"stopTip": "Agent stoppen",
|
||||||
|
|||||||
@@ -182,7 +182,17 @@
|
|||||||
"descriptionPlaceholder": "Add task details (markdown supported)...",
|
"descriptionPlaceholder": "Add task details (markdown supported)...",
|
||||||
"prepTitle": "Daily prep",
|
"prepTitle": "Daily prep",
|
||||||
"planDay": "Plan day",
|
"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": {
|
"agent": {
|
||||||
"stopTip": "Stop agent",
|
"stopTip": "Stop agent",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using ClaudeDo.Ui.Services.Interfaces;
|
|||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
@@ -138,7 +139,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
public string ComposedPreview =>
|
public string ComposedPreview =>
|
||||||
ClaudeDo.Data.TaskPromptComposer.Compose(
|
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]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
|
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
|
||||||
@@ -257,6 +261,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
OnPropertyChanged(nameof(ShowRoadblockCard));
|
OnPropertyChanged(nameof(ShowRoadblockCard));
|
||||||
AgentSettings.IsRunning = IsRunning;
|
AgentSettings.IsRunning = IsRunning;
|
||||||
NotifySessionSections();
|
NotifySessionSections();
|
||||||
|
OnPropertyChanged(nameof(CanAcceptDrop));
|
||||||
}
|
}
|
||||||
|
|
||||||
[ObservableProperty] private string? _model;
|
[ObservableProperty] private string? _model;
|
||||||
@@ -293,6 +298,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
public ObservableCollection<LogLineViewModel> Log { get; } = new();
|
public ObservableCollection<LogLineViewModel> Log { get; } = new();
|
||||||
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
|
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
|
||||||
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { 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 bool HasChildOutcomes => ChildOutcomes.Count > 0;
|
||||||
|
|
||||||
public int ChildrenNeedingAttention => ChildOutcomes.Count(c =>
|
public int ChildrenNeedingAttention => ChildOutcomes.Count(c =>
|
||||||
@@ -601,6 +612,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Log.Clear();
|
Log.Clear();
|
||||||
Subtasks.Clear();
|
Subtasks.Clear();
|
||||||
ChildOutcomes.Clear();
|
ChildOutcomes.Clear();
|
||||||
|
Attachments.Clear();
|
||||||
|
DropStatus = null;
|
||||||
OnPropertyChanged(nameof(HasChildOutcomes));
|
OnPropertyChanged(nameof(HasChildOutcomes));
|
||||||
SessionOutcome = null;
|
SessionOutcome = null;
|
||||||
Roadblocks = null;
|
Roadblocks = null;
|
||||||
@@ -683,6 +696,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
foreach (var s in subs)
|
foreach (var s in subs)
|
||||||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
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)
|
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
|
||||||
await LoadPlanningChildrenAsync(row.Id, ct);
|
await LoadPlanningChildrenAsync(row.Id, ct);
|
||||||
await LoadChildOutcomesAsync(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);
|
Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
|
||||||
NotifySessionSections();
|
NotifySessionSections();
|
||||||
|
OnPropertyChanged(nameof(CanAcceptDrop));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1196,6 +1216,106 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
catch { /* stale review action; broadcast reconciles */ }
|
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)
|
internal static (int Additions, int Deletions) ParseDiffStat(string? stat)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(stat)) return (0, 0);
|
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 sealed partial class SubtaskRowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
|
|||||||
@@ -161,6 +161,58 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Attachments section -->
|
||||||
|
<Border BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
Padding="0,8,0,0">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="section-label" Text="{loc:Tr details.attachments.sectionLabel}"/>
|
||||||
|
|
||||||
|
<!-- Attachment rows -->
|
||||||
|
<ItemsControl ItemsSource="{Binding Attachments}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:AttachmentRowViewModel">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2,0,2">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding FileName}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="{StaticResource FontSizeBody}"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding SizeText}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="8,0"
|
||||||
|
FontSize="{StaticResource FontSizeBody}"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Classes="icon-btn"
|
||||||
|
ToolTip.Tip="{loc:Tr details.attachments.removeTip}"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).RemoveAttachmentCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<PathIcon Data="{StaticResource Icon.X}" Width="11" Height="11"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- Add file button -->
|
||||||
|
<Button Classes="btn"
|
||||||
|
Padding="8,3"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Click="OnAddFileClick"
|
||||||
|
Content="{loc:Tr details.attachments.addFile}"/>
|
||||||
|
|
||||||
|
<!-- Drop status / confirmation -->
|
||||||
|
<TextBlock Text="{Binding DropStatus}"
|
||||||
|
IsVisible="{Binding DropStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||||
|
FontSize="{StaticResource FontSizeBody}"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Input.Platform;
|
using Avalonia.Input.Platform;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
||||||
@@ -34,4 +35,32 @@ public partial class DescriptionStepsCard : UserControl
|
|||||||
&& vm.CommitSubtaskEditCommand.CanExecute(row))
|
&& vm.CommitSubtaskEditCommand.CanExecute(row))
|
||||||
vm.CommitSubtaskEditCommand.Execute(row);
|
vm.CommitSubtaskEditCommand.Execute(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnAddFileClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not DetailsIslandViewModel vm) return;
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
if (topLevel is null) return;
|
||||||
|
|
||||||
|
var picked = await topLevel.StorageProvider.OpenFilePickerAsync(
|
||||||
|
new FilePickerOpenOptions { AllowMultiple = true });
|
||||||
|
|
||||||
|
if (picked.Count == 0) return;
|
||||||
|
|
||||||
|
var files = new List<(string FileName, System.IO.Stream Content)>();
|
||||||
|
foreach (var item in picked)
|
||||||
|
{
|
||||||
|
var stream = await item.OpenReadAsync();
|
||||||
|
files.Add((item.Name, stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await vm.AddFilesAsync(files);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var (_, s) in files) await s.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
xmlns:detail="using:ClaudeDo.Ui.Views.Islands.Detail"
|
xmlns:detail="using:ClaudeDo.Ui.Views.Islands.Detail"
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
|
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
|
||||||
x:DataType="vm:DetailsIslandViewModel">
|
x:DataType="vm:DetailsIslandViewModel"
|
||||||
|
DragDrop.AllowDrop="True">
|
||||||
|
<Panel>
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
|
|
||||||
<!-- ── Metadata footer (sticky bottom) — created-at + close — task detail only ── -->
|
<!-- ── Metadata footer (sticky bottom) — created-at + close — task detail only ── -->
|
||||||
@@ -124,4 +126,21 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Drop overlay — shown while dragging files over the pane -->
|
||||||
|
<Border IsVisible="{Binding IsDragOver}"
|
||||||
|
Background="{DynamicResource AccentSoftBrush}"
|
||||||
|
BorderBrush="{DynamicResource AccentBrush}"
|
||||||
|
BorderThickness="2"
|
||||||
|
CornerRadius="14"
|
||||||
|
IsHitTestVisible="False">
|
||||||
|
<TextBlock Text="{loc:Tr details.attachments.dropToAttach}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource AccentBrush}"
|
||||||
|
FontSize="16"
|
||||||
|
FontWeight="Medium"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Panel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Reactive;
|
using Avalonia.Reactive;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using ClaudeDo.Ui.Views.Modals;
|
using ClaudeDo.Ui.Views.Modals;
|
||||||
@@ -27,6 +29,78 @@ public partial class DetailsIslandView : UserControl
|
|||||||
// row Min/Max during a drag, so the console stops shrinking at 1/3.
|
// row Min/Max during a drag, so the console stops shrinking at 1/3.
|
||||||
DetailBodyGrid.GetObservable(BoundsProperty)
|
DetailBodyGrid.GetObservable(BoundsProperty)
|
||||||
.Subscribe(new AnonymousObserver<Rect>(_ => UpdateRowLimits()));
|
.Subscribe(new AnonymousObserver<Rect>(_ => UpdateRowLimits()));
|
||||||
|
|
||||||
|
AddHandler(DragDrop.DragEnterEvent, OnDragEnter);
|
||||||
|
AddHandler(DragDrop.DragOverEvent, OnDragOver);
|
||||||
|
AddHandler(DragDrop.DragLeaveEvent, OnDragLeave);
|
||||||
|
AddHandler(DragDrop.DropEvent, OnDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsFilesDrop(DragEventArgs e)
|
||||||
|
=> e.DataTransfer?.Contains(DataFormat.File) == true;
|
||||||
|
|
||||||
|
private void OnDragEnter(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (_vm is { CanAcceptDrop: true } && IsFilesDrop(e))
|
||||||
|
{
|
||||||
|
e.DragEffects = DragDropEffects.Copy;
|
||||||
|
_vm.IsDragOver = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e.DragEffects = DragDropEffects.None;
|
||||||
|
}
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDragOver(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (_vm is { CanAcceptDrop: true } && IsFilesDrop(e))
|
||||||
|
{
|
||||||
|
e.DragEffects = DragDropEffects.Copy;
|
||||||
|
_vm.IsDragOver = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e.DragEffects = DragDropEffects.None;
|
||||||
|
}
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDragLeave(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_vm != null) _vm.IsDragOver = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnDrop(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (_vm != null) _vm.IsDragOver = false;
|
||||||
|
if (_vm is not { CanAcceptDrop: true } || !IsFilesDrop(e)) return;
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
var items = e.DataTransfer.TryGetFiles();
|
||||||
|
if (items is null) return;
|
||||||
|
|
||||||
|
var files = new List<(string FileName, System.IO.Stream Content)>();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (item is IStorageFile sf)
|
||||||
|
{
|
||||||
|
var stream = await sf.OpenReadAsync();
|
||||||
|
files.Add((sf.Name, stream));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.Count == 0) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _vm.AddFilesAsync(files);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var (_, s) in files) await s.DisposeAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateRowLimits()
|
private void UpdateRowLimits()
|
||||||
|
|||||||
Reference in New Issue
Block a user