From 189fc363b5bb25258380b4a37ab717aa079176cd Mon Sep 17 00:00:00 2001 From: Sean Shaffer Date: Tue, 3 Nov 2026 00:09:44 -0600 Subject: [PATCH 1/2] Round 10A: Recycle Bin as a browsable virtual location --- docs/CHANGELOG.md | 2 + docs/ROADMAP.md | 3 +- src/Meridian.App/MainWindow.BatchRename.cs | 5 + src/Meridian.App/MainWindow.Clipboard.cs | 4 +- src/Meridian.App/MainWindow.Preview.cs | 13 + src/Meridian.App/MainWindow.RecycleBin.cs | 250 +++++++++++++++++++ src/Meridian.App/MainWindow.Sidebar.cs | 4 +- src/Meridian.App/MainWindow.Tabs.cs | 4 +- src/Meridian.App/MainWindow.Templates.cs | 2 +- src/Meridian.App/MainWindow.Transfers.cs | 11 +- src/Meridian.App/MainWindow.xaml | 6 + src/Meridian.App/MainWindow.xaml.cs | 120 ++++++++- src/Meridian.App/Services/WorkspaceStore.cs | 4 +- src/Meridian.Core/Models/FileEntry.cs | 7 + src/Meridian.Core/Services/RecycleBin.cs | 121 +++++++++ tests/Meridian.Core.Tests/RecycleBinTests.cs | 113 +++++++++ 16 files changed, 649 insertions(+), 20 deletions(-) create mode 100644 src/Meridian.App/MainWindow.RecycleBin.cs create mode 100644 src/Meridian.Core/Services/RecycleBin.cs create mode 100644 tests/Meridian.Core.Tests/RecycleBinTests.cs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1127a2b..880d356 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- The Recycle Bin is now browsable like a folder: a Places entry in the sidebar opens it in a pane, showing every item in the signed-in user's bin across all drives with its original location and deletion time. Items can be restored to where they were deleted from (with the name numbered if something already sits there), deleted permanently after a confirmation, or the whole bin emptied; file operations that cannot apply to recycled items step aside while a pane shows the bin, and a tab left on the bin reopens on it. + - The dark theme is now charcoal with a crimson accent: layered charcoal surfaces (#232323 window, #2B2B2B panels, #333333 raised areas, #404040 borders) replace the near-black ones, the default accent is crimson (#DC143C, listed as "Crimson" in Settings; a saved "Red" choice lands on it), the panel hairlines carry the same crimson, and text on the accent is now white. The red-gray selection, green readouts, and the light theme's surfaces are unchanged. - Each pane now keeps its own folder tabs, living in the path-bar area: inactive tabs sit as compact chips above the bar, the active tab is the breadcrumb bar itself, and new-tab and close-tab buttons sit at the bar's right end. Every tab remembers its own back/forward history, the session restores both panes' tabs, and the shortcuts are Ctrl+T (new tab), Ctrl+W (close tab) and Ctrl+Tab / Ctrl+Shift+Tab (cycle); middle-clicking a chip closes that tab. Closed tabs are remembered (the last 50, with their history) and Ctrl+Shift+T reopens the most recently closed one. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0f8c4c8..3c36036 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,7 +10,7 @@ See [STATUS.md](STATUS.md) for the dated implemented/CI/runtime inventory. Check Meridian's feature target is KDE's Dolphin, adapted to Windows. Already at parity: dual-pane (split) browsing, per-pane tabs with closed-tab history, clickable breadcrumbs, view modes remembered per folder, a Places-style sidebar, previews, batch rename, sorting and grouping, and content search. The remaining parity work, in planned order: -1. **Virtual locations** — one location layer under the panes, so the Recycle Bin, recent files, search results, and archives browse like folders (builds on Phase 6's archive-as-folder and virtual-view items). +1. **Virtual locations** — one location layer under the panes, so the Recycle Bin, recent files, search results, and archives browse like folders (builds on Phase 6's archive-as-folder and virtual-view items). _Started: the Recycle Bin browses as a virtual location with restore, permanent delete, and empty._ 2. **Indexed search** — a USN-journal-backed index for instant name search, growing toward content (Phase 4). 3. **Embedded terminal panel** that follows the current folder (Phase 7). 4. **Tags, ratings, and comments** stored locally and searchable, with optional NTFS alternate-data-stream storage investigated for portability (Phase 2). @@ -222,6 +222,7 @@ Avoid starting large Phase 2/3 experiments until these release blockers are reso - [ ] Improve Android MTP/PTP and supported Apple-device browsing/transfers where Windows APIs permit - [ ] WSL distribution navigation and \wsl$ support; investigate safe read/browse support for EXT4 VHDX images (distribution navigation and \wsl$ browsing delivered; EXT4 VHDX investigation remains) - [ ] Investigate virtual views for Windows Registry hives and running processes, with strict read-only defaults and prominent warnings +- [x] Recycle Bin as a browsable virtual location, with restore, permanent delete, and empty (the first virtual location; Registry/process views remain under the investigation item above) ## Phase 7 — Developer and power-user tools diff --git a/src/Meridian.App/MainWindow.BatchRename.cs b/src/Meridian.App/MainWindow.BatchRename.cs index 0bb4658..fac94bf 100644 --- a/src/Meridian.App/MainWindow.BatchRename.cs +++ b/src/Meridian.App/MainWindow.BatchRename.cs @@ -29,6 +29,11 @@ public sealed partial class MainWindow /// single-item rename exactly as it was, warning included. private async Task RenameEntryAsync(PaneState pane) { + if (pane.IsRecycleBin) + { + SetStatus("Recycled items keep the name they were deleted with — restore an item to rename it.", StatusSeverity.Warning); + return; + } if (pane.SelectionList.SelectedItems.Count >= 2) await BatchRenameAsync(pane); else diff --git a/src/Meridian.App/MainWindow.Clipboard.cs b/src/Meridian.App/MainWindow.Clipboard.cs index e0ec292..9aec698 100644 --- a/src/Meridian.App/MainWindow.Clipboard.cs +++ b/src/Meridian.App/MainWindow.Clipboard.cs @@ -103,7 +103,7 @@ private void ApplyCutDimming() /// private async Task PasteClipboardAsFileAsync(PaneState pane) { - if (pane.IsHome || string.IsNullOrWhiteSpace(pane.CurrentPath)) + if (pane.IsHome || pane.IsRecycleBin || string.IsNullOrWhiteSpace(pane.CurrentPath)) { SetStatus("Open a folder to paste into.", StatusSeverity.Warning); return; @@ -195,7 +195,7 @@ private async Task SetClipboardFromPaneAsync(PaneState pane, bool cut) private async Task PasteIntoPaneAsync(PaneState pane) { - if (pane.IsHome || string.IsNullOrWhiteSpace(pane.CurrentPath)) + if (pane.IsHome || pane.IsRecycleBin || string.IsNullOrWhiteSpace(pane.CurrentPath)) { SetStatus("Open a folder to paste into.", StatusSeverity.Warning); return; diff --git a/src/Meridian.App/MainWindow.Preview.cs b/src/Meridian.App/MainWindow.Preview.cs index 5c6c9cc..c4e94a1 100644 --- a/src/Meridian.App/MainWindow.Preview.cs +++ b/src/Meridian.App/MainWindow.Preview.cs @@ -158,6 +158,13 @@ private async Task PopulatePreviewPanelAsync(FileEntry selected, StackPanel pane private async Task OpenSelectedAsync(PaneState pane) { if (pane.SelectionList.SelectedItem is not FileEntry selected) return; + if (pane.IsRecycleBin) + { + // Opening a recycled item restores it — its bin data path is + // never launched or navigated into directly. + await RestoreSelectedAsync(pane); + return; + } if (selected.IsDirectory) { // In the columns layout a selected folder is already cascaded into @@ -186,6 +193,11 @@ private async Task MoveSelectedAsync(PaneState source, PaneState destination) SetStatus("Open a folder in the other pane first — Home is not a move destination.", StatusSeverity.Warning); return; } + if (source.IsRecycleBin || destination.IsRecycleBin) + { + SetStatus("Recycled items cannot be moved directly — restore an item to bring it back, or delete it permanently.", StatusSeverity.Warning); + return; + } if (string.IsNullOrWhiteSpace(destination.CurrentPath)) return; var confirmedPaths = selection.Select(item => item.FullPath).ToArray(); @@ -243,6 +255,7 @@ private async Task CopySelectedAsync(PaneState source, PaneState destination) var selection = GetSelectedEntries(source); if (selection.Count == 0) { SetStatus($"Select an item in the {source.Name.ToLowerInvariant()} pane first.", StatusSeverity.Warning); return; } if (destination.IsHome) { SetStatus("Open a folder in the other pane first \u2014 Home is not a copy destination.", StatusSeverity.Warning); return; } + if (source.IsRecycleBin || destination.IsRecycleBin) { SetStatus("Recycled items cannot be copied directly — restore an item to bring it back, or delete it permanently.", StatusSeverity.Warning); return; } if (string.IsNullOrWhiteSpace(destination.CurrentPath)) return; var sourcePaths = selection.Select(item => item.FullPath).ToArray(); diff --git a/src/Meridian.App/MainWindow.RecycleBin.cs b/src/Meridian.App/MainWindow.RecycleBin.cs new file mode 100644 index 0000000..9949f22 --- /dev/null +++ b/src/Meridian.App/MainWindow.RecycleBin.cs @@ -0,0 +1,250 @@ +using System.Security.Principal; +using Meridian.Models; +using Meridian.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Meridian.App; + +/// +/// Recycle Bin browsing: a pane can navigate to the bin sentinel and see +/// every item in the signed-in user's bin across all ready drives, newest +/// deletion first. Entries carry their original path; Restore moves an +/// item back (numbering the name when something already sits there), +/// Delete permanently removes the data for good, and Empty clears the +/// whole bin. All three act on the real bin files through the Core +/// scanner's pairing, so what Meridian shows is exactly what Windows +/// holds. +/// +public sealed partial class MainWindow +{ + private async Task NavigateRecycleBinAsync(PaneState pane, bool addHistory = true) + { + CancelFolderSizeMeasurement(pane); + pane.ReadCancellation?.Cancel(); + pane.ReadCancellation?.Dispose(); + pane.ReadCancellation = new CancellationTokenSource(); + var token = pane.ReadCancellation.Token; + if (addHistory && !string.IsNullOrWhiteSpace(pane.CurrentPath) && + !string.Equals(pane.CurrentPath, RecycleBinPath, StringComparison.OrdinalIgnoreCase)) + { + pane.BackHistory.Push(pane.CurrentPath); + pane.ForwardHistory.Clear(); + } + pane.CurrentPath = RecycleBinPath; + ResetFind(pane); + SetPaneStatus(pane, "Reading the Recycle Bin…"); + IReadOnlyList recycled; + try + { + recycled = await Task.Run(ScanRecycleBin, token); + } + catch (OperationCanceledException) { return false; } + if (token.IsCancellationRequested) return false; + var entries = recycled.Select(RecycledEntryFor).ToArray(); + RebuildBreadcrumbs(pane); + UpdatePaneHeader(pane); + pane.AllItems = entries; + ApplyFilter(pane); + ApplyPaneMode(pane); + ClearColumns(pane); + RefreshSidebarSelection(); + RefreshPreviewBlock(pane); + var totalBytes = recycled.Sum(item => item.SizeBytes); + SetPaneStatus(pane, entries.Length == 0 + ? "Recycle Bin · empty" + : $"Recycle Bin · {entries.Length:N0} items · {DirectoryReader.FormatSize(totalBytes)}"); + SetStatus("Ready · Local-only · No telemetry"); + SyncActiveTabFromPane(pane); + RebuildTabStrip(pane); + if (!_restoringSession) await PersistSessionAsync(); + UpdatePaneWatch(pane); + return true; + } + + private static IReadOnlyList ScanRecycleBin() + { + var sid = WindowsIdentity.GetCurrent().User?.Value; + if (string.IsNullOrEmpty(sid)) return Array.Empty(); + var all = new List(); + foreach (var drive in DriveInfo.GetDrives()) + { + if (!drive.IsReady) continue; + all.AddRange(RecycleBinScanner.Scan(RecycleBinScanner.BinFolderFor(drive.RootDirectory.FullName, sid))); + } + all.Sort((a, b) => b.DeletedUtc.CompareTo(a.DeletedUtc)); + return all; + } + + private static FileEntry RecycledEntryFor(RecycledItem item) + { + var original = item.OriginalPath; + var cut = original.LastIndexOf('\\'); + var name = cut >= 0 && cut < original.Length - 1 ? original[(cut + 1)..] : original; + var dot = name.LastIndexOf('.'); + var type = item.IsDirectory ? "Folder" : dot > 0 ? $"{name[(dot + 1)..].ToUpperInvariant()} file" : "File"; + return new FileEntry + { + Name = name, + FullPath = item.DataPath, + IsDirectory = item.IsDirectory, + TypeLabel = type, + SizeLabel = item.IsDirectory ? "—" : DirectoryReader.FormatSize(item.SizeBytes), + SizeBytes = item.IsDirectory ? 0 : item.SizeBytes, + ModifiedUtc = item.DeletedUtc, + ModifiedLabel = item.DeletedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"), + AgeLabel = RelativeAge.Format(item.DeletedUtc, DateTime.UtcNow), + OriginalPath = original + }; + } + + /// The $I info file paired with a bin entry's data file. + private static string InfoPathFor(FileEntry entry) => + Path.Combine(Path.GetDirectoryName(entry.FullPath)!, "$I" + Path.GetFileName(entry.FullPath)[2..]); + + private async Task RestoreSelectedAsync(PaneState pane) + { + var selection = GetSelectedEntries(pane); + if (selection.Count == 0) + { + SetStatus("Select a recycled item to restore.", StatusSeverity.Warning); + return; + } + var restored = 0; + var failed = new List(); + await Task.Run(() => + { + foreach (var entry in selection) + { + try + { + var target = entry.OriginalPath!; + var parent = Path.GetDirectoryName(target); + if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(parent); + if (File.Exists(target) || Directory.Exists(target)) + target = FreeRestoredName(target); + if (entry.IsDirectory) Directory.Move(entry.FullPath, target); + else File.Move(entry.FullPath, target); + File.Delete(InfoPathFor(entry)); + restored++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + failed.Add($"{entry.Name}: {ex.Message}"); + } + } + }); + if (failed.Count == 0) + SetStatus($"Restored {restored:N0} item{(restored == 1 ? "" : "s")} from the Recycle Bin.", StatusSeverity.Success); + else + SetStatus($"Restored {restored:N0}; {failed.Count:N0} could not be restored · {failed[0]}", StatusSeverity.Error); + await NavigateRecycleBinAsync(pane, addHistory: false); + } + + /// "name (restored).ext", then "name (restored 2).ext", while the plain name is taken. + private static string FreeRestoredName(string target) + { + var parent = Path.GetDirectoryName(target)!; + var name = Path.GetFileNameWithoutExtension(target); + var extension = Path.GetExtension(target); + for (var n = 1; ; n++) + { + var suffix = n == 1 ? " (restored)" : $" (restored {n})"; + var candidate = Path.Combine(parent, name + suffix + extension); + if (!File.Exists(candidate) && !Directory.Exists(candidate)) return candidate; + } + } + + private async Task DeleteRecycledPermanentlyAsync(PaneState pane) + { + var selection = GetSelectedEntries(pane); + if (selection.Count == 0) + { + SetStatus("Select a recycled item to delete permanently.", StatusSeverity.Warning); + return; + } + var dialog = new ContentDialog + { + Title = "Delete permanently?", + Content = selection.Count == 1 + ? $"Delete \"{selection[0].Name}\" for good?\n\n{selection[0].OriginalPath}\n\nThis cannot be undone." + : $"Delete {selection.Count:N0} recycled items for good?\n\nThis cannot be undone.", + PrimaryButtonText = "Delete permanently", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = RootGrid.XamlRoot + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + var deleted = 0; + var failed = new List(); + await Task.Run(() => + { + foreach (var entry in selection) + { + try + { + if (entry.IsDirectory) + { + // Recycled folders can hold read-only files; clear + // the flags so the recursive delete can finish. + foreach (var file in Directory.EnumerateFiles(entry.FullPath, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(entry.FullPath, recursive: true); + } + else + { + File.SetAttributes(entry.FullPath, FileAttributes.Normal); + File.Delete(entry.FullPath); + } + File.Delete(InfoPathFor(entry)); + deleted++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + failed.Add($"{entry.Name}: {ex.Message}"); + } + } + }); + if (failed.Count == 0) + SetStatus($"Deleted {deleted:N0} item{(deleted == 1 ? "" : "s")} permanently.", StatusSeverity.Success); + else + SetStatus($"Deleted {deleted:N0}; {failed.Count:N0} could not be deleted · {failed[0]}", StatusSeverity.Error); + await NavigateRecycleBinAsync(pane, addHistory: false); + } + + private async void EmptyRecycleBin_Click(object sender, RoutedEventArgs e) + { + var pane = ReferenceEquals(sender, LeftEmptyBinMenuItem) ? _left : _right; + var dialog = new ContentDialog + { + Title = "Empty the Recycle Bin?", + Content = "Every item in the Recycle Bin, on every drive, is deleted for good.\n\nThis cannot be undone.", + PrimaryButtonText = "Empty Recycle Bin", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = RootGrid.XamlRoot + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + await Task.Run(() => + { + foreach (var item in ScanRecycleBin()) + { + try + { + if (item.IsDirectory) Directory.Delete(item.DataPath, recursive: true); + else File.Delete(item.DataPath); + File.Delete(item.InfoPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { /* reported by the refresh below */ } + } + }); + SetStatus("Recycle Bin emptied.", StatusSeverity.Success); + if (_left.IsRecycleBin) await NavigateRecycleBinAsync(_left, addHistory: false); + if (_right.IsRecycleBin) await NavigateRecycleBinAsync(_right, addHistory: false); + } + + private async void RestoreRecycledFromLeft_Click(object sender, RoutedEventArgs e) => await RestoreSelectedAsync(_left); + private async void RestoreRecycledFromRight_Click(object sender, RoutedEventArgs e) => await RestoreSelectedAsync(_right); + private async void DeleteRecycledFromLeft_Click(object sender, RoutedEventArgs e) => await DeleteRecycledPermanentlyAsync(_left); + private async void DeleteRecycledFromRight_Click(object sender, RoutedEventArgs e) => await DeleteRecycledPermanentlyAsync(_right); +} diff --git a/src/Meridian.App/MainWindow.Sidebar.cs b/src/Meridian.App/MainWindow.Sidebar.cs index dc97648..1ab1c14 100644 --- a/src/Meridian.App/MainWindow.Sidebar.cs +++ b/src/Meridian.App/MainWindow.Sidebar.cs @@ -42,6 +42,7 @@ private void BuildSidebar() AddSidebarItem(Symbol.Audio, "Music", KnownFolderPath(Environment.SpecialFolder.MyMusic), "Open the Music folder in the active pane"); AddSidebarItem(Symbol.Pictures, "Pictures", KnownFolderPath(Environment.SpecialFolder.MyPictures), "Open the Pictures folder in the active pane"); AddSidebarItem(Symbol.Video, "Videos", KnownFolderPath(Environment.SpecialFolder.MyVideos), "Open the Videos folder in the active pane"); + AddSidebarItem(Symbol.Delete, "Recycle Bin", RecycleBinPath, "Browse the Recycle Bin in the active pane — restore items or delete them for good"); AddSidebarSection("Favorites"); // Ungrouped favorites first (in saved order, exactly as the flat // list always showed them), then each group under its header. @@ -541,7 +542,8 @@ private void AddSidebarItem(Symbol glyph, string label, string targetPath, strin AutomationProperties.SetName(button, label); if (contextFlyout is not null) button.ContextFlyout = contextFlyout; button.Click += SidebarItem_Click; - if (!string.Equals(targetPath, HomePath, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(targetPath, HomePath, StringComparison.OrdinalIgnoreCase) && + !string.Equals(targetPath, RecycleBinPath, StringComparison.OrdinalIgnoreCase)) { button.AllowDrop = true; button.DragOver += SidebarItem_DragOver; diff --git a/src/Meridian.App/MainWindow.Tabs.cs b/src/Meridian.App/MainWindow.Tabs.cs index 8c45251..96b61e3 100644 --- a/src/Meridian.App/MainWindow.Tabs.cs +++ b/src/Meridian.App/MainWindow.Tabs.cs @@ -51,7 +51,9 @@ private void RebuildTabStrip(PaneState pane) if (i == set.ActiveIndex) continue; var index = i; var tab = set.Tabs[i]; - var label = PaneTabSet.LabelFor(tab.Path, HomePath); + var label = string.Equals(tab.Path, RecycleBinPath, StringComparison.OrdinalIgnoreCase) + ? "Recycle Bin" + : PaneTabSet.LabelFor(tab.Path, HomePath); var chip = new Button { Content = label, Padding = new Thickness(8, 2, 8, 2), FontSize = 12 }; ToolTipService.SetToolTip(chip, tab.Path + " — open this tab (middle-click closes it)"); AutomationProperties.SetName(chip, $"Tab {label}, {pane.Name} pane"); diff --git a/src/Meridian.App/MainWindow.Templates.cs b/src/Meridian.App/MainWindow.Templates.cs index 0de5cd5..7405a20 100644 --- a/src/Meridian.App/MainWindow.Templates.cs +++ b/src/Meridian.App/MainWindow.Templates.cs @@ -82,7 +82,7 @@ private void EnsureTemplatesFolder() private async Task CreateFromTemplateAsync(PaneState pane, string templatePath) { - if (pane.IsHome || string.IsNullOrWhiteSpace(pane.CurrentPath)) + if (pane.IsHome || pane.IsRecycleBin || string.IsNullOrWhiteSpace(pane.CurrentPath)) { SetStatus("Open a folder to create the file in.", StatusSeverity.Warning); return; diff --git a/src/Meridian.App/MainWindow.Transfers.cs b/src/Meridian.App/MainWindow.Transfers.cs index 43863c3..4cbe488 100644 --- a/src/Meridian.App/MainWindow.Transfers.cs +++ b/src/Meridian.App/MainWindow.Transfers.cs @@ -570,6 +570,14 @@ private void PaneList_DragItemsStarting(object sender, DragItemsStartingEventArg string? sourceFolder = null; if (ReferenceEquals(sender, LeftItems)) { pane = _left; sourceFolder = _left.CurrentPath; } else if (ReferenceEquals(sender, RightItems)) { pane = _right; sourceFolder = _right.CurrentPath; } + // Recycled items are never dragged: their paths point inside the + // bin's storage, and a drop elsewhere would scatter $R-named files. + if (pane is not null && pane.IsRecycleBin) + { + e.Cancel = true; + SetStatus("Recycled items cannot be dragged out — restore an item to bring it back.", StatusSeverity.Warning); + return; + } else { foreach (var candidate in new[] { _left, _right }) @@ -728,7 +736,8 @@ private async Task HandleDropAsync(DragEventArgs e, string backgroundDestination if (list is not null && TryHitFolderRow(list, e, out var folderRow)) destination = folderRow; if (string.IsNullOrWhiteSpace(destination) - || string.Equals(destination, HomePath, StringComparison.OrdinalIgnoreCase)) + || string.Equals(destination, HomePath, StringComparison.OrdinalIgnoreCase) + || string.Equals(destination, RecycleBinPath, StringComparison.OrdinalIgnoreCase)) return null; var sources = await ResolveDragSourcePathsAsync(e); diff --git a/src/Meridian.App/MainWindow.xaml b/src/Meridian.App/MainWindow.xaml index f6cfa9f..a8c331b 100644 --- a/src/Meridian.App/MainWindow.xaml +++ b/src/Meridian.App/MainWindow.xaml @@ -378,6 +378,9 @@ + + + @@ -521,6 +524,9 @@ + + + diff --git a/src/Meridian.App/MainWindow.xaml.cs b/src/Meridian.App/MainWindow.xaml.cs index bd858a5..c17e313 100644 --- a/src/Meridian.App/MainWindow.xaml.cs +++ b/src/Meridian.App/MainWindow.xaml.cs @@ -26,6 +26,14 @@ public sealed partial class MainWindow : Window /// private const string HomePath = "meridian://home"; + /// + /// The sentinel a pane's CurrentPath holds while it browses the + /// Recycle Bin. Like the Home sentinel it is not a filesystem path; + /// unlike Home it stays valid in saved sessions and tabs, so a pane + /// left on the bin reopens on the bin. + /// + private const string RecycleBinPath = "meridian://recycle-bin"; + private readonly PaneState _left; private readonly PaneState _right; private readonly WorkspaceStore _workspaceStore = new(); @@ -258,9 +266,13 @@ private async Task InitializeAsync(string documents, string downloads) // active tab whose folder has gone away since the save). _left.Tabs = PaneTabSet.Restore(session?.LeftTabs, session?.LeftActiveTabIndex ?? 0, leftPath); _right.Tabs = PaneTabSet.Restore(session?.RightTabs, session?.RightActiveTabIndex ?? 0, rightPath); - if (!string.Equals(_left.Tabs.Active.Path, HomePath, StringComparison.OrdinalIgnoreCase) && !Directory.Exists(_left.Tabs.Active.Path)) + if (!string.Equals(_left.Tabs.Active.Path, HomePath, StringComparison.OrdinalIgnoreCase) && + !string.Equals(_left.Tabs.Active.Path, RecycleBinPath, StringComparison.OrdinalIgnoreCase) && + !Directory.Exists(_left.Tabs.Active.Path)) _left.Tabs.Active.Path = leftPath; - if (!string.Equals(_right.Tabs.Active.Path, HomePath, StringComparison.OrdinalIgnoreCase) && !Directory.Exists(_right.Tabs.Active.Path)) + if (!string.Equals(_right.Tabs.Active.Path, HomePath, StringComparison.OrdinalIgnoreCase) && + !string.Equals(_right.Tabs.Active.Path, RecycleBinPath, StringComparison.OrdinalIgnoreCase) && + !Directory.Exists(_right.Tabs.Active.Path)) _right.Tabs.Active.Path = rightPath; leftPath = _left.Tabs.Active.Path; rightPath = _right.Tabs.Active.Path; @@ -455,6 +467,8 @@ private async Task NavigateAsync(PaneState pane, string requestedPath, boo { if (string.Equals(requestedPath.Trim(), HomePath, StringComparison.OrdinalIgnoreCase)) return await NavigateHomeAsync(pane, addHistory); + if (string.Equals(requestedPath.Trim(), RecycleBinPath, StringComparison.OrdinalIgnoreCase)) + return await NavigateRecycleBinAsync(pane, addHistory); CancelFolderSizeMeasurement(pane); pane.ReadCancellation?.Cancel(); pane.ReadCancellation?.Dispose(); @@ -607,15 +621,17 @@ private void ApplySidebarVisibility() => private void ApplyPaneMode(PaneState pane) { var home = pane.IsHome; - var columns = !home && pane.Layout == PaneLayout.Columns; + // The bin renders in the standard list whatever the layout setting: + // its entries are not a folder chain. + var columns = !home && !pane.IsRecycleBin && pane.Layout == PaneLayout.Columns; pane.HomeView.Visibility = home ? Visibility.Visible : Visibility.Collapsed; pane.List.Visibility = !home && !columns ? Visibility.Visible : Visibility.Collapsed; pane.ColumnsView.Visibility = columns ? Visibility.Visible : Visibility.Collapsed; pane.FilterRow.Visibility = home ? Visibility.Collapsed : Visibility.Visible; pane.StandardLayoutToggle.IsChecked = pane.Layout == PaneLayout.Standard; pane.ColumnsLayoutToggle.IsChecked = pane.Layout == PaneLayout.Columns; - pane.StandardLayoutToggle.IsEnabled = !home; - pane.ColumnsLayoutToggle.IsEnabled = !home; + pane.StandardLayoutToggle.IsEnabled = !home && !pane.IsRecycleBin; + pane.ColumnsLayoutToggle.IsEnabled = !home && !pane.IsRecycleBin; var viewControlsEnabled = !home && !columns; pane.DetailsViewToggle.IsEnabled = viewControlsEnabled; pane.GridViewToggle.IsEnabled = viewControlsEnabled; @@ -633,7 +649,7 @@ private void ApplyPaneMode(PaneState pane) /// back/forward, tree pick) keeps the tree following the pane. private void ApplyFolderTreeVisibility(PaneState pane) { - var show = pane.FolderTreeVisible && !pane.IsHome; + var show = pane.FolderTreeVisible && !pane.IsHome && !pane.IsRecycleBin; pane.FolderTree.Visibility = show ? Visibility.Visible : Visibility.Collapsed; pane.FolderTreeToggle.IsChecked = pane.FolderTreeVisible; if (show && !string.IsNullOrWhiteSpace(pane.CurrentPath)) _ = SyncFolderTreeAsync(pane); @@ -859,9 +875,10 @@ private async void LayoutShortcut_Invoked(KeyboardAccelerator sender, KeyboardAc private async Task SetPaneLayoutAsync(PaneState pane, PaneLayout layout) { - // Columns need a folder root; at Home the switcher is disabled and a - // toggle attempt simply leaves the pane exactly as it is. - if (pane.IsHome) { ApplyPaneMode(pane); return; } + // Columns need a folder root; at Home (and in the Recycle Bin) the + // switcher is disabled and a toggle attempt simply leaves the pane + // exactly as it is. + if (pane.IsHome || pane.IsRecycleBin) { ApplyPaneMode(pane); return; } if (pane.Layout == layout) { ApplyPaneMode(pane); return; } pane.Layout = layout; if (layout == PaneLayout.Columns) CancelFolderSizeMeasurement(pane); @@ -2082,6 +2099,11 @@ private static string FormatFileSize(long bytes) private async Task DeleteSelectedAsync(PaneState pane) { + if (pane.IsRecycleBin) + { + await DeleteRecycledPermanentlyAsync(pane); + return; + } var selection = GetSelectedEntries(pane); if (selection.Count == 0) { @@ -2857,6 +2879,48 @@ private void UpdateContextMenu(PaneState pane, bool isLeftPane) UpdateContextMenuItems(pane, isLeftPane, LeftCopyMenuItem, LeftMoveMenuItem, LeftRenameMenuItem, LeftPropertiesMenuItem, LeftShelfMenuItem, LeftDeleteMenuItem, LeftClipboardCopyMenuItem, LeftClipboardCutMenuItem, LeftPasteMenuItem, LeftCopyPathMenuItem); else UpdateContextMenuItems(pane, isLeftPane, RightCopyMenuItem, RightMoveMenuItem, RightRenameMenuItem, RightPropertiesMenuItem, RightShelfMenuItem, RightDeleteMenuItem, RightClipboardCopyMenuItem, RightClipboardCutMenuItem, RightPasteMenuItem, RightCopyPathMenuItem); + ApplyRecycleBinMenuState(pane, isLeftPane); + } + + /// + /// In the Recycle Bin the standard file operations step aside: the + /// menu shows Restore / Delete permanently / Empty instead, and the + /// file operations are disabled (their handlers refuse too, so no + /// route — keyboard, palette, drag — can act on the bin's storage + /// files). Outside the bin the three bin items stay hidden. + /// + private void ApplyRecycleBinMenuState(PaneState pane, bool isLeftPane) + { + var bin = pane.IsRecycleBin; + var visibility = bin ? Visibility.Visible : Visibility.Collapsed; + var restore = isLeftPane ? LeftRestoreMenuItem : RightRestoreMenuItem; + var deletePermanently = isLeftPane ? LeftDeletePermanentlyMenuItem : RightDeletePermanentlyMenuItem; + var empty = isLeftPane ? LeftEmptyBinMenuItem : RightEmptyBinMenuItem; + restore.Visibility = visibility; + deletePermanently.Visibility = visibility; + empty.Visibility = visibility; + (isLeftPane ? LeftTemplatesMenuItem : RightTemplatesMenuItem).IsEnabled = !bin; + (isLeftPane ? LeftPasteAsFileMenuItem : RightPasteAsFileMenuItem).IsEnabled = !bin; + if (!bin) return; + var count = pane.SelectionList.SelectedItems.Count; + restore.IsEnabled = count > 0; + deletePermanently.IsEnabled = count > 0; + restore.Text = count > 1 ? $"Restore {count:N0} items" : "Restore"; + deletePermanently.Text = count > 1 ? $"Delete {count:N0} items permanently" : "Delete permanently"; + foreach (var item in new MenuFlyoutItem[] + { + isLeftPane ? LeftCopyMenuItem : RightCopyMenuItem, + isLeftPane ? LeftMoveMenuItem : RightMoveMenuItem, + isLeftPane ? LeftRenameMenuItem : RightRenameMenuItem, + isLeftPane ? LeftPropertiesMenuItem : RightPropertiesMenuItem, + isLeftPane ? LeftShelfMenuItem : RightShelfMenuItem, + isLeftPane ? LeftDeleteMenuItem : RightDeleteMenuItem, + isLeftPane ? LeftClipboardCopyMenuItem : RightClipboardCopyMenuItem, + isLeftPane ? LeftClipboardCutMenuItem : RightClipboardCutMenuItem, + isLeftPane ? LeftPasteMenuItem : RightPasteMenuItem + }) + item.IsEnabled = false; + (isLeftPane ? LeftCopyPathMenuItem : RightCopyPathMenuItem).IsEnabled = false; } /// Shared by the standard lists' XAML menus and the code-built column @@ -2865,7 +2929,7 @@ private void UpdateContextMenuItems(PaneState pane, bool isLeftPane, MenuFlyoutI { var other = isLeftPane ? _right : _left; var count = pane.SelectionList.SelectedItems.Count; - var hasDestination = !string.IsNullOrWhiteSpace(other.CurrentPath) && !other.IsHome; + var hasDestination = !string.IsNullOrWhiteSpace(other.CurrentPath) && !other.IsHome && !other.IsRecycleBin; var direction = isLeftPane ? "right" : "left"; copyItem.IsEnabled = count > 0 && hasDestination; moveItem.IsEnabled = count > 0 && hasDestination; @@ -2914,9 +2978,9 @@ private void UpdateContextMenuItems(PaneState pane, bool isLeftPane, MenuFlyoutI private void RefreshHeaderReadout() { var pane = _activePane; - if (pane is null || pane.IsHome || string.IsNullOrEmpty(pane.CurrentPath)) + if (pane is null || pane.IsHome || pane.IsRecycleBin || string.IsNullOrEmpty(pane.CurrentPath)) { - HeaderPathReadout.Text = pane is not null && pane.IsHome ? "Home" : string.Empty; + HeaderPathReadout.Text = pane is null ? string.Empty : pane.IsHome ? "Home" : pane.IsRecycleBin ? "Recycle Bin" : string.Empty; HeaderSpaceReadout.Text = string.Empty; HeaderSpaceSeparator.Visibility = Visibility.Collapsed; return; @@ -3001,6 +3065,14 @@ private void UpdatePaneHeader(PaneState pane) UpdatePaneHeaderAutomation(); return; } + if (pane.IsRecycleBin) + { + nameBlock.Text = "Recycle Bin"; + pathBlock.Text = "Deleted items from all drives · select an item to restore it or delete it for good"; + ToolTipService.SetToolTip(pathBlock, null); + UpdatePaneHeaderAutomation(); + return; + } var path = pane.CurrentPath; var name = Path.GetFileName(path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); if (string.IsNullOrWhiteSpace(name)) name = string.IsNullOrWhiteSpace(path) ? pane.Name + " pane" : path; @@ -3108,6 +3180,27 @@ private void RebuildBreadcrumbs(PaneState pane) if (pane.BreadcrumbPanel is not { } panel) return; panel.Visibility = Visibility.Visible; panel.Children.Clear(); + if (pane.IsRecycleBin) + { + var binLabel = new TextBlock + { + Text = "Recycle Bin", + VerticalAlignment = VerticalAlignment.Center, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold + }; + var binButton = new Button + { + Content = binLabel, + Padding = new Thickness(6, 3, 6, 3), + Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent), + BorderThickness = new Thickness(0) + }; + ToolTipService.SetToolTip(binButton, "Recycle Bin — click to refresh the listing"); + AutomationProperties.SetName(binButton, "Recycle Bin"); + binButton.Click += async (s, e) => await NavigateAsync(pane, RecycleBinPath, addHistory: false); + panel.Children.Add(binButton); + return; + } var segments = BreadcrumbPath.SegmentsFor(pane.CurrentPath, HomePath); for (var i = 0; i < segments.Count; i++) { @@ -3318,6 +3411,9 @@ private sealed class PaneState(string name, TextBox pathBox, TextBox filterBox, /// True while the pane shows the Home page instead of a folder. public bool IsHome => string.Equals(CurrentPath, HomePath, StringComparison.Ordinal); + /// True while the pane browses the Recycle Bin. + public bool IsRecycleBin => string.Equals(CurrentPath, RecycleBinPath, StringComparison.Ordinal); + // ----- Miller columns state (unused while Layout is Standard) ----- public ScrollViewer ColumnsView { get; } = columnsView; diff --git a/src/Meridian.App/Services/WorkspaceStore.cs b/src/Meridian.App/Services/WorkspaceStore.cs index 68025bc..edbce1c 100644 --- a/src/Meridian.App/Services/WorkspaceStore.cs +++ b/src/Meridian.App/Services/WorkspaceStore.cs @@ -145,7 +145,9 @@ private static bool IsValid(WorkspaceSnapshot snapshot) /// Home page like any other folder. /// private static bool ValidTabPath(string? path) => - string.Equals(path, "meridian://home", StringComparison.OrdinalIgnoreCase) || ValidPath(path, optional: false); + string.Equals(path, "meridian://home", StringComparison.OrdinalIgnoreCase) || + string.Equals(path, "meridian://recycle-bin", StringComparison.OrdinalIgnoreCase) || + ValidPath(path, optional: false); private static bool ValidPath(string? path, bool optional) => string.IsNullOrEmpty(path) ? optional : path.Length <= 32767 && !path.Any(char.IsControl) && Path.IsPathFullyQualified(path); diff --git a/src/Meridian.Core/Models/FileEntry.cs b/src/Meridian.Core/Models/FileEntry.cs index aa75352..b5f92a1 100644 --- a/src/Meridian.Core/Models/FileEntry.cs +++ b/src/Meridian.Core/Models/FileEntry.cs @@ -20,6 +20,13 @@ public sealed class FileEntry : INotifyPropertyChanged /// Friendly relative age of the last write ("2 hours ago"), computed when the folder was read. public string AgeLabel { get; init; } = string.Empty; + /// + /// Where a recycled item originally lived, set only for Recycle Bin + /// entries (whose FullPath points at the item's data file inside the + /// bin). Restore moves the item back here. + /// + public string? OriginalPath { get; init; } + /// The age band of the last write; the window uses it to pick a restrained text tier for the Age column. public AgeBucket AgeBucket => RelativeAge.BucketOf(ModifiedUtc, DateTime.UtcNow); diff --git a/src/Meridian.Core/Services/RecycleBin.cs b/src/Meridian.Core/Services/RecycleBin.cs new file mode 100644 index 0000000..304a89e --- /dev/null +++ b/src/Meridian.Core/Services/RecycleBin.cs @@ -0,0 +1,121 @@ +using System.Buffers.Binary; +using System.Text; + +namespace Meridian.Services; + +/// +/// One item sitting in the Recycle Bin: where it originally lived, where +/// its data and info files sit inside the bin, how big it is, and when it +/// was deleted. +/// +public sealed record RecycledItem( + string OriginalPath, + string DataPath, + string InfoPath, + long SizeBytes, + DateTime DeletedUtc, + bool IsDirectory); + +/// +/// Reads the Recycle Bin's on-disk format. Every deleted item leaves two +/// files in $Recycle.Bin\<user SID> on its drive: a data file +/// named $R plus a random suffix (the item itself, renamed) and an +/// info file named $I plus the same suffix, which records the +/// original path, the deletion time, and the size. Info files come in two +/// versions: version 1 stores the path in a fixed 260-character field, +/// version 2 stores a character count before the path. Parsing is +/// defensive — a damaged info file yields no item, never an exception. +/// +public static class RecycleBinInfo +{ + public static bool TryParse(byte[] bytes, out string originalPath, out long sizeBytes, out DateTime deletedUtc) + { + originalPath = string.Empty; + sizeBytes = 0; + deletedUtc = default; + if (bytes is null || bytes.Length < 24) + return false; + var version = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(0, 8)); + if (version is not (1 or 2)) + return false; + sizeBytes = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(8, 8)); + var fileTime = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(16, 8)); + try { deletedUtc = DateTime.FromFileTimeUtc(fileTime); } + catch (ArgumentOutOfRangeException) { return false; } + + if (version == 1) + { + const int pathOffset = 24; + const int pathBytes = 260 * 2; + if (bytes.Length < pathOffset + 2) + return false; + var available = Math.Min(pathBytes, bytes.Length - pathOffset); + originalPath = ReadNullTerminated(bytes, pathOffset, available); + } + else + { + if (bytes.Length < 28) + return false; + var charCount = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(24, 4)); + if (charCount <= 0 || charCount > 32768) + return false; + const int pathOffset = 28; + var available = Math.Min(charCount * 2, bytes.Length - pathOffset); + if (available < 2) + return false; + originalPath = ReadNullTerminated(bytes, pathOffset, available); + } + return !string.IsNullOrWhiteSpace(originalPath); + } + + private static string ReadNullTerminated(byte[] bytes, int offset, int byteCount) + { + var text = Encoding.Unicode.GetString(bytes, offset, byteCount - (byteCount % 2)); + var nul = text.IndexOf('\0'); + return (nul >= 0 ? text[..nul] : text).Trim(); + } +} + +/// Finds the items in one user's bin folder on one drive. +public static class RecycleBinScanner +{ + /// The bin folder for one user on one drive: <root>$Recycle.Bin\<sid>. + public static string BinFolderFor(string driveRoot, string userSid) + { + var root = driveRoot.EndsWith('\\') || driveRoot.EndsWith('/') ? driveRoot : driveRoot + "\\"; + return root + "$Recycle.Bin\\" + userSid; + } + + /// + /// Pairs every readable info file in the folder with its data file. + /// Items whose data file is gone (or whose info file is damaged) are + /// skipped. A missing folder reads as an empty bin. + /// + public static IReadOnlyList Scan(string binFolder) + { + var items = new List(); + if (string.IsNullOrWhiteSpace(binFolder) || !Directory.Exists(binFolder)) + return items; + string[] infoFiles; + try { infoFiles = Directory.GetFiles(binFolder, "$I*"); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return items; } + foreach (var infoPath in infoFiles) + { + var name = infoPath[(binFolder.Length + 1)..]; + if (name.Length < 3) + continue; + var dataPath = Path.Combine(binFolder, "$R" + name[2..]); + byte[] bytes; + try { bytes = File.ReadAllBytes(infoPath); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { continue; } + if (!RecycleBinInfo.TryParse(bytes, out var originalPath, out var size, out var deletedUtc)) + continue; + var isDirectory = Directory.Exists(dataPath); + if (!isDirectory && !File.Exists(dataPath)) + continue; // the data is gone; the info file is a leftover + items.Add(new RecycledItem(originalPath, dataPath, infoPath, size, deletedUtc, isDirectory)); + } + items.Sort((a, b) => b.DeletedUtc.CompareTo(a.DeletedUtc)); + return items; + } +} diff --git a/tests/Meridian.Core.Tests/RecycleBinTests.cs b/tests/Meridian.Core.Tests/RecycleBinTests.cs new file mode 100644 index 0000000..97c9164 --- /dev/null +++ b/tests/Meridian.Core.Tests/RecycleBinTests.cs @@ -0,0 +1,113 @@ +using System.Buffers.Binary; +using System.Text; +using Meridian.Services; +using Xunit; + +namespace Meridian.Core.Tests; + +public sealed class RecycleBinTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "MeridianBin-" + Guid.NewGuid().ToString("N")); + + public RecycleBinTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch (IOException) { } + } + + private static byte[] InfoFile(long version, long size, DateTime deletedUtc, string originalPath) + { + using var stream = new MemoryStream(); + void WriteLong(long value) { var b = new byte[8]; BinaryPrimitives.WriteInt64LittleEndian(b, value); stream.Write(b); } + WriteLong(version); + WriteLong(size); + WriteLong(deletedUtc.ToFileTimeUtc()); + if (version == 1) + { + var pathBytes = new byte[260 * 2]; + Encoding.Unicode.GetBytes(originalPath).CopyTo(pathBytes, 0); + stream.Write(pathBytes); + } + else + { + var count = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(count, originalPath.Length + 1); + stream.Write(count); + stream.Write(Encoding.Unicode.GetBytes(originalPath + "\0")); + } + return stream.ToArray(); + } + + [Fact] + public void Parse_Version2_ReadsPathSizeAndTime() + { + var deleted = new DateTime(2026, 11, 1, 10, 30, 0, DateTimeKind.Utc); + var bytes = InfoFile(2, 12345, deleted, @"C:\Users\sean\report.pdf"); + Assert.True(RecycleBinInfo.TryParse(bytes, out var path, out var size, out var when)); + Assert.Equal(@"C:\Users\sean\report.pdf", path); + Assert.Equal(12345, size); + Assert.Equal(deleted, when); + } + + [Fact] + public void Parse_Version1_ReadsFixedFieldPath() + { + var deleted = new DateTime(2026, 10, 20, 8, 0, 0, DateTimeKind.Utc); + var bytes = InfoFile(1, 7, deleted, @"D:\old\notes.txt"); + Assert.True(RecycleBinInfo.TryParse(bytes, out var path, out var size, out var when)); + Assert.Equal(@"D:\old\notes.txt", path); + Assert.Equal(7, size); + Assert.Equal(deleted, when); + } + + [Theory] + [InlineData(new byte[0])] + [InlineData(new byte[] { 1, 2, 3 })] + public void Parse_Truncated_ReturnsFalse(byte[] bytes) + { + Assert.False(RecycleBinInfo.TryParse(bytes, out _, out _, out _)); + } + + [Fact] + public void Parse_UnknownVersion_ReturnsFalse() + { + var bytes = InfoFile(9, 1, DateTime.UtcNow, @"C:\x.txt"); + Assert.False(RecycleBinInfo.TryParse(bytes, out _, out _, out _)); + } + + [Fact] + public void BinFolderFor_JoinsRootSid() + { + Assert.Equal(@"C:\$Recycle.Bin\S-1-5-21-1", RecycleBinScanner.BinFolderFor(@"C:\", "S-1-5-21-1")); + Assert.Equal(@"D:\$Recycle.Bin\S-1-5-21-1", RecycleBinScanner.BinFolderFor("D:", "S-1-5-21-1")); + } + + [Fact] + public void Scan_PairsInfoAndData_SortedNewestFirst() + { + var older = new DateTime(2026, 10, 1, 9, 0, 0, DateTimeKind.Utc); + var newer = new DateTime(2026, 11, 1, 9, 0, 0, DateTimeKind.Utc); + File.WriteAllBytes(Path.Combine(_root, "$IAAAA01.txt"), InfoFile(2, 10, older, @"C:\a.txt")); + File.WriteAllText(Path.Combine(_root, "$RAAAA01.txt"), "data"); + File.WriteAllBytes(Path.Combine(_root, "$IBBBB02"), InfoFile(2, 20, newer, @"C:\folder")); + Directory.CreateDirectory(Path.Combine(_root, "$RBBBB02")); + // Orphaned info file (no data) and a damaged one are skipped. + File.WriteAllBytes(Path.Combine(_root, "$IORPHAN"), InfoFile(2, 1, newer, @"C:\gone.txt")); + File.WriteAllBytes(Path.Combine(_root, "$IBADBAD"), new byte[] { 9, 9, 9 }); + + var items = RecycleBinScanner.Scan(_root); + Assert.Equal(2, items.Count); + Assert.Equal(@"C:\folder", items[0].OriginalPath); + Assert.True(items[0].IsDirectory); + Assert.Equal(@"C:\a.txt", items[1].OriginalPath); + Assert.False(items[1].IsDirectory); + Assert.Equal(10, items[1].SizeBytes); + } + + [Fact] + public void Scan_MissingFolder_ReadsEmpty() + { + Assert.Empty(RecycleBinScanner.Scan(Path.Combine(_root, "nope"))); + } +} From c717de24bc3592df7b1f5e22832075cd063c378d Mon Sep 17 00:00:00 2001 From: Sean Shaffer Date: Tue, 3 Nov 2026 00:26:00 -0600 Subject: [PATCH 2/2] Fix MainWindow namespace in the Recycle Bin file --- src/Meridian.App/MainWindow.RecycleBin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Meridian.App/MainWindow.RecycleBin.cs b/src/Meridian.App/MainWindow.RecycleBin.cs index 9949f22..7b22c25 100644 --- a/src/Meridian.App/MainWindow.RecycleBin.cs +++ b/src/Meridian.App/MainWindow.RecycleBin.cs @@ -4,7 +4,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -namespace Meridian.App; +namespace Meridian; /// /// Recycle Bin browsing: a pane can navigate to the bin sentinel and see