From c564954184404498c4e2db0643b87f6c80f83295 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 13 Jul 2026 11:52:33 -0500 Subject: [PATCH] Add a context-menu action to delete a clip to the Recycle Bin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a clip in the sidebar now offers "Delete to Recycle Bin" so a cluttered timeline can be tidied without leaving the app. The whole clip folder is moved to the Windows Recycle Bin (recoverable) after a yes/no confirmation. Deleting the clip that's currently open first stops playback so Flyleaf releases its file handles — Windows can't recycle a folder whose files are still locked. The deleted clip is removed from the sidebar list and from the player's Next/Previous playlist (new ClipPlaylist.RemoveClip, which keeps the current selection pointed at the same clip). The confirm prompt and the shell recycle call are injectable so the view-model logic is unit-tested without touching the real Recycle Bin. --- SentryDeck.Data/Playback/ClipPlaylist.cs | 30 +++++ SentryDeck.Tests/ClipPlaylistTests.cs | 76 ++++++++++++ SentryDeck.Tests/MainWindowViewModelTests.cs | 117 +++++++++++++++++++ SentryDeck/MainWindow.xaml | 9 ++ SentryDeck/MainWindowViewModel.cs | 74 ++++++++++++ SentryDeck/Playback/VideoPlayerController.cs | 7 ++ 6 files changed, 313 insertions(+) diff --git a/SentryDeck.Data/Playback/ClipPlaylist.cs b/SentryDeck.Data/Playback/ClipPlaylist.cs index 5e7258c..d7e41a7 100644 --- a/SentryDeck.Data/Playback/ClipPlaylist.cs +++ b/SentryDeck.Data/Playback/ClipPlaylist.cs @@ -61,6 +61,36 @@ public bool MoveTo(int index) return true; } + /// + /// Removes a clip from the playlist while keeping the current selection pointed at the same + /// clip. Removing the current clip itself clears the selection (the caller owns stopping + /// playback); a clip before the current one shifts the index down to compensate. Returns false + /// when the clip isn't in the playlist. + /// + public bool RemoveClip(CamClip clip) + { + var index = clip is null ? -1 : _clips.IndexOf(clip); + if (index < 0) + { + return false; + } + + _clips.RemoveAt(index); + + if (index < _currentIndex) + { + _currentIndex--; + } + else if (index == _currentIndex) + { + _currentIndex = -1; + } + + Log.Debug("Removed clip from playlist. ClipName={ClipName}; ClipCount={ClipCount}", clip.Name, _clips.Count); + PlaylistChanged?.Invoke(this, EventArgs.Empty); + return true; + } + public void Clear() { SetClips([]); diff --git a/SentryDeck.Tests/ClipPlaylistTests.cs b/SentryDeck.Tests/ClipPlaylistTests.cs index 0026511..f38aa3d 100644 --- a/SentryDeck.Tests/ClipPlaylistTests.cs +++ b/SentryDeck.Tests/ClipPlaylistTests.cs @@ -95,4 +95,80 @@ public void SetClips_RaisesPlaylistAndCurrentClipEvents() playlistChanged.ShouldBe(1); currentChanged.ShouldBe(1); } + + [Fact] + public void RemoveClip_BeforeCurrent_KeepsCurrentClip_AndShiftsIndex() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(3); + playlist.SetClips(clips); + playlist.MoveTo(2); + + var removed = playlist.RemoveClip(clips[0]); + + removed.ShouldBeTrue(); + playlist.Clips.ShouldBe(new[] { clips[1], clips[2] }); + playlist.CurrentIndex.ShouldBe(1); + playlist.CurrentClip.ShouldBe(clips[2]); // same clip, index slid down by one + } + + [Fact] + public void RemoveClip_AfterCurrent_LeavesCurrentAndIndexUntouched() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(3); + playlist.SetClips(clips); + playlist.MoveTo(0); + + playlist.RemoveClip(clips[2]); + + playlist.CurrentIndex.ShouldBe(0); + playlist.CurrentClip.ShouldBe(clips[0]); + playlist.HasNext.ShouldBeTrue(); // clips[1] still follows + } + + [Fact] + public void RemoveClip_TheCurrentClip_ClearsSelection() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(3); + playlist.SetClips(clips); + playlist.MoveTo(1); + + playlist.RemoveClip(clips[1]); + + playlist.CurrentIndex.ShouldBe(-1); + playlist.CurrentClip.ShouldBeNull(); + playlist.Clips.ShouldBe(new[] { clips[0], clips[2] }); + } + + [Fact] + public void RemoveClip_NotInPlaylist_ReturnsFalse_AndChangesNothing() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(2); + playlist.SetClips(clips); + playlist.MoveTo(1); + var stranger = TestClips.Create(1)[0]; + + var removed = playlist.RemoveClip(stranger); + + removed.ShouldBeFalse(); + playlist.Clips.ShouldBe(clips); + playlist.CurrentIndex.ShouldBe(1); + } + + [Fact] + public void RemoveClip_RaisesPlaylistChanged() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(2); + playlist.SetClips(clips); + var playlistChanged = 0; + playlist.PlaylistChanged += (_, _) => playlistChanged++; + + playlist.RemoveClip(clips[0]); + + playlistChanged.ShouldBe(1); + } } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.cs b/SentryDeck.Tests/MainWindowViewModelTests.cs index e00c986..b891925 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.cs @@ -583,6 +583,123 @@ public void ShowOnMap_DisabledWithoutCoordinates() vm.ShowOnMapCommand.CanExecute(withLocation).ShouldBeTrue(); } + // --- Delete to Recycle Bin: the injectable confirm/recycle delegates keep this off the shell --- + + private static List ClipsWithDistinctPaths(int count) => + Enumerable.Range(0, count) + .Select(index => new CamClip( + $@"C:\clips\clip{index}", + $"Clip {index}", + new DateTime(2025, 1, 1, 12, 0, 0).AddMinutes(index), + [], + camEvent: null)) + .ToList(); + + private static async Task LoadedViewModelAsync(IReadOnlyList clips) + { + var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); + await vm.LoadClipsAsync(new[] { "root" }); + return vm; + } + + [Fact] + public void DeleteClipCommand_CanExecute_RequiresAClip() + { + var vm = CreateViewModel(); + + vm.DeleteClipCommand.CanExecute(null).ShouldBeFalse(); + vm.DeleteClipCommand.CanExecute(TestClips.Create(1)[0]).ShouldBeTrue(); + } + + [Fact] + public async Task DeleteClip_Confirmed_RecyclesFolder_AndRemovesFromList() + { + var clips = ClipsWithDistinctPaths(3); + var vm = await LoadedViewModelAsync(clips); + string recycledPath = null; + vm.ConfirmDeleteClip = _ => true; + vm.RecycleClipFolder = path => recycledPath = path; + + var target = vm.FilteredClips.Single(clip => clip.Name == "Clip 1"); + await vm.DeleteClipCommand.ExecuteAsync(target); + + recycledPath.ShouldBe(target.FullPath); + vm.FilteredClips.ShouldNotContain(target); + vm.ClipCount.ShouldBe(2); + } + + [Fact] + public async Task DeleteClip_Cancelled_KeepsClip_AndDoesNotRecycle() + { + var clips = ClipsWithDistinctPaths(2); + var vm = await LoadedViewModelAsync(clips); + var recycleCalls = 0; + vm.ConfirmDeleteClip = _ => false; + vm.RecycleClipFolder = _ => recycleCalls++; + + var target = vm.FilteredClips[0]; + await vm.DeleteClipCommand.ExecuteAsync(target); + + recycleCalls.ShouldBe(0); + vm.ClipCount.ShouldBe(2); + vm.FilteredClips.ShouldContain(target); + } + + [Fact] + public async Task DeleteClip_TheSelectedClip_ClearsSelectionAndNowPlaying() + { + var clips = ClipsWithDistinctPaths(2); + var vm = await LoadedViewModelAsync(clips); + vm.ConfirmDeleteClip = _ => true; + vm.RecycleClipFolder = _ => { }; + + var target = vm.FilteredClips[0]; + vm.SelectedClip = target; // sets NowPlayingClip too (see OnSelectedClipChanged) + vm.NowPlayingClip.ShouldBe(target); + + await vm.DeleteClipCommand.ExecuteAsync(target); + + vm.SelectedClip.ShouldBeNull(); + vm.NowPlayingClip.ShouldBeNull(); + vm.FilteredClips.ShouldNotContain(target); + } + + [Fact] + public async Task DeleteClip_NotTheSelectedClip_LeavesSelectionIntact() + { + var clips = ClipsWithDistinctPaths(3); + var vm = await LoadedViewModelAsync(clips); + vm.ConfirmDeleteClip = _ => true; + vm.RecycleClipFolder = _ => { }; + + var selected = vm.FilteredClips.Single(clip => clip.Name == "Clip 2"); + var victim = vm.FilteredClips.Single(clip => clip.Name == "Clip 0"); + vm.SelectedClip = selected; + + await vm.DeleteClipCommand.ExecuteAsync(victim); + + vm.SelectedClip.ShouldBe(selected); + vm.FilteredClips.ShouldNotContain(victim); + vm.ClipCount.ShouldBe(2); + } + + [Fact] + public async Task DeleteClip_WhenRecycleFails_ShowsError_AndKeepsClip() + { + var clips = ClipsWithDistinctPaths(2); + var vm = await LoadedViewModelAsync(clips); + vm.ConfirmDeleteClip = _ => true; + vm.RecycleClipFolder = _ => throw new IOException("The file is in use."); + + var target = vm.FilteredClips[0]; + await vm.DeleteClipCommand.ExecuteAsync(target); + + vm.ShowErrorOverlay.ShouldBeTrue(); + vm.ErrorTitle.ShouldBe("Delete Failed"); + vm.ClipCount.ShouldBe(2); + vm.FilteredClips.ShouldContain(target); + } + [Fact] public async Task FilteredClips_NoMatch_IsEmpty() { diff --git a/SentryDeck/MainWindow.xaml b/SentryDeck/MainWindow.xaml index 9b59611..28321da 100644 --- a/SentryDeck/MainWindow.xaml +++ b/SentryDeck/MainWindow.xaml @@ -537,6 +537,15 @@ + + + + + + + diff --git a/SentryDeck/MainWindowViewModel.cs b/SentryDeck/MainWindowViewModel.cs index 75f4af8..e8460ff 100644 --- a/SentryDeck/MainWindowViewModel.cs +++ b/SentryDeck/MainWindowViewModel.cs @@ -9,6 +9,7 @@ using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.VisualBasic.FileIO; using Microsoft.Win32; using Serilog; @@ -1029,6 +1030,24 @@ private static string PickSavePathWithDialog(string defaultFileName) internal Action RevealInExplorer { get; set; } = path => Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true }); + /// + /// Asks the user to confirm sending a clip to the Recycle Bin. Returns true to proceed. + /// Overridable for tests; defaults to a yes/no message box. + /// + internal Func ConfirmDeleteClip { get; set; } = clip => + MessageBox.Show( + $"Move this clip to the Recycle Bin?\n\n{clip.Name}\n{clip.FullPath}", + "Delete clip", + MessageBoxButton.YesNo, + MessageBoxImage.Warning) == MessageBoxResult.Yes; + + /// + /// Sends a clip folder to the Windows Recycle Bin (recoverable). Overridable for tests; defaults + /// to the shell recycle operation, which surfaces its own error dialog if a file is in use. + /// + internal Action RecycleClipFolder { get; set; } = path => + FileSystem.DeleteDirectory(path, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + private static string FormatTimeSpanForFileName(TimeSpan ts) => FormatTimeSpan(ts).Replace(':', '.'); private static string SanitizeFileName(string name) @@ -1721,6 +1740,61 @@ private void CopyTimestamp(CamClip clip) } } + /// + /// Sends the clip's folder to the Recycle Bin so the timeline can be tidied without leaving the + /// app. Confirms first, then — if the clip is the one currently open — stops playback so Flyleaf + /// releases its file handles before the shell tries to recycle the (otherwise locked) folder. + /// + [RelayCommand(CanExecute = nameof(CanUseClip))] + private async Task DeleteClipAsync(CamClip clip) + { + if (clip is null || !ConfirmDeleteClip(clip)) + { + return; + } + + // Flyleaf keeps the current clip's camera files open; Windows can't recycle a folder whose + // files are still locked, so stop and close the players before deleting it. + var isCurrent = ReferenceEquals(SelectedClip, clip) + || ReferenceEquals(NowPlayingClip, clip) + || _playerController?.CurrentClip == clip; + if (isCurrent && _playerController is not null) + { + await _playerController.StopAsync(); + SeekPosition = 0; + } + + try + { + Log.Information("Deleting clip to Recycle Bin. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath); + await Task.Run(() => RecycleClipFolder(clip.FullPath)); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to delete clip. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath); + ShowError("Delete Failed", $"Could not delete clip: {clip.Name}\n\nError: {ex.Message}"); + return; + } + + // Drop it from the sidebar list and keep the player's Next/Previous playlist in sync. + _allClips.Remove(clip); + _playerController?.RemoveClip(clip); + + if (ReferenceEquals(NowPlayingClip, clip)) + { + NowPlayingClip = null; + } + + if (ReferenceEquals(SelectedClip, clip)) + { + SelectedClip = null; + } + + OnPropertyChanged(nameof(FilteredClips)); + OnPropertyChanged(nameof(ClipCount)); + RefreshClipState(); + } + [RelayCommand(CanExecute = nameof(CanShowOnMap))] private void ShowOnMap(CamClip clip) { diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index 97bc4d0..a6d48f0 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -385,6 +385,13 @@ public void LoadClips(IEnumerable clips) Playlist.SetClips(clips); } + /// + /// Drops a single clip from the playlist so Next/Previous navigation stays aligned with a + /// trimmed clip list (e.g. after the user deletes a clip). Does not touch what's playing; + /// when the removed clip is the current one the caller is responsible for having stopped it. + /// + public void RemoveClip(CamClip clip) => Playlist.RemoveClip(clip); + public void Dispose() { if (_isDisposed)