diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 1127a2b..b992822 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+- Files and folders can now carry Meridian tags, a star rating, and a comment: the right-click menu's "Tags & rating…" edits them for the selection (tags and rating apply to every selected item, the comment to a single item), they are kept in Meridian's own local store — never written into the files, so they work for any file or folder — and they show in the pane's preview block.
+
+- 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..283dc65 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -10,10 +10,10 @@ 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).
+4. **Tags, ratings, and comments** stored locally and searchable, with optional NTFS alternate-data-stream storage investigated for portability (Phase 2). _Started: tags, ratings, and comments are stored locally and shown in the preview block; searching by them and the ADS investigation remain._
5. **Network protocols** — SMB browsing first, then optional SFTP, FTP/FTPS, WebDAV, and SCP clients and optional cloud providers, all behind provider interfaces; local file management never depends on them (Phase 6).
Operation undo/redo and Dolphin-style context-menu actions (service menus — the Scripts menu is the seed) round out the program.
@@ -165,7 +165,7 @@ Avoid starting large Phase 2/3 experiments until these release blockers are reso
- [x] In-memory Drop Shelf with advisory preview, confirmed sequential copy, cancellation and partial-result reporting; persistence remains planned
- [x] Flat/branch view across nested directories
- [ ] Per-folder view preferences stored in a local database (columns, sorting, icon size, and supported visual options), rather than depending on fragile registry Bags
-- [ ] Color labels, tags, and ratings stored locally; investigate optional NTFS alternate data stream (ADS) storage with explicit portability and privacy warnings
+- [ ] Color labels, tags, and ratings stored locally; investigate optional NTFS alternate data stream (ADS) storage with explicit portability and privacy warnings (tags, ratings, and comments stored locally with editing and preview display done; color labels, search/filter, and the ADS investigation remain)
- [ ] File-age visualization using restrained tinting or age bars
- [ ] Disk-usage treemap/sunburst visualization
- [x] Saved/virtual search folders with live criteria
@@ -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..2aa7a85 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();
@@ -405,6 +418,7 @@ private async Task RefreshPreviewBlockAsync(PaneState pane)
$"Created: {info.CreationTime.ToString("g")}",
$"Modified: {info.LastWriteTime.ToString("g")}"
};
+ lines.AddRange(UserMetadataLines(entry));
if (entry.IsDirectory)
{
// The folder treatment the Space quick preview gives a
diff --git a/src/Meridian.App/MainWindow.RecycleBin.cs b/src/Meridian.App/MainWindow.RecycleBin.cs
new file mode 100644
index 0000000..7b22c25
--- /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;
+
+///
+/// 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.Tags.cs b/src/Meridian.App/MainWindow.Tags.cs
new file mode 100644
index 0000000..04fc725
--- /dev/null
+++ b/src/Meridian.App/MainWindow.Tags.cs
@@ -0,0 +1,112 @@
+using Meridian.Models;
+using Meridian.Services;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Automation;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Meridian;
+
+///
+/// Tags, ratings, and comments — Meridian's own per-item metadata, kept
+/// in the local metadata store (never written into the files themselves,
+/// so it works for every file and folder). The pane menu's "Tags &
+/// rating…" item opens the editor for the current selection: tags and
+/// the star rating apply to every selected item; the comment is a
+/// single-item field. Saved values show in the pane's preview block.
+///
+public sealed partial class MainWindow
+{
+ private async void TagsFromLeft_Click(object sender, RoutedEventArgs e) => await EditTagsAsync(_left);
+ private async void TagsFromRight_Click(object sender, RoutedEventArgs e) => await EditTagsAsync(_right);
+
+ /// The store key for an entry: its original path for recycled items, else its own path.
+ private static string MetadataKeyFor(FileEntry entry) => entry.OriginalPath ?? entry.FullPath;
+
+ private async Task EditTagsAsync(PaneState pane)
+ {
+ var selection = GetSelectedEntries(pane);
+ if (selection.Count == 0)
+ {
+ SetStatus("Select an item to tag first.", StatusSeverity.Warning);
+ return;
+ }
+ var single = selection.Count == 1;
+ var existing = single ? _fileMetadataStore.Get(MetadataKeyFor(selection[0])) : null;
+
+ var tagsBox = new TextBox
+ {
+ Header = single ? "Tags (separate with commas)" : $"Tags for {selection.Count:N0} items (separate with commas)",
+ Text = existing is null ? string.Empty : string.Join(", ", existing.Tags),
+ PlaceholderText = "work, urgent, later"
+ };
+ ToolTipService.SetToolTip(tagsBox, "Tags Meridian keeps for the item — separate them with commas");
+ AutomationProperties.SetName(tagsBox, "Tags");
+
+ var rating = new RatingControl
+ {
+ Caption = "Rating",
+ Value = existing?.Rating ?? 0,
+ IsClearEnabled = true
+ };
+ ToolTipService.SetToolTip(rating, "Star rating, from none to five");
+ AutomationProperties.SetName(rating, "Rating");
+
+ var panel = new StackPanel { Spacing = 12, MinWidth = 320 };
+ panel.Children.Add(tagsBox);
+ panel.Children.Add(rating);
+
+ TextBox? commentBox = null;
+ if (single)
+ {
+ commentBox = new TextBox
+ {
+ Header = "Comment",
+ Text = existing?.Comment ?? string.Empty,
+ AcceptsReturn = true,
+ Height = 72,
+ TextWrapping = TextWrapping.Wrap
+ };
+ ToolTipService.SetToolTip(commentBox, "A note Meridian keeps about this item");
+ AutomationProperties.SetName(commentBox, "Comment");
+ panel.Children.Add(commentBox);
+ }
+
+ var dialog = new ContentDialog
+ {
+ Title = single ? $"Tags & rating — {selection[0].Name}" : $"Tags & rating — {selection.Count:N0} items",
+ Content = panel,
+ PrimaryButtonText = "Save",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Primary,
+ XamlRoot = RootGrid.XamlRoot
+ };
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+ var tags = FileMetadataStore.ParseTags(tagsBox.Text);
+ var stars = rating.Value < 0 ? 0 : (int)Math.Round(rating.Value);
+ foreach (var entry in selection)
+ {
+ var key = MetadataKeyFor(entry);
+ var comment = commentBox?.Text ?? _fileMetadataStore.Get(key)?.Comment ?? string.Empty;
+ await _fileMetadataStore.SetAsync(key, new FileMetadata(tags, stars, comment));
+ }
+ RefreshPreviewBlock(pane);
+ SetStatus(selection.Count == 1
+ ? $"Saved tags & rating for {selection[0].Name}."
+ : $"Saved tags & rating for {selection.Count:N0} items.", StatusSeverity.Success);
+ }
+
+ /// The preview block's lines for an item's saved metadata, empty when it has none.
+ private List UserMetadataLines(FileEntry entry)
+ {
+ var lines = new List();
+ if (_fileMetadataStore.Get(MetadataKeyFor(entry)) is not { } metadata) return lines;
+ if (metadata.Tags.Length > 0)
+ lines.Add("Tags: " + string.Join(", ", metadata.Tags));
+ if (metadata.Rating > 0)
+ lines.Add("Rating: " + new string('★', metadata.Rating) + new string('☆', 5 - metadata.Rating));
+ if (!string.IsNullOrWhiteSpace(metadata.Comment))
+ lines.Add("Comment: " + metadata.Comment);
+ return lines;
+ }
+}
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..eb3946e 100644
--- a/src/Meridian.App/MainWindow.xaml
+++ b/src/Meridian.App/MainWindow.xaml
@@ -375,9 +375,13 @@
+
+
+
+
@@ -518,9 +522,13 @@
+
+
+
+
diff --git a/src/Meridian.App/MainWindow.xaml.cs b/src/Meridian.App/MainWindow.xaml.cs
index bd858a5..2416c7e 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();
@@ -48,6 +56,7 @@ public sealed partial class MainWindow : Window
private readonly FolderViewStore _folderViewStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "folder-views.json"));
private readonly SavedSearchStore _savedSearchStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "saved-searches.json"));
private readonly ClosedTabStore _closedTabStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "closed-tabs.json"));
+ private readonly FileMetadataStore _fileMetadataStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "file-metadata.json"));
private readonly NamedWorkspaceStore _namedWorkspaceStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "named-workspaces.json"));
private readonly FolderSizeCache _folderSizeCache = new();
private readonly FolderSizeStore _folderSizeStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "folder-sizes.json"));
@@ -258,9 +267,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;
@@ -271,6 +284,7 @@ private async Task InitializeAsync(string documents, string downloads)
await _folderViewStore.LoadAsync();
await _savedSearchStore.LoadAsync();
await _closedTabStore.LoadAsync();
+ await _fileMetadataStore.LoadAsync();
await _namedWorkspaceStore.LoadAsync();
_folderViewsLoaded = true;
await _recentsStore.LoadAsync();
@@ -455,6 +469,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 +623,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 +651,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 +877,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 +2101,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)
{
@@ -2853,10 +2877,55 @@ private async Task HandleListKeyDownAsync(PaneState pane, KeyRoutedEventArgs e)
private void UpdateContextMenu(PaneState pane, bool isLeftPane)
{
PopulateTemplatesMenu(isLeftPane ? LeftTemplatesMenuItem : RightTemplatesMenuItem, pane);
+ (isLeftPane ? LeftTagsMenuItem : RightTagsMenuItem).IsEnabled =
+ pane.SelectionList.SelectedItems.Count > 0 && !pane.IsHome;
if (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 ? LeftTagsMenuItem : RightTagsMenuItem).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 +2934,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 +2983,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 +3070,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 +3185,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 +3416,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/FileMetadataStore.cs b/src/Meridian.Core/Services/FileMetadataStore.cs
new file mode 100644
index 0000000..26feab5
--- /dev/null
+++ b/src/Meridian.Core/Services/FileMetadataStore.cs
@@ -0,0 +1,152 @@
+using System.Text.Json;
+
+namespace Meridian.Services;
+
+/// One item's user metadata: its tags, a 0–5 star rating (0 = unrated), and a free-text comment.
+public sealed record FileMetadata(string[] Tags, int Rating, string Comment);
+
+///
+/// Meridian's own per-file metadata — tags, ratings, and comments — kept
+/// in one local JSON file keyed by full path. Unlike the embedded file
+/// properties the Properties window edits, this works for every file and
+/// folder whatever its format, and it never writes into the user's files.
+/// Saving is quiet and atomic (temp file, then a move); a damaged file
+/// reads as empty. An item whose metadata is cleared entirely is dropped
+/// from the store, so the file only ever holds live entries.
+///
+public sealed class FileMetadataStore
+{
+ public const int MaximumEntries = 4096;
+ public const int MaximumTagsPerItem = 20;
+ public const int MaximumTagLength = 40;
+ public const int MaximumCommentLength = 500;
+
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ private readonly string _filePath;
+ private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase);
+
+ public FileMetadataStore(string filePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
+ _filePath = filePath;
+ }
+
+ public int Count => _entries.Count;
+
+ public FileMetadata? Get(string path) =>
+ !string.IsNullOrWhiteSpace(path) && _entries.TryGetValue(path, out var metadata) ? metadata : null;
+
+ /// Every distinct tag in use, alphabetical — for pickers and future search.
+ public IReadOnlyList AllTags() =>
+ _entries.Values.SelectMany(metadata => metadata.Tags)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ public async Task LoadAsync()
+ {
+ _entries.Clear();
+ try
+ {
+ if (!File.Exists(_filePath)) return;
+ var json = await File.ReadAllTextAsync(_filePath);
+ var stored = JsonSerializer.Deserialize>(json, JsonOptions);
+ if (stored is null) return;
+ foreach (var entry in stored)
+ {
+ if (entry is null || string.IsNullOrWhiteSpace(entry.Path)) continue;
+ var metadata = Normalize(entry.Tags ?? [], entry.Rating, entry.Comment ?? string.Empty);
+ if (!IsEmpty(metadata))
+ _entries[entry.Path] = metadata;
+ if (_entries.Count >= MaximumEntries) break;
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or NotSupportedException)
+ {
+ _entries.Clear();
+ }
+ }
+
+ ///
+ /// Sets one item's metadata (normalized first). Metadata that is
+ /// empty after normalization removes the item from the store.
+ ///
+ public async Task SetAsync(string path, FileMetadata metadata)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return;
+ var normalized = Normalize(metadata.Tags, metadata.Rating, metadata.Comment);
+ if (IsEmpty(normalized))
+ _entries.Remove(path);
+ else
+ _entries[path] = normalized;
+ await SaveAsync();
+ }
+
+ public async Task RemoveAsync(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) || !_entries.Remove(path)) return;
+ await SaveAsync();
+ }
+
+ private async Task SaveAsync()
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(_filePath);
+ if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
+ var stored = _entries
+ .Select(pair => new StoredMetadata { Path = pair.Key, Tags = pair.Value.Tags, Rating = pair.Value.Rating, Comment = pair.Value.Comment })
+ .ToList();
+ var temporaryPath = _filePath + ".tmp";
+ await File.WriteAllTextAsync(temporaryPath, JsonSerializer.Serialize(stored, JsonOptions));
+ File.Move(temporaryPath, _filePath, overwrite: true);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
+ {
+ // Metadata is a convenience; a failed save never reaches the user.
+ }
+ }
+
+ public static bool IsEmpty(FileMetadata metadata) =>
+ metadata.Tags.Length == 0 && metadata.Rating == 0 && string.IsNullOrWhiteSpace(metadata.Comment);
+
+ /// Splits typed tag text ("work, urgent; later") into clean tags.
+ public static string[] ParseTags(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return [];
+ return NormalizeTags(text.Split(new[] { ',', ';' }, StringSplitOptions.None));
+ }
+
+ public static string[] NormalizeTags(IEnumerable tags) => NormalizeTags(tags as string[] ?? tags.ToArray());
+
+ private static string[] NormalizeTags(IReadOnlyList tags)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var clean = new List();
+ foreach (var raw in tags)
+ {
+ var tag = raw?.Trim() ?? string.Empty;
+ if (tag.Length == 0) continue;
+ if (tag.Length > MaximumTagLength) tag = tag[..MaximumTagLength];
+ if (seen.Add(tag)) clean.Add(tag);
+ if (clean.Count >= MaximumTagsPerItem) break;
+ }
+ return [.. clean];
+ }
+
+ private static FileMetadata Normalize(IEnumerable tags, int rating, string comment)
+ {
+ var trimmedComment = (comment ?? string.Empty).Trim();
+ if (trimmedComment.Length > MaximumCommentLength) trimmedComment = trimmedComment[..MaximumCommentLength];
+ return new FileMetadata(NormalizeTags(tags), Math.Clamp(rating, 0, 5), trimmedComment);
+ }
+
+ private sealed class StoredMetadata
+ {
+ public string Path { get; set; } = string.Empty;
+ public string[] Tags { get; set; } = [];
+ public int Rating { get; set; }
+ public string Comment { get; set; } = string.Empty;
+ }
+}
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/FileMetadataStoreTests.cs b/tests/Meridian.Core.Tests/FileMetadataStoreTests.cs
new file mode 100644
index 0000000..45b5742
--- /dev/null
+++ b/tests/Meridian.Core.Tests/FileMetadataStoreTests.cs
@@ -0,0 +1,83 @@
+using Meridian.Services;
+using Xunit;
+
+namespace Meridian.Core.Tests;
+
+public sealed class FileMetadataStoreTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "MeridianMeta-" + Guid.NewGuid().ToString("N"));
+ private readonly string _file;
+
+ public FileMetadataStoreTests()
+ {
+ Directory.CreateDirectory(_root);
+ _file = Path.Combine(_root, "file-metadata.json");
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
+ }
+
+ [Fact]
+ public async Task SetAndReload_RoundTrips()
+ {
+ var store = new FileMetadataStore(_file);
+ await store.SetAsync(@"C:\docs\report.pdf", new FileMetadata(["work", "urgent"], 4, "For Friday"));
+ var reloaded = new FileMetadataStore(_file);
+ await reloaded.LoadAsync();
+ var metadata = reloaded.Get(@"c:\DOCS\report.pdf"); // paths match case-insensitively
+ Assert.NotNull(metadata);
+ Assert.Equal(["work", "urgent"], metadata.Tags);
+ Assert.Equal(4, metadata.Rating);
+ Assert.Equal("For Friday", metadata.Comment);
+ }
+
+ [Fact]
+ public async Task Set_NormalizesTagsRatingAndComment()
+ {
+ var store = new FileMetadataStore(_file);
+ await store.SetAsync(@"C:\a.txt", new FileMetadata([" Work ", "work", "", "later"], 9, " hi "));
+ var metadata = store.Get(@"C:\a.txt");
+ Assert.NotNull(metadata);
+ Assert.Equal(["Work", "later"], metadata.Tags);
+ Assert.Equal(5, metadata.Rating);
+ Assert.Equal("hi", metadata.Comment);
+ }
+
+ [Fact]
+ public async Task Set_EmptyMetadata_RemovesTheEntry()
+ {
+ var store = new FileMetadataStore(_file);
+ await store.SetAsync(@"C:\a.txt", new FileMetadata(["x"], 1, ""));
+ await store.SetAsync(@"C:\a.txt", new FileMetadata([], 0, " "));
+ Assert.Null(store.Get(@"C:\a.txt"));
+ Assert.Equal(0, store.Count);
+ }
+
+ [Fact]
+ public async Task Load_DamagedFile_ReadsEmpty()
+ {
+ await File.WriteAllTextAsync(_file, "{ not json");
+ var store = new FileMetadataStore(_file);
+ await store.LoadAsync();
+ Assert.Equal(0, store.Count);
+ }
+
+ [Fact]
+ public async Task AllTags_DistinctAndSorted()
+ {
+ var store = new FileMetadataStore(_file);
+ await store.SetAsync(@"C:\a.txt", new FileMetadata(["work", "Later"], 0, ""));
+ await store.SetAsync(@"C:\b.txt", new FileMetadata(["home", "WORK"], 0, ""));
+ Assert.Equal(["home", "Later", "work"], store.AllTags());
+ }
+
+ [Fact]
+ public void ParseTags_SplitsTrimsAndDedupes()
+ {
+ Assert.Equal(["work", "urgent", "later"], FileMetadataStore.ParseTags(" work, urgent; later, WORK "));
+ Assert.Empty(FileMetadataStore.ParseTags(" "));
+ Assert.Empty(FileMetadataStore.ParseTags(null));
+ }
+}
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")));
+ }
+}