Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/Meridian.App/MainWindow.BatchRename.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ public sealed partial class MainWindow
/// single-item rename exactly as it was, warning included.</summary>
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
Expand Down
4 changes: 2 additions & 2 deletions src/Meridian.App/MainWindow.Clipboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ private void ApplyCutDimming()
/// </summary>
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;
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions src/Meridian.App/MainWindow.Preview.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
250 changes: 250 additions & 0 deletions src/Meridian.App/MainWindow.RecycleBin.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public sealed partial class MainWindow
{
private async Task<bool> 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<RecycledItem> 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<RecycledItem> ScanRecycleBin()
{
var sid = WindowsIdentity.GetCurrent().User?.Value;
if (string.IsNullOrEmpty(sid)) return Array.Empty<RecycledItem>();
var all = new List<RecycledItem>();
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
};
}

/// <summary>The $I info file paired with a bin entry's data file.</summary>
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<string>();
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);
}

/// <summary>"name (restored).ext", then "name (restored 2).ext", while the plain name is taken.</summary>
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<string>();
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);
}
4 changes: 3 additions & 1 deletion src/Meridian.App/MainWindow.Sidebar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading