From ba96f0e8fe29d0d6f3e4304e6b4ee713b7bb5b4b Mon Sep 17 00:00:00 2001 From: SeanShaffer88 <252387677+SeanShaffer88@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:19:03 +0000 Subject: [PATCH 1/9] Add multi-item batch rename (F2 / Rename command) Select several files or folders and rename them at once. Every new name is validated against the folder's existing names before anything is renamed, and one item that cannot be renamed never stops the others: each result is reported per item, successes are applied, and failures are listed with their reasons. Core: FileOperations.RenameItems renames independently and returns a RenameResult per item; ValidateNewName is now public so the UI can pre-flight every row. UI: the rename dialog shows one editable row per selection with per-row error text, keeps the dialog open on validation problems, refreshes the pane afterward, and shows a failures dialog when any item fails. F2 and context-menu Rename now work on multi-selections (pluralized menu label and tooltip included). Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- CHANGELOG.md | 2 + README.md | 2 +- src/Meridian.App/MainWindow.xaml.cs | 184 +++++++++++++++++- src/Meridian.Core/Services/FileOperations.cs | 30 ++- .../FileOperationsTests.cs | 79 ++++++++ 5 files changed, 288 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d12503e6..5d73957a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Batch rename: select multiple files or folders and rename them all at once from the Rename command or F2. Every new name is checked before anything is renamed, one item that can't be renamed never stops the others, and the outcome is reported per item. + ## 0.5.0 - The dark theme is darker: the window background, panels and raised areas are now near-black tones, still one visible step apart so the sidebar, panes and header stay distinct. The light theme is unchanged. diff --git a/README.md b/README.md index bd910de2..d1373409 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ These features are **implemented on the main development branch**. They are not | **Breadcrumb path bar** | Each pane's path is a breadcrumb bar: one button per level from the drive root (or network share) down to the current folder — click any level to jump there. Clicking the empty stretch of the bar, or pressing `Ctrl+L`, swaps in a text box holding the full path: Enter opens it, Escape returns to the breadcrumbs. Long folder names compress with an ellipsis and show their full path as a tooltip. Segments accept drops (onto that level's folder); the bar's empty stretch drops into the current folder. | | **Preview block** | Each pane has a preview block at its bottom, replacing the old right-side details panel: a large preview of the selected item (an image preview, the opening of a text file, or a folder treatment with its measured size and counts), its name, type, created and modified dates, and size, plus photo details and the "Opens in" app where they exist. With nothing selected it describes the current folder. Its header line always shows the folder's item count and total size — folders count only once measured, never walked just for the line — and the selection's count and size while anything is selected. Each pane's block collapses on its own chevron and remembers its state; a details panel you had open becomes both blocks expanded. | | **File hash** | Tools → File hash… shows the SHA-256 fingerprint of each selected file, with a copy button per file and a compare field that reports "Matches " or "Does not match" for a hash you paste. | -| **Conservative file actions** | Copy, move, rename, create folders, view basic properties, and request Recycle Bin deletion. | +| **Conservative file actions** | Copy, move, rename — including multi-item batch rename (F2 or the Rename command, each name checked before anything changes), create folders, view basic properties, and request Recycle Bin deletion. | | **Universal Viewer** | Preview text (UTF-8/UTF-16), hex, and images, and play audio/video with Windows media controls and no autoplay. Reads are bounded and read-only; playback behavior is not yet verified on real devices. | | **Archives** | Inspect ZIP/TAR/TGZ contents read-only, create an archive from selected files and folders, or extract into a new no-overwrite folder under entry-policy and size limits, with cancellation. | | **TimeVault (read-only)** | Discover existing Windows shadow copies and list a snapshot's version of the current folder. Discovery only — no recovery operations, and real VSS access is unverified. | diff --git a/src/Meridian.App/MainWindow.xaml.cs b/src/Meridian.App/MainWindow.xaml.cs index 6a1d9139..76459177 100644 --- a/src/Meridian.App/MainWindow.xaml.cs +++ b/src/Meridian.App/MainWindow.xaml.cs @@ -1365,8 +1365,7 @@ private async Task HandleColumnKeyDownAsync(PaneState pane, ColumnViewState colu break; case VirtualKey.F2: e.Handled = true; - if (pane.SelectionList.SelectedItems.Count == 1) await RenameSelectedAsync(pane); - else if (pane.SelectionList.SelectedItems.Count > 1) SetStatus("Select a single item to rename.", StatusSeverity.Warning); + await RenameSelectedAsync(pane); break; case VirtualKey.Space: { @@ -2856,13 +2855,24 @@ private async Task CreateNewFolderAsync() private async Task RenameSelectedAsync(PaneState pane) { - if (pane.SelectionList.SelectedItem is not FileEntry selected) + var selection = GetSelectedEntries(pane); + if (selection.Count == 0) { SetStatus("Select a file or folder to rename.", StatusSeverity.Warning); return; } + if (selection.Count == 1) + { + await RenameOneAsync(pane, selection[0]); + return; + } + await RenameManyAsync(pane, selection); + } - var parentPath = pane.CurrentPath; + /// Renames exactly one item with a single name box (the + /// historical behavior, still used for one selection and for F2). + private async Task RenameOneAsync(PaneState pane, FileEntry selected) + { var nameBox = new TextBox { Text = selected.Name, MinWidth = 280, SelectionStart = 0, SelectionLength = selected.Name.Length }; AutomationProperties.SetName(nameBox, "New name"); var renameContent = new StackPanel { Spacing = 8 }; @@ -2900,6 +2910,163 @@ private async Task RenameSelectedAsync(PaneState pane) } } + /// Renames several selected items at once: one row per item with + /// its current name and an editable new name. Every name is validated + /// against the folder's existing names before anything is renamed, and + /// one item's failure never stops another. + private async Task RenameManyAsync(PaneState pane, IReadOnlyList selection) + { + var existingNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in pane.AllItems) + existingNames.Add(entry.Name); + + var rows = new List<(FileEntry Entry, TextBox NameBox, TextBlock ErrorText)>(); + var rowPanel = new StackPanel { Spacing = 8 }; + foreach (var entry in selection) + { + var original = new TextBlock + { + Text = entry.Name, + FontSize = 12, + Foreground = ThemeBrush("MeridianTextPrimaryBrush"), + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = 260 + }; + ToolTipService.SetToolTip(original, entry.FullPath); + var nameBox = new TextBox + { + Text = entry.Name, + MinWidth = 300, + VerticalAlignment = VerticalAlignment.Center, + SelectionStart = 0, + SelectionLength = entry.Name.Length + }; + AutomationProperties.SetName(nameBox, $"New name for {entry.Name}"); + var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 }; + header.Children.Add(original); + header.Children.Add(nameBox); + var errorText = new TextBlock + { + Foreground = ThemeBrush("MeridianErrorBrush"), + FontSize = 11, + TextWrapping = TextWrapping.Wrap, + Visibility = Visibility.Collapsed + }; + var row = new StackPanel { Spacing = 4 }; + row.Children.Add(header); + row.Children.Add(errorText); + rowPanel.Children.Add(row); + rows.Add((entry, nameBox, errorText)); + } + + var instruction = new TextBlock + { + Text = "Type a new name for each item. A name must not already exist in this folder, and no two items may share a name.", + TextWrapping = TextWrapping.Wrap + }; + var summary = new TextBlock + { + Foreground = ThemeBrush("MeridianErrorBrush"), + FontSize = 11, + TextWrapping = TextWrapping.Wrap, + Visibility = Visibility.Collapsed + }; + + var content = new StackPanel { Spacing = 12 }; + content.Children.Add(instruction); + content.Children.Add(new ScrollViewer { Content = rowPanel, MaxHeight = 440, VerticalScrollBarVisibility = ScrollBarVisibility.Auto }); + content.Children.Add(summary); + + var dialog = new ContentDialog + { + Title = $"Rename {selection.Count:N0} items", + Content = content, + PrimaryButtonText = "Rename", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Primary, + XamlRoot = Content.XamlRoot + }; + + dialog.PrimaryButtonClick += async (s, e) => + { + var deferral = e.GetDeferral(); + try + { + var problemCount = 0; + var proposedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (entry, nameBox, errorText) in rows) + { + string? problem = null; + var newName = nameBox.Text; + if (string.IsNullOrWhiteSpace(newName)) + problem = "Enter a name."; + else + { + try { FileOperations.ValidateNewName(newName); } + catch (ArgumentException ex) { problem = ex.Message; } + if (problem is null && !proposedNames.Add(newName)) + problem = $"“{newName}” is used more than once in this rename."; + if (problem is null && + !string.Equals(newName, entry.Name, StringComparison.OrdinalIgnoreCase) && + existingNames.Contains(newName)) + problem = $"“{newName}” already exists in this folder."; + } + errorText.Text = problem ?? string.Empty; + errorText.Visibility = problem is null ? Visibility.Collapsed : Visibility.Visible; + if (problem is not null) problemCount++; + } + summary.Text = problemCount == 0 + ? string.Empty + : $"Fix {problemCount} problem(s) before renaming."; + summary.Visibility = problemCount == 0 ? Visibility.Collapsed : Visibility.Visible; + if (problemCount > 0) + { + e.Cancel = true; + return; + } + + var inputs = rows.Select(row => (row.Entry.FullPath, row.NameBox.Text)).ToArray(); + var results = FileOperations.RenameItems(inputs); + var succeeded = results.Count(result => result.Error is null); + var failed = results.Count - succeeded; + + await NavigateAsync(pane, pane.CurrentPath, addHistory: false); + + if (failed == 0) + SetStatus($"Renamed {succeeded:N0} item(s).", StatusSeverity.Success); + else + { + var failures = new StackPanel { Spacing = 4 }; + foreach (var result in results.Where(result => result.Error is not null)) + { + failures.Children.Add(new TextBlock + { + Text = $"{Path.GetFileName(result.SourcePath)} → {result.NewName}: {result.Error}", + TextWrapping = TextWrapping.Wrap, + Foreground = ThemeBrush("MeridianErrorBrush") + }); + } + var resultDialog = new ContentDialog + { + Title = $"Renamed {succeeded:N0} of {results.Count:N0} item(s)", + Content = new ScrollViewer { Content = failures, MaxHeight = 340, VerticalScrollBarVisibility = ScrollBarVisibility.Auto }, + CloseButtonText = "Close", + XamlRoot = Content.XamlRoot + }; + await resultDialog.ShowAsync(); + SetStatus($"Renamed {succeeded:N0} of {results.Count:N0} item(s) · {failed} failed.", StatusSeverity.Warning); + } + } + finally + { + deferral.Complete(); + } + }; + + await dialog.ShowAsync(); + } + private async void PropertiesSelected_Click(object sender, RoutedEventArgs e) => await PropertiesSelectedAsync(_activePane); private async Task PropertiesSelectedAsync(PaneState pane) @@ -4174,8 +4341,7 @@ private async Task HandleListKeyDownAsync(PaneState pane, KeyRoutedEventArgs e) break; case VirtualKey.F2: e.Handled = true; - if (pane.SelectionList.SelectedItems.Count == 1) await RenameSelectedAsync(pane); - else if (pane.SelectionList.SelectedItems.Count > 1) SetStatus("Select a single item to rename.", StatusSeverity.Warning); + await RenameSelectedAsync(pane); break; case VirtualKey.Enter: e.Handled = true; @@ -4205,10 +4371,14 @@ private void UpdateContextMenuItems(PaneState pane, bool isLeftPane, MenuFlyoutI var direction = isLeftPane ? "right" : "left"; copyItem.IsEnabled = count > 0 && hasDestination; moveItem.IsEnabled = count > 0 && hasDestination; - renameItem.IsEnabled = count == 1; + renameItem.IsEnabled = count > 0; propertiesItem.IsEnabled = count == 1; shelfItem.IsEnabled = count > 0; deleteItem.IsEnabled = count > 0; + renameItem.Text = count > 1 ? $"Rename {count:N0} items" : "Rename"; + ToolTipService.SetToolTip(renameItem, count > 1 + ? $"Rename the {count:N0} selected items (F2). Each name is checked before anything is renamed." + : "Rename the selected item (F2)."); copyItem.Text = count > 1 ? $"Copy {count:N0} items to {direction} pane" : $"Copy to {direction} pane"; moveItem.Text = count > 1 ? $"Move {count:N0} items to {direction} pane" : $"Move to {direction} pane"; shelfItem.Text = count > 1 ? $"Add {count:N0} items to Drop Shelf" : "Add to Drop Shelf"; diff --git a/src/Meridian.Core/Services/FileOperations.cs b/src/Meridian.Core/Services/FileOperations.cs index 363d84ce..778a7223 100644 --- a/src/Meridian.Core/Services/FileOperations.cs +++ b/src/Meridian.Core/Services/FileOperations.cs @@ -36,6 +36,34 @@ public static string RenameItem(string sourcePath, string newName) return destination; } + /// The outcome of renaming one item in a batch: the path that + /// was renamed, the name requested, the resulting path on success, or the + /// error that stopped that one item without touching the others. + public sealed record RenameResult(string SourcePath, string NewName, string? ResultPath, string? Error); + + /// Renames each item independently, in the order given. One + /// item's failure never stops another: every result reports success or + /// the error that applied to that item only, so callers can show + /// per-item outcomes and keep everything that succeeded. + public static IReadOnlyList RenameItems(IEnumerable<(string SourcePath, string NewName)> items) + { + ArgumentNullException.ThrowIfNull(items); + var results = new List(); + foreach (var (sourcePath, newName) in items) + { + try + { + var resultPath = RenameItem(sourcePath, newName); + results.Add(new RenameResult(sourcePath, newName, resultPath, null)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or System.Security.SecurityException) + { + results.Add(new RenameResult(sourcePath, newName, null, ex.Message)); + } + } + return results; + } + // File.Move can emulate cross-volume moves with copy/delete. Do not enable // MOVEFILE_COPY_ALLOWED or MOVEFILE_REPLACE_EXISTING: an unsupported rename // must fail without staging a copy or removing the source. @@ -95,7 +123,7 @@ public static string CreateFolder(string parentDirectory, string name) [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] private static extern bool CreateDirectory(string path, IntPtr securityAttributes); - private static void ValidateNewName(string name) + public static void ValidateNewName(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); if (name is "." or ".." || name.Length > 255 || name.EndsWith('.') || diff --git a/tests/Meridian.Core.Tests/FileOperationsTests.cs b/tests/Meridian.Core.Tests/FileOperationsTests.cs index 027db8f6..ee69682c 100644 --- a/tests/Meridian.Core.Tests/FileOperationsTests.cs +++ b/tests/Meridian.Core.Tests/FileOperationsTests.cs @@ -343,6 +343,85 @@ public void MoveItem_RejectsMovingDirectoryIntoDescendant() Assert.Empty(Directory.GetDirectories(descendant)); } + [Fact] + public void RenameItems_RenamesEachItemInTheBatch() + { + using var temp = new TemporaryDirectory(); + var parent = Directory.CreateDirectory(temp.Path).FullName; + var first = Path.Combine(parent, "first.txt"); + File.WriteAllText(first, "contents one"); + var second = Path.Combine(parent, "second.txt"); + File.WriteAllText(second, "contents two"); + + var results = FileOperations.RenameItems(new[] + { + (first, "alpha.txt"), + (second, "beta.txt"), + }); + + Assert.Equal(2, results.Count); + Assert.All(results, result => Assert.Null(result.Error)); + Assert.Equal(Path.Combine(parent, "alpha.txt"), results[0].ResultPath); + Assert.Equal(Path.Combine(parent, "beta.txt"), results[1].ResultPath); + Assert.False(File.Exists(first)); + Assert.False(File.Exists(second)); + Assert.Equal("contents one", File.ReadAllText(results[0].ResultPath!)); + Assert.Equal("contents two", File.ReadAllText(results[1].ResultPath!)); + } + + [Fact] + public void RenameItems_KeepsGoingWhenOneItemFails() + { + using var temp = new TemporaryDirectory(); + var parent = Directory.CreateDirectory(temp.Path).FullName; + var good = Path.Combine(parent, "good.txt"); + File.WriteAllText(good, "keep"); + var blocked = Path.Combine(parent, "blocked.txt"); + File.WriteAllText(blocked, "stays"); + var taken = Path.Combine(parent, "taken.txt"); + File.WriteAllText(taken, "occupant"); + + var results = FileOperations.RenameItems(new[] + { + (good, "renamed.txt"), + (blocked, ".txt"), + (taken, "renamed.txt"), + }); + + Assert.Equal(3, results.Count); + Assert.Null(results[0].Error); + Assert.Equal(Path.Combine(parent, "renamed.txt"), results[0].ResultPath); + Assert.NotNull(results[1].Error); + Assert.NotNull(results[2].Error); + Assert.True(File.Exists(blocked)); + Assert.True(File.Exists(taken)); + Assert.False(File.Exists(good)); + Assert.Equal("keep", File.ReadAllText(Path.Combine(parent, "renamed.txt"))); + } + + [Fact] + public void RenameItems_CaseOnlyChangeRenamesInPlace() + { + using var temp = new TemporaryDirectory(); + var parent = Directory.CreateDirectory(temp.Path).FullName; + var source = Path.Combine(parent, "Casing.Txt"); + File.WriteAllText(source, "same bytes"); + + var results = FileOperations.RenameItems(new[] { (source, "casing.txt") }); + + Assert.Single(results); + Assert.Null(results[0].Error); + Assert.False(File.Exists(source)); + Assert.True(File.Exists(Path.Combine(parent, "casing.txt"))); + } + + [Fact] + public void RenameItems_EmptyBatchReturnsEmptyResults() + { + var results = FileOperations.RenameItems(Array.Empty<(string SourcePath, string NewName)>()); + Assert.Empty(results); + } + private sealed class TemporaryDirectory : IDisposable { public string Path { get; } = System.IO.Path.Combine( From aeeae5a03c12d482c8fa43505b7583e9338c3fdc Mon Sep 17 00:00:00 2001 From: SeanShaffer88 <252387677+SeanShaffer88@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:24:08 +0000 Subject: [PATCH 2/9] Add color tags (OneCommander Pro feature) Mark files and folders with a colored indicator (red, orange, yellow, green, blue, purple, gray, or none) from the right-click menu in either pane. Tags are a visual supplement to the item name, never carry meaning by color alone, and each tag has a plain-text accessibility label. Core: FileTagColor enum, a fixed restrained palette with labels, and FileTagStore persisting path -> color in an atomic JSON file under %LOCALAPPDATA%/Meridian. FileEntry carries a nullable TagColor that is rehydrated from the store after every folder read. UI: a small tag badge in the Details, Grid and Tiles row templates, plus a Tag submenu (None and the seven colors) on both pane context menus. Tagging applies to every selected item at once, saves immediately, and updates the rows live through change notification. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 2 +- src/Meridian.App/App.xaml | 5 +- .../Converters/FileTagToBrushConverter.cs | 23 +++ .../Converters/FileTagToLabelConverter.cs | 19 +++ .../FileTagToVisibilityConverter.cs | 21 +++ src/Meridian.App/MainWindow.xaml | 70 ++++++++- src/Meridian.App/MainWindow.xaml.cs | 62 +++++++- src/Meridian.Core/Models/FileEntry.cs | 16 ++ src/Meridian.Core/Models/FileTagColor.cs | 20 +++ src/Meridian.Core/Services/FileTagPalette.cs | 47 ++++++ src/Meridian.Core/Services/FileTagStore.cs | 136 ++++++++++++++++ .../FileTagPaletteTests.cs | 47 ++++++ .../Meridian.Core.Tests/FileTagStoreTests.cs | 145 ++++++++++++++++++ 14 files changed, 606 insertions(+), 8 deletions(-) create mode 100644 src/Meridian.App/Converters/FileTagToBrushConverter.cs create mode 100644 src/Meridian.App/Converters/FileTagToLabelConverter.cs create mode 100644 src/Meridian.App/Converters/FileTagToVisibilityConverter.cs create mode 100644 src/Meridian.Core/Models/FileTagColor.cs create mode 100644 src/Meridian.Core/Services/FileTagPalette.cs create mode 100644 src/Meridian.Core/Services/FileTagStore.cs create mode 100644 tests/Meridian.Core.Tests/FileTagPaletteTests.cs create mode 100644 tests/Meridian.Core.Tests/FileTagStoreTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d73957a..44518f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Batch rename: select multiple files or folders and rename them all at once from the Rename command or F2. Every new name is checked before anything is renamed, one item that can't be renamed never stops the others, and the outcome is reported per item. +- Color tags: mark files and folders with a colored indicator (red, orange, yellow, green, blue, purple, gray or none) from the right-click menu in either pane. Tags persist across sessions and travel with the item row wherever it is shown. ## 0.5.0 diff --git a/README.md b/README.md index d1373409..795053cc 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ These features are **implemented on the main development branch**. They are not | **Breadcrumb path bar** | Each pane's path is a breadcrumb bar: one button per level from the drive root (or network share) down to the current folder — click any level to jump there. Clicking the empty stretch of the bar, or pressing `Ctrl+L`, swaps in a text box holding the full path: Enter opens it, Escape returns to the breadcrumbs. Long folder names compress with an ellipsis and show their full path as a tooltip. Segments accept drops (onto that level's folder); the bar's empty stretch drops into the current folder. | | **Preview block** | Each pane has a preview block at its bottom, replacing the old right-side details panel: a large preview of the selected item (an image preview, the opening of a text file, or a folder treatment with its measured size and counts), its name, type, created and modified dates, and size, plus photo details and the "Opens in" app where they exist. With nothing selected it describes the current folder. Its header line always shows the folder's item count and total size — folders count only once measured, never walked just for the line — and the selection's count and size while anything is selected. Each pane's block collapses on its own chevron and remembers its state; a details panel you had open becomes both blocks expanded. | | **File hash** | Tools → File hash… shows the SHA-256 fingerprint of each selected file, with a copy button per file and a compare field that reports "Matches " or "Does not match" for a hash you paste. | -| **Conservative file actions** | Copy, move, rename — including multi-item batch rename (F2 or the Rename command, each name checked before anything changes), create folders, view basic properties, and request Recycle Bin deletion. | +| **Conservative file actions** | Copy, move, rename — including multi-item batch rename (F2 or the Rename command, each name checked before anything changes), create folders, view basic properties, request Recycle Bin deletion, and apply color tags to files and folders. | | **Universal Viewer** | Preview text (UTF-8/UTF-16), hex, and images, and play audio/video with Windows media controls and no autoplay. Reads are bounded and read-only; playback behavior is not yet verified on real devices. | | **Archives** | Inspect ZIP/TAR/TGZ contents read-only, create an archive from selected files and folders, or extract into a new no-overwrite folder under entry-policy and size limits, with cancellation. | | **TimeVault (read-only)** | Discover existing Windows shadow copies and list a snapshot's version of the current folder. Discovery only — no recovery operations, and real VSS access is unverified. | diff --git a/src/Meridian.App/App.xaml b/src/Meridian.App/App.xaml index 884f8c10..e307af22 100644 --- a/src/Meridian.App/App.xaml +++ b/src/Meridian.App/App.xaml @@ -1,9 +1,12 @@ - + + + + - + + + + @@ -38,7 +44,13 @@ - + + + + @@ -46,14 +58,26 @@ - + + + + - + + + + @@ -64,6 +88,10 @@ + @@ -74,6 +102,10 @@ + @@ -290,6 +322,21 @@ + + + + + + + + + + + + + + + @@ -403,6 +450,21 @@ + + + + + + + + + + + + + + + diff --git a/src/Meridian.App/MainWindow.xaml.cs b/src/Meridian.App/MainWindow.xaml.cs index 76459177..85fbb5f9 100644 --- a/src/Meridian.App/MainWindow.xaml.cs +++ b/src/Meridian.App/MainWindow.xaml.cs @@ -44,6 +44,8 @@ public sealed partial class MainWindow : Window private double _splitDragStartX; private double _splitDragStartFraction; private readonly DropShelf _dropShelf = new(); + private readonly FileTagStore _tagStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "file-tags.json")); + private PaneState? _tagContextPane; private readonly RecentPathsStore _recentsStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "recents.json")); private readonly FolderViewStore _folderViewStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "folder-views.json")); private readonly FolderSizeCache _folderSizeCache = new(); @@ -156,6 +158,7 @@ private async Task InitializeAsync(string documents, string downloads) _nameCollisionPolicy = ParseCollisionPolicy(settings.NameCollisionPolicy); _transfersStripCollapsed = settings.TransfersStripCollapsed; } + await _tagStore.LoadAsync(); await InitializeTransfersAsync(); try { @@ -371,6 +374,7 @@ private async Task NavigateAsync(PaneState pane, string requestedPath, boo UpdatePaneHeader(pane); ApplyFolderViewState(pane); pane.AllItems = entries; + RehydrateTags(pane); ApplyFilter(pane); ApplyPaneMode(pane); if (pane.Layout == PaneLayout.Columns) SyncColumnsAfterNavigation(pane, fullPath); @@ -2207,6 +2211,15 @@ private void ApplyFilter(PaneState? pane) : $"{matches.Count:N0} of {pane.AllItems.Count:N0} items · filtered"); } + /// Rehydrates each entry's color tag from the tag store so a + /// fresh folder read shows the tags saved in a previous session. + private void RehydrateTags(PaneState pane) + { + if (pane.AllItems is null) return; + foreach (var entry in pane.AllItems) + entry.TagColor = _tagStore.GetTag(entry.FullPath); + } + private void LeftSort_SelectionChanged(object sender, SelectionChangedEventArgs e) => UpdateSort(_left, LeftSort, LeftSortDescending); private void RightSort_SelectionChanged(object sender, SelectionChangedEventArgs e) => UpdateSort(_right, RightSort, RightSortDescending); private void LeftSortDirection_Changed(object sender, RoutedEventArgs e) => UpdateSort(_left, LeftSort, LeftSortDescending); @@ -4350,8 +4363,18 @@ private async Task HandleListKeyDownAsync(PaneState pane, KeyRoutedEventArgs e) } } - private void LeftContextMenu_Opening(object sender, object e) => UpdateContextMenu(_left, isLeftPane: true); - private void RightContextMenu_Opening(object sender, object e) => UpdateContextMenu(_right, isLeftPane: false); + private void LeftContextMenu_Opening(object sender, object e) + { + _tagContextPane = _left; + UpdateContextMenu(_left, isLeftPane: true); + LeftTagMenuItem.IsEnabled = _left.SelectionList.SelectedItems.Count > 0; + } + private void RightContextMenu_Opening(object sender, object e) + { + _tagContextPane = _right; + UpdateContextMenu(_right, isLeftPane: false); + RightTagMenuItem.IsEnabled = _right.SelectionList.SelectedItems.Count > 0; + } private void UpdateContextMenu(PaneState pane, bool isLeftPane) { @@ -4385,6 +4408,41 @@ private void UpdateContextMenuItems(PaneState pane, bool isLeftPane, MenuFlyoutI deleteItem.Text = count > 1 ? $"Delete {count:N0} items (Recycle Bin)" : "Delete (Recycle Bin)"; } + private void TagFlyout_Opening(object sender, object e) + { + var pane = _tagContextPane; + var hasSelection = pane is not null && pane.SelectionList.SelectedItems.Count > 0; + if (sender is MenuFlyout flyout) + { + foreach (var item in flyout.Items.OfType()) + item.IsEnabled = hasSelection; + } + } + + private async void TagColor_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuFlyoutItem item) return; + if (!Enum.TryParse(item.Text, ignoreCase: true, out var color)) return; + var pane = _tagContextPane; + if (pane is null) return; + await ApplyTagToSelectionAsync(pane, color); + } + + /// Assigns one color tag to every selected item, saves it, and + /// refreshes the rows so the tag badges appear immediately. + private async Task ApplyTagToSelectionAsync(PaneState pane, FileTagColor color) + { + var selection = GetSelectedEntries(pane); + if (selection.Count == 0) return; + await _tagStore.SetTagsAsync(selection.Select(item => item.FullPath), color); + foreach (var entry in selection) entry.TagColor = color; + var label = FileTagPalette.LabelFor(color); + SetStatus(color is FileTagColor.None + ? $"Cleared tags on {selection.Count:N0} item(s)." + : $"Tagged {selection.Count:N0} item(s) {label}.", + StatusSeverity.Success); + } + private async void RenameFromLeft_Click(object sender, RoutedEventArgs e) => await RenameSelectedAsync(_left); private async void RenameFromRight_Click(object sender, RoutedEventArgs e) => await RenameSelectedAsync(_right); private async void PropertiesFromLeft_Click(object sender, RoutedEventArgs e) => await PropertiesSelectedAsync(_left); diff --git a/src/Meridian.Core/Models/FileEntry.cs b/src/Meridian.Core/Models/FileEntry.cs index 2a32dfde..b815d3cf 100644 --- a/src/Meridian.Core/Models/FileEntry.cs +++ b/src/Meridian.Core/Models/FileEntry.cs @@ -48,6 +48,22 @@ public string? FolderSizeLabel public event PropertyChangedEventHandler? PropertyChanged; + private FileTagColor? _tagColor; + + /// The color tag attached to this item, or null when untagged. + /// Tags are applied by the window after a folder read from the tag store, + /// so a fresh read rehydrates the tag for each entry. + public FileTagColor? TagColor + { + get => _tagColor; + set + { + if (Equals(_tagColor, value)) return; + _tagColor = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TagColor))); + } + } + public string Glyph => IsDirectory ? "\uE8B7" : "\uE7C3"; /// Chevron shown beside folders in the Miller-columns view, where a folder opens into the next column. diff --git a/src/Meridian.Core/Models/FileTagColor.cs b/src/Meridian.Core/Models/FileTagColor.cs new file mode 100644 index 00000000..e9ea15f1 --- /dev/null +++ b/src/Meridian.Core/Models/FileTagColor.cs @@ -0,0 +1,20 @@ +namespace Meridian.Models; + +/// The color tags a user can attach to a file or folder. +/// +/// Color tags are a visual supplement to the item name — they never carry +/// meaning by color alone, and every tag has a plain-text label. The palette +/// is fixed and restrained so tags stay readable on both the dark and light +/// themes without following the accent color. +/// +public enum FileTagColor +{ + None, + Red, + Orange, + Yellow, + Green, + Blue, + Purple, + Gray, +} diff --git a/src/Meridian.Core/Services/FileTagPalette.cs b/src/Meridian.Core/Services/FileTagPalette.cs new file mode 100644 index 00000000..d3967247 --- /dev/null +++ b/src/Meridian.Core/Services/FileTagPalette.cs @@ -0,0 +1,47 @@ +using Windows.UI; + +namespace Meridian.Services; + +/// Maps each to a restrained, +/// theme-independent color and its plain-text label. +public static class FileTagPalette +{ + private static readonly (FileTagColor Tag, Color Color, string Label)[] Entries = + [ + (FileTagColor.None, Colors.Transparent, "No tag"), + (FileTagColor.Red, ColorFromArgb(0xFF, 0xE4, 0x58, 0x4C), "Red tag"), + (FileTagColor.Orange, ColorFromArgb(0xFF, 0xE8, 0x94, 0x3C), "Orange tag"), + (FileTagColor.Yellow, ColorFromArgb(0xFF, 0xE5, 0xC0, 0x3C), "Yellow tag"), + (FileTagColor.Green, ColorFromArgb(0xFF, 0x6F, 0xB0, 0x5A), "Green tag"), + (FileTagColor.Blue, ColorFromArgb(0xFF, 0x4C, 0x8B, 0xF5), "Blue tag"), + (FileTagColor.Purple, ColorFromArgb(0xFF, 0xB5, 0x7B, 0xDD), "Purple tag"), + (FileTagColor.Gray, ColorFromArgb(0xFF, 0x8A, 0x8F, 0x98), "Gray tag"), + ]; + + /// The color to paint a tag indicator, or null when untagged. + public static Color? ColorFor(FileTagColor? tag) + { + if (tag is null or FileTagColor.None) return null; + return EntryFor(tag.Value).Color; + } + + /// The plain-text label for a tag, used by accessibility names. + public static string LabelFor(FileTagColor? tag) + { + if (tag is null or FileTagColor.None) return "No tag"; + return EntryFor(tag.Value).Label; + } + + /// True when a tag is set. + public static bool IsTagged(FileTagColor? tag) => tag is not null and not FileTagColor.None; + + private static (FileTagColor Tag, Color Color, string Label) EntryFor(FileTagColor tag) + { + foreach (var entry in Entries) + if (entry.Tag == tag) return entry; + // A future enum value without a palette entry renders as untagged. + return Entries[0]; + } + + private static Color ColorFromArgb(byte a, byte r, byte g, byte b) => Color.FromArgb(a, r, g, b); +} diff --git a/src/Meridian.Core/Services/FileTagStore.cs b/src/Meridian.Core/Services/FileTagStore.cs new file mode 100644 index 00000000..7f2ae8bb --- /dev/null +++ b/src/Meridian.Core/Services/FileTagStore.cs @@ -0,0 +1,136 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Meridian.Models; + +namespace Meridian.Services; + +/// +/// Persists the color tag attached to each file or folder: a mapping of +/// full path to tag color. Tags are a convenience — a missing or damaged +/// tag file reads as empty and never prevents the app from opening, and the +/// in-memory tags stay correct for the session even if a save fails. +/// +public sealed class FileTagStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + }; + private readonly string _filePath; + private readonly Dictionary _tags = new(StringComparer.OrdinalIgnoreCase); + + public FileTagStore(string filePath) => _filePath = Path.GetFullPath(filePath); + + /// The current tags, path to tag color. + public IReadOnlyDictionary Tags => _tags; + + /// Loads the saved tags, replacing the in-memory map. A missing + /// or unreadable file yields an empty map. + public async Task LoadAsync() + { + _tags.Clear(); + try + { + if (!File.Exists(_filePath)) return; + await using var stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true); + var saved = await JsonSerializer.DeserializeAsync>(stream, JsonOptions); + if (saved is null) return; + foreach (var (path, color) in saved) + { + if (string.IsNullOrWhiteSpace(path)) continue; + _tags[path.Trim()] = color; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + // A damaged tag file must not prevent the app from opening. + _tags.Clear(); + } + } + + /// The tag on one path, or null when untagged. + public FileTagColor? GetTag(string path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + return _tags.TryGetValue(path.Trim(), out var color) ? color : null; + } + + /// Assigns (or clears, when is null or + /// ) the tag on one path and saves. + public async Task SetTagAsync(string path, FileTagColor? color) + { + if (string.IsNullOrWhiteSpace(path)) return; + var trimmed = path.Trim(); + if (color is null or FileTagColor.None) + _tags.Remove(trimmed); + else + _tags[trimmed] = color.Value; + await SaveQuietlyAsync(); + } + + /// Assigns one tag to every path given. + public async Task SetTagsAsync(IEnumerable paths, FileTagColor color) + { + var changed = false; + foreach (var path in paths) + { + if (string.IsNullOrWhiteSpace(path)) continue; + var trimmed = path.Trim(); + if (color is FileTagColor.None) + { + if (_tags.Remove(trimmed)) changed = true; + } + else if (!_tags.TryGetValue(trimmed, out var existing) || !Equals(existing, color)) + { + _tags[trimmed] = color; + changed = true; + } + } + if (changed) await SaveQuietlyAsync(); + } + + /// Removes the tag from every path given. + public async Task ClearTagsAsync(IEnumerable paths) + { + var changed = false; + foreach (var path in paths) + { + if (string.IsNullOrWhiteSpace(path)) continue; + if (_tags.Remove(path.Trim())) changed = true; + } + if (changed) await SaveQuietlyAsync(); + } + + private async Task SaveQuietlyAsync() + { + string? temporaryPath = null; + try + { + var directory = Path.GetDirectoryName(_filePath)!; + Directory.CreateDirectory(directory); + temporaryPath = _filePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, useAsync: true)) + { + await JsonSerializer.SerializeAsync(stream, _tags, JsonOptions); + await stream.FlushAsync(); + } + + File.Move(temporaryPath, _filePath, overwrite: true); + temporaryPath = null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + // Best-effort: the in-memory tags stay correct for this session. + } + finally + { + if (temporaryPath is not null) + { + try { File.Delete(temporaryPath); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + } +} diff --git a/tests/Meridian.Core.Tests/FileTagPaletteTests.cs b/tests/Meridian.Core.Tests/FileTagPaletteTests.cs new file mode 100644 index 00000000..f001f766 --- /dev/null +++ b/tests/Meridian.Core.Tests/FileTagPaletteTests.cs @@ -0,0 +1,47 @@ +using Meridian.Models; +using Meridian.Services; +using Windows.UI; + +namespace Meridian.Core.Tests; + +public sealed class FileTagPaletteTests +{ + [Fact] + public void ColorFor_ReturnsNullForNone() + { + Assert.Null(FileTagPalette.ColorFor(null)); + Assert.Null(FileTagPalette.ColorFor(FileTagColor.None)); + } + + [Fact] + public void ColorFor_ReturnsADistinctColorForEachTag() + { + var tags = Enum.GetValues().Where(tag => tag != FileTagColor.None); + var colors = new HashSet(); + foreach (var tag in tags) + { + var color = FileTagPalette.ColorFor(tag); + Assert.NotNull(color); + Assert.True(colors.Add(color.Value), $"The {tag} tag shares its color with another tag."); + } + } + + [Fact] + public void LabelFor_ReturnsAPlainTextLabelForEachTag() + { + Assert.Equal("No tag", FileTagPalette.LabelFor(null)); + Assert.Equal("No tag", FileTagPalette.LabelFor(FileTagColor.None)); + Assert.Equal("Red tag", FileTagPalette.LabelFor(FileTagColor.Red)); + Assert.Equal("Green tag", FileTagPalette.LabelFor(FileTagColor.Green)); + Assert.Equal("Blue tag", FileTagPalette.LabelFor(FileTagColor.Blue)); + } + + [Fact] + public void IsTagged_IsTrueOnlyForRealTags() + { + Assert.False(FileTagPalette.IsTagged(null)); + Assert.False(FileTagPalette.IsTagged(FileTagColor.None)); + foreach (var tag in Enum.GetValues().Where(tag => tag != FileTagColor.None)) + Assert.True(FileTagPalette.IsTagged(tag)); + } +} diff --git a/tests/Meridian.Core.Tests/FileTagStoreTests.cs b/tests/Meridian.Core.Tests/FileTagStoreTests.cs new file mode 100644 index 00000000..3b08c9e4 --- /dev/null +++ b/tests/Meridian.Core.Tests/FileTagStoreTests.cs @@ -0,0 +1,145 @@ +using Meridian.Models; +using Meridian.Services; + +namespace Meridian.Core.Tests; + +public sealed class FileTagStoreTests +{ + private static FileTagStore NewStore(string root) => new(Path.Combine(root, "file-tags.json")); + + private static string NewRoot() + { + var root = Path.Combine(Path.GetTempPath(), $"meridian-tags-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + return root; + } + + [Fact] + public async Task GetTag_ReturnsNullUntilATagIsSet() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + Assert.Null(store.GetTag(@"C:\Docs\a.txt")); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task SetTag_ThenGetTag_ReturnsTheSameColor() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + await store.SetTagAsync(@"C:\Docs\a.txt", FileTagColor.Red); + + Assert.Equal(FileTagColor.Red, store.GetTag(@"C:\Docs\a.txt")); + Assert.Null(store.GetTag(@"C:\Docs\b.txt")); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task SetTag_None_ClearsTheTag() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + await store.SetTagAsync(@"C:\Docs\a.txt", FileTagColor.Red); + await store.SetTagAsync(@"C:\Docs\a.txt", FileTagColor.None); + + Assert.Null(store.GetTag(@"C:\Docs\a.txt")); + Assert.Empty(store.Tags); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task SetTagsAsync_AppliesOneColorToManyPaths() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + await store.SetTagsAsync(new[] + { + @"C:\Docs\a.txt", @"C:\Docs\b.txt", @"C:\Docs\sub\c.txt", + }, FileTagColor.Blue); + + Assert.Equal(FileTagColor.Blue, store.GetTag(@"C:\Docs\a.txt")); + Assert.Equal(FileTagColor.Blue, store.GetTag(@"C:\Docs\b.txt")); + Assert.Equal(FileTagColor.Blue, store.GetTag(@"C:\Docs\sub\c.txt")); + Assert.Equal(3, store.Tags.Count); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task ClearTagsAsync_RemovesTheGivenPaths() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + await store.SetTagsAsync(new[] { @"C:\Docs\a.txt", @"C:\Docs\b.txt" }, FileTagColor.Green); + await store.ClearTagsAsync(new[] { @"C:\Docs\a.txt" }); + + Assert.Null(store.GetTag(@"C:\Docs\a.txt")); + Assert.Equal(FileTagColor.Green, store.GetTag(@"C:\Docs\b.txt")); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task Load_PersistedTags_RoundTripsAcrossStoreInstances() + { + var root = NewRoot(); + try + { + var first = NewStore(root); + await first.LoadAsync(); + await first.SetTagAsync(@"C:\Docs\a.txt", FileTagColor.Purple); + + var second = NewStore(root); + await second.LoadAsync(); + Assert.Equal(FileTagColor.Purple, second.GetTag(@"C:\Docs\a.txt")); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task MissingFile_LoadsAsEmpty() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + Assert.Empty(store.Tags); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task PathLookup_IsCaseInsensitive() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + await store.SetTagAsync(@"C:\Docs\a.txt", FileTagColor.Orange); + + Assert.Equal(FileTagColor.Orange, store.GetTag(@"c:\docs\a.txt")); + } + finally { Directory.Delete(root, recursive: true); } + } +} From e59d5dfeda32de71ac526935840e15ee7f3e1367 Mon Sep 17 00:00:00 2001 From: SeanShaffer88 <252387677+SeanShaffer88@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:34:07 +0000 Subject: [PATCH 3/9] Add queued, undoable rename with before/after preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames no longer run synchronously without a safety net. Select one or more items, choose Rename (or F2), and a preview dialog shows old name → new name for each, flagging names that already exist in the folder. On confirm the whole batch is enqueued as a TransferJobKind.Rename job that runs through the same Transfers queue as copies and moves: progress in the strip, cancellable, one item's failure never aborts the job, and every finished rename is recorded in History where it can be undone. Core: - TransferJobKind.Rename; TransferExecutor renames each item in place (a single same-folder Windows rename per item, never a copy), parks name collisions for a user Replace/Keep-both/Skip choice, and records destination facts so undo can verify nothing changed since. - UndoPlanner reverses a rename by moving each item back to its original name (numbered name if the original is occupied again), refusing when the destination file changed in the meantime. UI: - Rename dialog is a before/after preview; the batch is enqueued rather than applied synchronously, so the pane refreshes when the job settles. - Transfers strip shows Renaming/Renamed; the Replace option is hidden for rename conflicts since a rename must never overwrite an existing item. Tests cover rename of files and folders, one failed source not aborting the job, Keep-both numbering, Ask parking until resolved, and undo (including original-occupied → numbered name, and changed-since → not undoable). Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- CHANGELOG.md | 2 +- README.md | 2 +- src/Meridian.App/MainWindow.Transfers.cs | 8 ++ src/Meridian.App/MainWindow.xaml.cs | 130 ++++++++++-------- .../Transfers/TransferExecutor.cs | 110 ++++++++++++++- src/Meridian.Core/Transfers/TransferModel.cs | 7 + src/Meridian.Core/Transfers/UndoPlanner.cs | 11 +- .../TransferExecutorTests.cs | 125 +++++++++++++++++ tests/Meridian.Core.Tests/UndoPlannerTests.cs | 88 ++++++++++++ 9 files changed, 419 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44518f9b..dd2820fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Batch rename: select multiple files or folders and rename them all at once from the Rename command or F2. Every new name is checked before anything is renamed, one item that can't be renamed never stops the others, and the outcome is reported per item. +- Batch rename with preview and undo: select multiple files or folders and rename them all at once from the Rename command or F2. Every new name is checked before anything is renamed, collisions are flagged in the preview, and the whole batch runs as a transfer job in the Transfers queue — progress shows in the strip, one item that can't be renamed never stops the others, and every finished rename can be undone from Transfer History. - Color tags: mark files and folders with a colored indicator (red, orange, yellow, green, blue, purple, gray or none) from the right-click menu in either pane. Tags persist across sessions and travel with the item row wherever it is shown. ## 0.5.0 diff --git a/README.md b/README.md index 795053cc..86bb2aed 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ These features are **implemented on the main development branch**. They are not | **Breadcrumb path bar** | Each pane's path is a breadcrumb bar: one button per level from the drive root (or network share) down to the current folder — click any level to jump there. Clicking the empty stretch of the bar, or pressing `Ctrl+L`, swaps in a text box holding the full path: Enter opens it, Escape returns to the breadcrumbs. Long folder names compress with an ellipsis and show their full path as a tooltip. Segments accept drops (onto that level's folder); the bar's empty stretch drops into the current folder. | | **Preview block** | Each pane has a preview block at its bottom, replacing the old right-side details panel: a large preview of the selected item (an image preview, the opening of a text file, or a folder treatment with its measured size and counts), its name, type, created and modified dates, and size, plus photo details and the "Opens in" app where they exist. With nothing selected it describes the current folder. Its header line always shows the folder's item count and total size — folders count only once measured, never walked just for the line — and the selection's count and size while anything is selected. Each pane's block collapses on its own chevron and remembers its state; a details panel you had open becomes both blocks expanded. | | **File hash** | Tools → File hash… shows the SHA-256 fingerprint of each selected file, with a copy button per file and a compare field that reports "Matches " or "Does not match" for a hash you paste. | -| **Conservative file actions** | Copy, move, rename — including multi-item batch rename (F2 or the Rename command, each name checked before anything changes), create folders, view basic properties, request Recycle Bin deletion, and apply color tags to files and folders. | +| **Conservative file actions** | Copy, move, rename — including multi-item batch rename with a before/after preview (F2 or the Rename command, each name checked before anything changes). Renames run through the Transfers queue, so they show progress, can be cancelled, and — like every finished transfer — can be undone from History. Also: create folders, view basic properties, request Recycle Bin deletion, and apply color tags to files and folders. | | **Universal Viewer** | Preview text (UTF-8/UTF-16), hex, and images, and play audio/video with Windows media controls and no autoplay. Reads are bounded and read-only; playback behavior is not yet verified on real devices. | | **Archives** | Inspect ZIP/TAR/TGZ contents read-only, create an archive from selected files and folders, or extract into a new no-overwrite folder under entry-policy and size limits, with cancellation. | | **TimeVault (read-only)** | Discover existing Windows shadow copies and list a snapshot's version of the current folder. Discovery only — no recovery operations, and real VSS access is unverified. | diff --git a/src/Meridian.App/MainWindow.Transfers.cs b/src/Meridian.App/MainWindow.Transfers.cs index ced11c29..bc5098e7 100644 --- a/src/Meridian.App/MainWindow.Transfers.cs +++ b/src/Meridian.App/MainWindow.Transfers.cs @@ -99,6 +99,7 @@ private void UpdateTransferStatusSummary(TransferProgressUpdate update) { TransferJobKind.Move => "Moving", TransferJobKind.Delete => "Deleting", + TransferJobKind.Rename => "Renaming", _ => "Copying", }; var summary = update.Progress.TotalBytes > 0 @@ -115,6 +116,7 @@ private void ReportSettledJob(TransferJob job) { TransferJobKind.Move => "Moved", TransferJobKind.Delete => "Deleted", + TransferJobKind.Rename => "Renamed", _ => "Copied", }; var destination = string.IsNullOrWhiteSpace(job.DestinationFolder) ? "" : $" to {job.DestinationFolder}"; @@ -336,6 +338,8 @@ private Border BuildConflictRow(TransferJob job, TransferItem item) ToolTipService.SetToolTip(replace, "Replace the existing file with the incoming one. The existing file is overwritten and cannot be brought back from here."); AutomationProperties.SetName(replace, $"Replace the existing {Path.GetFileName(item.SourcePath)}"); replace.Click += async (s, e) => await ResolveConflictFromUiAsync(job, item, CollisionDecisionKind.Replace, applyToAll.IsOn); + // A rename must never overwrite an existing item, so Replace is not offered for renames. + replace.Visibility = job.Kind == TransferJobKind.Rename ? Visibility.Collapsed : Visibility.Visible; var keepBoth = new Button { Content = "Keep both" }; ToolTipService.SetToolTip(keepBoth, "Keep both files. The incoming file is saved under a numbered name, like \u201cname (2)\u201d."); AutomationProperties.SetName(keepBoth, $"Keep both copies of {Path.GetFileName(item.SourcePath)}"); @@ -399,6 +403,7 @@ private Border BuildHistoryRow(TransferHistoryRecord record) { TransferJobKind.Move => "Moved", TransferJobKind.Delete => "Deleted", + TransferJobKind.Rename => "Renamed", _ => "Copied", }; var destination = string.IsNullOrWhiteSpace(record.DestinationFolder) ? "" : $" to {record.DestinationFolder}"; @@ -474,6 +479,9 @@ private static string TransferJobTitle(TransferJob job) TransferJobKind.Delete => job.State == TransferJobState.Running ? $"Deleting {count:N0} {items}" : $"Delete {count:N0} {items}", + TransferJobKind.Rename => job.State == TransferJobState.Running + ? $"Renaming {count:N0} {items}" + : $"Rename {count:N0} {items}", _ => job.State == TransferJobState.Running ? $"Copying {count:N0} {items} to {job.DestinationFolder}" : $"Copy {count:N0} {items} to {job.DestinationFolder}", diff --git a/src/Meridian.App/MainWindow.xaml.cs b/src/Meridian.App/MainWindow.xaml.cs index 85fbb5f9..989a3611 100644 --- a/src/Meridian.App/MainWindow.xaml.cs +++ b/src/Meridian.App/MainWindow.xaml.cs @@ -2882,8 +2882,9 @@ private async Task RenameSelectedAsync(PaneState pane) await RenameManyAsync(pane, selection); } - /// Renames exactly one item with a single name box (the - /// historical behavior, still used for one selection and for F2). + /// Renames exactly one item with a single name box. The rename is + /// not applied directly: it is enqueued as a transfer job so it is reviewable, + /// cancellable and — like every finished transfer — undoable from History. private async Task RenameOneAsync(PaneState pane, FileEntry selected) { var nameBox = new TextBox { Text = selected.Name, MinWidth = 280, SelectionStart = 0, SelectionLength = selected.Name.Length }; @@ -2907,33 +2908,34 @@ private async Task RenameOneAsync(PaneState pane, FileEntry selected) if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; - try + var newName = nameBox.Text; + if (string.IsNullOrWhiteSpace(newName)) { - var renamedTo = FileOperations.RenameItem(selected.FullPath, nameBox.Text); - await NavigateAsync(pane, pane.CurrentPath, addHistory: false); - var unchanged = string.Equals(renamedTo, selected.FullPath, StringComparison.Ordinal); - SetStatus(unchanged - ? "Name unchanged." - : $"Renamed · {Path.GetFileName(renamedTo)}", - unchanged ? StatusSeverity.Info : StatusSeverity.Success); + SetStatus("Enter a name.", StatusSeverity.Warning); + return; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or System.Security.SecurityException) + try { FileOperations.ValidateNewName(newName); } + catch (ArgumentException ex) { SetStatus("Could not rename item · " + ex.Message, StatusSeverity.Error); + return; } + + await EnqueueRenameAsync(pane, [(selected, newName)]); } /// Renames several selected items at once: one row per item with /// its current name and an editable new name. Every name is validated - /// against the folder's existing names before anything is renamed, and - /// one item's failure never stops another. + /// before anything is renamed, and one item's failure never stops another. + /// The whole batch is enqueued as one transfer job, so it is reviewable, + /// cancellable and undoable from History. private async Task RenameManyAsync(PaneState pane, IReadOnlyList selection) { var existingNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var entry in pane.AllItems) existingNames.Add(entry.Name); - var rows = new List<(FileEntry Entry, TextBox NameBox, TextBlock ErrorText)>(); + var rows = new List<(FileEntry Entry, TextBox NameBox, TextBlock StatusText)>(); var rowPanel = new StackPanel { Spacing = 8 }; foreach (var entry in selection) { @@ -2956,31 +2958,29 @@ private async Task RenameManyAsync(PaneState pane, IReadOnlyList sele SelectionLength = entry.Name.Length }; AutomationProperties.SetName(nameBox, $"New name for {entry.Name}"); - var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 }; - header.Children.Add(original); - header.Children.Add(nameBox); - var errorText = new TextBlock + var statusText = new TextBlock { - Foreground = ThemeBrush("MeridianErrorBrush"), FontSize = 11, TextWrapping = TextWrapping.Wrap, Visibility = Visibility.Collapsed }; + var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 }; + header.Children.Add(original); + header.Children.Add(nameBox); var row = new StackPanel { Spacing = 4 }; row.Children.Add(header); - row.Children.Add(errorText); + row.Children.Add(statusText); rowPanel.Children.Add(row); - rows.Add((entry, nameBox, errorText)); + rows.Add((entry, nameBox, statusText)); } var instruction = new TextBlock { - Text = "Type a new name for each item. A name must not already exist in this folder, and no two items may share a name.", + Text = "Type a new name for each item. Names already in this folder are flagged — you will be asked what to do with each one before it is renamed.", TextWrapping = TextWrapping.Wrap }; var summary = new TextBlock { - Foreground = ThemeBrush("MeridianErrorBrush"), FontSize = 11, TextWrapping = TextWrapping.Wrap, Visibility = Visibility.Collapsed @@ -3008,9 +3008,10 @@ private async Task RenameManyAsync(PaneState pane, IReadOnlyList sele { var problemCount = 0; var proposedNames = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var (entry, nameBox, errorText) in rows) + foreach (var (entry, nameBox, statusText) in rows) { string? problem = null; + string? conflict = null; var newName = nameBox.Text; if (string.IsNullOrWhiteSpace(newName)) problem = "Enter a name."; @@ -3023,15 +3024,26 @@ private async Task RenameManyAsync(PaneState pane, IReadOnlyList sele if (problem is null && !string.Equals(newName, entry.Name, StringComparison.OrdinalIgnoreCase) && existingNames.Contains(newName)) - problem = $"“{newName}” already exists in this folder."; + conflict = $"“{newName}” already exists — you will be asked whether to keep both or skip it."; + } + if (problem is not null) + { + statusText.Text = problem; + statusText.Foreground = ThemeBrush("MeridianErrorBrush"); + statusText.Visibility = Visibility.Visible; + problemCount++; + } + else + { + statusText.Text = conflict ?? string.Empty; + statusText.Foreground = conflict is null ? ThemeBrush("MeridianTextSecondaryBrush") : ThemeBrush("MeridianWarningBrush"); + statusText.Visibility = conflict is null ? Visibility.Collapsed : Visibility.Visible; } - errorText.Text = problem ?? string.Empty; - errorText.Visibility = problem is null ? Visibility.Collapsed : Visibility.Visible; - if (problem is not null) problemCount++; } summary.Text = problemCount == 0 ? string.Empty : $"Fix {problemCount} problem(s) before renaming."; + summary.Foreground = ThemeBrush("MeridianErrorBrush"); summary.Visibility = problemCount == 0 ? Visibility.Collapsed : Visibility.Visible; if (problemCount > 0) { @@ -3039,37 +3051,8 @@ private async Task RenameManyAsync(PaneState pane, IReadOnlyList sele return; } - var inputs = rows.Select(row => (row.Entry.FullPath, row.NameBox.Text)).ToArray(); - var results = FileOperations.RenameItems(inputs); - var succeeded = results.Count(result => result.Error is null); - var failed = results.Count - succeeded; - - await NavigateAsync(pane, pane.CurrentPath, addHistory: false); - - if (failed == 0) - SetStatus($"Renamed {succeeded:N0} item(s).", StatusSeverity.Success); - else - { - var failures = new StackPanel { Spacing = 4 }; - foreach (var result in results.Where(result => result.Error is not null)) - { - failures.Children.Add(new TextBlock - { - Text = $"{Path.GetFileName(result.SourcePath)} → {result.NewName}: {result.Error}", - TextWrapping = TextWrapping.Wrap, - Foreground = ThemeBrush("MeridianErrorBrush") - }); - } - var resultDialog = new ContentDialog - { - Title = $"Renamed {succeeded:N0} of {results.Count:N0} item(s)", - Content = new ScrollViewer { Content = failures, MaxHeight = 340, VerticalScrollBarVisibility = ScrollBarVisibility.Auto }, - CloseButtonText = "Close", - XamlRoot = Content.XamlRoot - }; - await resultDialog.ShowAsync(); - SetStatus($"Renamed {succeeded:N0} of {results.Count:N0} item(s) · {failed} failed.", StatusSeverity.Warning); - } + var planned = rows.Select(row => (row.Entry, row.NameBox.Text)).ToList(); + await EnqueueRenameAsync(pane, planned); } finally { @@ -3080,6 +3063,35 @@ private async Task RenameManyAsync(PaneState pane, IReadOnlyList sele await dialog.ShowAsync(); } + /// Builds a Rename transfer job from one old→new name plan per item + /// and enqueues it. The job runs through the same Transfers queue as copies + /// and moves: progress shows in the strip, and when it finishes it is recorded + /// in History where it can be undone. + private async Task EnqueueRenameAsync(PaneState pane, IReadOnlyList<(FileEntry Entry, string NewName)> planned) + { + var items = planned.Select(p => new TransferItem + { + SourcePath = p.Entry.FullPath, + DestinationPath = Path.Combine(pane.CurrentPath, p.NewName), + IsDirectory = p.Entry.IsDirectory, + Size = p.Entry.SizeBytes, + SourceLastWriteUtc = p.Entry.ModifiedUtc, + }).ToList(); + + var job = TransferJob.Create( + TransferJobKind.Rename, + planned.Select(p => p.Entry.FullPath), + destinationFolder: string.Empty, + CollisionPolicy: _nameCollisionPolicy); + job.Items = items; + + await _transfers.EnqueueAsync(job); + SetStatus(planned.Count == 1 + ? $"Renaming {Path.GetFileName(planned[0].Entry.FullPath)} · progress is in the Transfers strip below." + : $"Renaming {planned.Count:N0} items · progress is in the Transfers strip below.", + StatusSeverity.Success); + } + private async void PropertiesSelected_Click(object sender, RoutedEventArgs e) => await PropertiesSelectedAsync(_activePane); private async Task PropertiesSelectedAsync(PaneState pane) diff --git a/src/Meridian.Core/Transfers/TransferExecutor.cs b/src/Meridian.Core/Transfers/TransferExecutor.cs index 1144fb12..4eab1e9e 100644 --- a/src/Meridian.Core/Transfers/TransferExecutor.cs +++ b/src/Meridian.Core/Transfers/TransferExecutor.cs @@ -84,7 +84,7 @@ public async Task ExecuteAsync( throw new InvalidOperationException("This job is already running."); if (job.Items.Count == 0 && job.Sources.Count == 0) throw new InvalidOperationException("The job has nothing to transfer."); - if (job.Items.Count == 0 && job.Kind != TransferJobKind.Delete) + if (job.Items.Count == 0 && job.Kind is not (TransferJobKind.Delete or TransferJobKind.Rename)) { // Fresh jobs fail fast on a bad destination. Jobs that already carry // their items (a resumed queue entry, an undo plan) are self-describing: @@ -348,6 +348,11 @@ private async Task ProcessItemAsync( await CheckpointAsync(job, cancellationToken); + if (job.Kind == TransferJobKind.Rename) + { + await ProcessRenameItemAsync(job, item, progress, cancellationToken); + return; + } if (job.Kind == TransferJobKind.Delete) { ProcessDeleteItem(item); @@ -385,6 +390,109 @@ private void ProcessDeleteItem(TransferItem item) } } + /// Renames one item in place within its own folder. A rename never + /// copies data: it is a single Windows rename, so a same-folder rename always + /// targets the same volume and a source is never left half-processed. One + /// item's failure is recorded on that item and never aborts the job. + private async Task ProcessRenameItemAsync( + TransferJob job, TransferItem item, IProgress? progress, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var source = item.SourcePath; + var destination = item.DestinationPath; + if (destination is null) + { + item.State = TransferItemState.Failed; + item.Error = "The item has no destination."; + return; + } + if (string.Equals(source, destination, StringComparison.OrdinalIgnoreCase)) + { + item.State = TransferItemState.Failed; + item.Error = "The name is unchanged."; + return; + } + if (!_files.FileExists(source) && !_files.DirectoryExists(source)) + { + item.State = TransferItemState.Failed; + item.Error = "The source no longer exists."; + return; + } + + // A rename's destination is a specific new name in the same folder. A file + // or folder already sitting there is a collision the user must resolve. + var destinationTaken = _files.FileExists(destination) || _files.DirectoryExists(destination); + + CollisionDecision decision; + if (item.CollisionOverride is { } userDecision) + { + decision = userDecision switch + { + CollisionDecisionKind.Skip => new CollisionDecision(CollisionDecisionKind.Skip, null, "You chose to skip this rename."), + CollisionDecisionKind.Rename => new CollisionDecision(CollisionDecisionKind.Rename, destination, "You chose a new name for this item."), + CollisionDecisionKind.Replace => new CollisionDecision(CollisionDecisionKind.Replace, null, "A rename cannot replace an existing item."), + _ => new CollisionDecision(CollisionDecisionKind.Proceed, null, destinationTaken ? "A name is in the way." : "Nothing is in the way at the destination."), + }; + } + else + { + decision = destinationTaken + ? new CollisionDecision(CollisionDecisionKind.NeedsUserChoice, null, "A file or folder with this name already exists.") + : new CollisionDecision(CollisionDecisionKind.Proceed, null, "Nothing is in the way at the destination."); + } + + switch (decision.Kind) + { + case CollisionDecisionKind.NeedsUserChoice: + item.State = TransferItemState.WaitingOnConflict; + item.Error = null; + ReportProgress(job, progress, source); + return; + case CollisionDecisionKind.Skip: + item.State = TransferItemState.Skipped; + item.Error = null; + ReportProgress(job, progress, source); + return; + case CollisionDecisionKind.Rename: + destination = decision.NewDestinationPath ?? destination; + break; + case CollisionDecisionKind.Replace: + // Renaming must never overwrite an existing item. + item.State = TransferItemState.Failed; + item.Error = "Renaming cannot replace an existing item. Keep both (a numbered name) or skip instead."; + ReportProgress(job, progress, source); + return; + case CollisionDecisionKind.Proceed: + break; + } + + item.State = TransferItemState.InProgress; + item.BytesCopied = 0; + ReportProgress(job, progress, source); + try + { + var renamed = FileOperations.RenameItem(source, Path.GetFileName(destination)); + var facts = _files.GetFileFacts(renamed); + item.DestinationPath = renamed; + item.DestinationSize = facts?.Size ?? item.Size; + item.DestinationLastWriteUtc = facts?.LastWriteUtc; + item.BytesCopied = item.Size; + item.State = TransferItemState.Completed; + item.Error = null; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + item.State = TransferItemState.Cancelled; + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or System.Security.SecurityException) + { + item.State = TransferItemState.Failed; + item.Error = ex.Message; + } + ReportProgress(job, progress, source); + } + private void ProcessDirectoryItem(TransferItem item) { // Folder collisions merge: an existing destination folder is the normal case. diff --git a/src/Meridian.Core/Transfers/TransferModel.cs b/src/Meridian.Core/Transfers/TransferModel.cs index 73a7e2e3..e18bd69c 100644 --- a/src/Meridian.Core/Transfers/TransferModel.cs +++ b/src/Meridian.Core/Transfers/TransferModel.cs @@ -6,6 +6,13 @@ public enum TransferJobKind Copy, Move, + /// + /// Renames items in place, one new name per item. A rename is a same-folder + /// move: it never copies data, one item's failure never aborts the job, and a + /// finished rename can be undone by moving each item back to its original name. + /// + Rename, + /// /// Deletes the items named by the job. The transfer engine's deletes are /// permanent (they do not go to the Recycle Bin), and a finished Delete job diff --git a/src/Meridian.Core/Transfers/UndoPlanner.cs b/src/Meridian.Core/Transfers/UndoPlanner.cs index c776dd7d..7d8c659d 100644 --- a/src/Meridian.Core/Transfers/UndoPlanner.cs +++ b/src/Meridian.Core/Transfers/UndoPlanner.cs @@ -18,7 +18,9 @@ public sealed record UndoPlan(bool IsAvailable, string Reason, TransferJob? Inve /// Copy → the inverse is a Delete job over exactly the files the copy created (folders the /// copy created are left in place). Move → the inverse is a Move job returning each file to /// its original location; an original location that is occupied again gets the next free -/// "name (2)" style name rather than overwriting anything. Delete → never undoable: the +/// "name (2)" style name rather than overwriting anything. Rename → the inverse is a Rename +/// job returning each item to its original name; an original name that is occupied again gets +/// the next free numbered name. Delete → never undoable: the /// transfer engine's deletes are permanent and do not go to the Recycle Bin. Undoing an /// undo is how redo works — the inverse job's own history record plans back the other way. /// @@ -33,6 +35,9 @@ public static UndoPlan Plan(TransferHistoryRecord record, ITransferFileOperation { TransferJobKind.Copy => PlanCopyUndo(record, files), TransferJobKind.Move => PlanMoveUndo(record, files), + // A rename is a same-folder move: reverse it by moving each item back + // to its original name, with the same Keep-both / unchanged-file checks. + TransferJobKind.Rename => PlanMoveUndo(record, files), TransferJobKind.Delete => new UndoPlan(false, "Deletes cannot be undone from Meridian's records: the transfer engine removes files permanently instead of sending them to the Recycle Bin.", null), @@ -135,7 +140,9 @@ private static UndoPlan PlanMoveUndo(TransferHistoryRecord record, ITransferFile var inverse = new TransferJob { - Kind = TransferJobKind.Move, + // Renames reverse as renames (a single in-place rename); moves reverse + // as copy-then-delete, which is the existing safe behavior. + Kind = record.Kind == TransferJobKind.Rename ? TransferJobKind.Rename : TransferJobKind.Move, Sources = inverseItems.Select(item => item.SourcePath).ToList(), DestinationFolder = Path.GetDirectoryName(moved[0].SourcePath) ?? string.Empty, // The plan already chose free (or numbered) destinations; KeepBoth is diff --git a/tests/Meridian.Core.Tests/TransferExecutorTests.cs b/tests/Meridian.Core.Tests/TransferExecutorTests.cs index 798fede6..c59fe3d2 100644 --- a/tests/Meridian.Core.Tests/TransferExecutorTests.cs +++ b/tests/Meridian.Core.Tests/TransferExecutorTests.cs @@ -365,6 +365,131 @@ public async Task CopyFolderIntoItself_FailsWithoutCopying() Assert.Empty(Directory.EnumerateFileSystemEntries(inner)); } + [Fact] + public async Task RenameFiles_RenamesEachItemInPlace() + { + using var fixture = new Fixture(); + var first = Path.Combine(fixture.VolumeA, "first.txt"); + var second = Path.Combine(fixture.VolumeA, "second.txt"); + await File.WriteAllTextAsync(first, "contents one"); + await File.WriteAllTextAsync(second, "contents two"); + + var job = TransferJob.Create(TransferJobKind.Rename, [first, second], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = first, DestinationPath = Path.Combine(fixture.VolumeA, "alpha.txt"), IsDirectory = false, Size = 0 }, + new TransferItem { SourcePath = second, DestinationPath = Path.Combine(fixture.VolumeA, "beta.txt"), IsDirectory = false, Size = 0 }, + }; + + var result = await new TransferExecutor().ExecuteAsync(job); + + Assert.Equal(TransferJobState.Completed, result.State); + Assert.False(File.Exists(first)); + Assert.False(File.Exists(second)); + Assert.Equal("contents one", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "alpha.txt"))); + Assert.Equal("contents two", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "beta.txt"))); + Assert.All(result.Items, item => Assert.Equal(TransferItemState.Completed, item.State)); + } + + [Fact] + public async Task Rename_OneMissingSource_DoesNotAbortTheJob() + { + using var fixture = new Fixture(); + var good = Path.Combine(fixture.VolumeA, "good.txt"); + await File.WriteAllTextAsync(good, "good"); + var missing = Path.Combine(fixture.VolumeA, "missing.txt"); + + var job = TransferJob.Create(TransferJobKind.Rename, [good, missing], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = good, DestinationPath = Path.Combine(fixture.VolumeA, "renamed-good.txt"), IsDirectory = false, Size = 0 }, + new TransferItem { SourcePath = missing, DestinationPath = Path.Combine(fixture.VolumeA, "renamed-missing.txt"), IsDirectory = false, Size = 0 }, + }; + + var result = await new TransferExecutor().ExecuteAsync(job); + + Assert.Equal(TransferJobState.CompletedWithErrors, result.State); + Assert.True(File.Exists(Path.Combine(fixture.VolumeA, "renamed-good.txt"))); + Assert.False(File.Exists(good)); + var failed = result.Items.Single(item => item.SourcePath == missing); + Assert.Equal(TransferItemState.Failed, failed.State); + Assert.Contains("no longer exists", failed.Error); + } + + [Fact] + public async Task RenameCollisionKeepBoth_UsesTheNextFreeNumberedName() + { + using var fixture = new Fixture(); + var source = Path.Combine(fixture.VolumeA, "report.txt"); + await File.WriteAllTextAsync(source, "new content"); + await File.WriteAllTextAsync(Path.Combine(fixture.VolumeA, "report (2).txt"), "existing"); + + var job = TransferJob.Create(TransferJobKind.Rename, [source], string.Empty, CollisionPolicy.KeepBoth); + job.Items = new List + { + new TransferItem { SourcePath = source, DestinationPath = Path.Combine(fixture.VolumeA, "report.txt"), IsDirectory = false, Size = 0 }, + }; + + var result = await new TransferExecutor().ExecuteAsync(job); + + Assert.Equal(TransferJobState.Completed, result.State); + Assert.Equal("new content", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "report.txt"))); + Assert.Equal("new content", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "report (3).txt"))); + Assert.False(File.Exists(source)); + } + + [Fact] + public async Task RenameCollisionAsk_ParksUntilTheUserResolvesIt() + { + using var fixture = new Fixture(); + var plain = Path.Combine(fixture.VolumeA, "plain.txt"); + await File.WriteAllTextAsync(plain, "plain"); + var taken = Path.Combine(fixture.VolumeA, "taken.txt"); + await File.WriteAllTextAsync(taken, "taken"); + + var job = TransferJob.Create(TransferJobKind.Rename, [plain], string.Empty, CollisionPolicy.Ask); + job.Items = new List + { + new TransferItem { SourcePath = plain, DestinationPath = taken, IsDirectory = false, Size = 0 }, + }; + + var executor = new TransferExecutor(); + var first = await executor.ExecuteAsync(job); + + Assert.Equal(TransferJobState.WaitingOnConflict, first.State); + var waiting = first.Items.Single(item => item.State == TransferItemState.WaitingOnConflict); + + // Keep both: the rename lands under a numbered name instead of replacing. + waiting.ResolveConflict(new CollisionDecision(CollisionDecisionKind.Rename, Path.Combine(fixture.VolumeA, "plain (2).txt"), "test")); + var second = await executor.ExecuteAsync(first); + + Assert.Equal(TransferJobState.Completed, second.State); + Assert.Equal("plain", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "plain (2).txt"))); + Assert.Equal("taken", await File.ReadAllTextAsync(taken)); // never replaced + } + + [Fact] + public async Task RenameFolder_RenamesTheFolder() + { + using var fixture = new Fixture(); + var folder = Path.Combine(fixture.VolumeA, "bundle"); + Directory.CreateDirectory(folder); + await File.WriteAllTextAsync(Path.Combine(folder, "note.txt"), "keep"); + + var job = TransferJob.Create(TransferJobKind.Rename, [folder], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = folder, DestinationPath = Path.Combine(fixture.VolumeA, "archive"), IsDirectory = true, Size = 0 }, + }; + + var result = await new TransferExecutor().ExecuteAsync(job); + + Assert.Equal(TransferJobState.Completed, result.State); + Assert.False(Directory.Exists(folder)); + Assert.True(Directory.Exists(Path.Combine(fixture.VolumeA, "archive"))); + Assert.Equal("keep", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "archive", "note.txt"))); + } + /// Writes report.txt with new content in volume A and old content in volume B. private static async Task<(string Source, string Destination)> WritePair(Fixture fixture, string sourceContent) { diff --git a/tests/Meridian.Core.Tests/UndoPlannerTests.cs b/tests/Meridian.Core.Tests/UndoPlannerTests.cs index 667817de..00ac9492 100644 --- a/tests/Meridian.Core.Tests/UndoPlannerTests.cs +++ b/tests/Meridian.Core.Tests/UndoPlannerTests.cs @@ -163,6 +163,94 @@ public void DeleteRecord_IsNeverUndoable() Assert.Contains("Recycle Bin", plan.Reason); } + [Fact] + public async Task RenameUndo_MovesEachItemBackToItsOriginalName() + { + using var fixture = new Fixture(); + var first = Path.Combine(fixture.VolumeA, "alpha.txt"); + var second = Path.Combine(fixture.VolumeA, "beta.txt"); + await File.WriteAllTextAsync(first, "one"); + await File.WriteAllTextAsync(second, "two"); + var job = TransferJob.Create(TransferJobKind.Rename, [first, second], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = first, DestinationPath = Path.Combine(fixture.VolumeA, "renamed-alpha.txt"), IsDirectory = false, Size = 0 }, + new TransferItem { SourcePath = second, DestinationPath = Path.Combine(fixture.VolumeA, "renamed-beta.txt"), IsDirectory = false, Size = 0 }, + }; + var result = await new TransferExecutor().ExecuteAsync(job); + Assert.Equal(TransferJobState.Completed, result.State); + Assert.False(File.Exists(first)); + Assert.False(File.Exists(second)); + + var record = TransferHistoryRecord.FromJob(job); + var plan = UndoPlanner.Plan(record, Files); + + Assert.True(plan.IsAvailable, plan.Reason); + Assert.Equal(TransferJobKind.Rename, plan.InverseJob!.Kind); + await new TransferExecutor().ExecuteAsync(plan.InverseJob); + + Assert.Equal("one", await File.ReadAllTextAsync(first)); + Assert.Equal("two", await File.ReadAllTextAsync(second)); + Assert.False(File.Exists(Path.Combine(fixture.VolumeA, "renamed-alpha.txt"))); + Assert.False(File.Exists(Path.Combine(fixture.VolumeA, "renamed-beta.txt"))); + } + + [Fact] + public async Task RenameUndo_OriginalNameOccupiedAgain_ComesBackUnderNumberedName() + { + using var fixture = new Fixture(); + var source = Path.Combine(fixture.VolumeA, "report.txt"); + await File.WriteAllTextAsync(source, "moved-content"); + var job = TransferJob.Create(TransferJobKind.Rename, [source], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = source, DestinationPath = Path.Combine(fixture.VolumeA, "archive.txt"), IsDirectory = false, Size = 0 }, + }; + var result = await new TransferExecutor().ExecuteAsync(job); + Assert.Equal(TransferJobState.Completed, result.State); + + // Someone recreated the original name while the rename sits in history. + await File.WriteAllTextAsync(source, "someone else's new file"); + + var record = TransferHistoryRecord.FromJob(job); + var plan = UndoPlanner.Plan(record, Files); + + Assert.True(plan.IsAvailable, plan.Reason); + await new TransferExecutor().ExecuteAsync(plan.InverseJob!); + + // The original was never overwritten. + Assert.Equal("someone else's new file", await File.ReadAllTextAsync(source)); + // The renamed file came back under a numbered name. + Assert.Equal("moved-content", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "report (2).txt"))); + Assert.False(File.Exists(Path.Combine(fixture.VolumeA, "archive.txt"))); + } + + [Fact] + public async Task RenameUndo_DestinationChangedSince_IsUnavailable() + { + using var fixture = new Fixture(); + var source = Path.Combine(fixture.VolumeA, "doc.txt"); + await File.WriteAllTextAsync(source, "v1"); + var job = TransferJob.Create(TransferJobKind.Rename, [source], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = source, DestinationPath = Path.Combine(fixture.VolumeA, "doc-edited.txt"), IsDirectory = false, Size = 0 }, + }; + var result = await new TransferExecutor().ExecuteAsync(job); + Assert.Equal(TransferJobState.Completed, result.State); + + var renamed = Path.Combine(fixture.VolumeA, "doc-edited.txt"); + await File.WriteAllTextAsync(renamed, "v1 — edited since"); + File.SetLastWriteTimeUtc(renamed, DateTime.UtcNow.AddMinutes(5)); + + var record = TransferHistoryRecord.FromJob(job); + var plan = UndoPlanner.Plan(record, Files); + + Assert.False(plan.IsAvailable); + Assert.Null(plan.InverseJob); + Assert.Contains("has changed", plan.Reason); + } + private sealed class Fixture : IDisposable { public Fixture() From cf2069ac8b6923db28c3ffae6fda470fb9b1f0a7 Mon Sep 17 00:00:00 2001 From: SeanShaffer88 <252387677+SeanShaffer88@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:39:40 +0000 Subject: [PATCH 4/9] Add complete project mockup based on current roadmap Synthesized specification capturing Meridian's differentiation direction (safe/recoverable operations, project workspaces, dependable large-scale work), the complete capability map with roadmap traceability, a text UI mockup, and next implementation priorities. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- docs/PROJECT_MOCKUP.md | 130 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/PROJECT_MOCKUP.md diff --git a/docs/PROJECT_MOCKUP.md b/docs/PROJECT_MOCKUP.md new file mode 100644 index 00000000..fdfba7ff --- /dev/null +++ b/docs/PROJECT_MOCKUP.md @@ -0,0 +1,130 @@ +# Meridian — Complete Project Mockup + +> This document is the synthesized mockup/specification for Meridian based on the current roadmap (`ROADMAP.md`, `PRODUCT_VISION.md`, `docs/ARCHITECTURE.md`) and the differentiation decision: **"A project-focused file manager that makes complex file operations fast, visible, and recoverable."** It is aspirational, not a claim that any unchecked item is implemented. Implemented scope is tracked in `STATUS.md`. + +--- + +## Positioning (decision) + +**One-sentence promise:** "A project-focused file manager that makes complex file operations fast, visible, and recoverable." + +**Why not "OneCommander with more options":** OneCommander is a broad "file manager for everyone" with configurable views, color tags, previews, and scripting. Meridian's deliberate differentiation is three pillars: + +1. **Safe/recoverable operations** (signature): every destructive or bulk action is previewed before execution, journaled, cancellable, and — where technically possible — undoable. *Just shipped: queued rename with before/after preview and History undo.* +2. **Project workspaces**: a project is a first-class concept — saved source/destination folder pairs, pinned files, saved searches, notes, and repeatable actions — not just a sidebar bookmark list. +3. **Dependable large-scale work**: per-item progress, partial-failure reporting, pause/resume/retry, and honest recovery limits at any scale. + +--- + +## Complete capability map (roadmap synthesized) + +| Area | Status | What "complete" looks like | Differentiation angle | +|------|--------|---------------------------|----------------------| +| **Dual-pane / columns navigation** | Implemented | One-, two-, four-pane layouts; Miller columns; adjustable splits | Keyboard-first, predictable | +| **Workspace tabs + sessions** | Partial (single session) | Multi-tab, pinning, tab groups, saved workspace profiles | Task-oriented, not just locations | +| **Bookmarks & favorites** | Implemented | Project groups, folder trees, mini trees | Grouped by project, not just recency | +| **Drop Shelf** | Implemented (in-memory) | Persistent shelf, capsule save/load, cross-session | Gather before acting | +| **Copy/Move queue** | Implemented | Reorderable queue, pause/resume, speed limits, selective retry | Visible progress per file + per job | +| **Transfer History + Undo** | Implemented | Operation journal, recovery receipts, undo where reliable | *Signature: nothing is silently destructive* | +| **Rename** | Implemented (queued + undo) | Batch rename with regex, counters, metadata tokens, live preview | Preview before touching anything | +| **Delete** | Implemented (Recycle Bin) | Secure-deletion research with honest SSD/snapshot limitations | Never claim guarantees we can't substantiate | +| **Color tags** | Implemented | Ratings, file-age visualization, NTFS ADS storage option | Visual organization without moving files | +| **Folder DNA** | Implemented (summary) | Read-only hashes, signatures, provenance analysis | Understand before acting | +| **Archives ZIP/TAR/TGZ** | Implemented | Archive-as-folder navigation, 7z/RAR/ISO where licensed | Safe extraction by default | +| **TimeVault (VSS)** | Implemented (read-only) | File History adapters, unified version timeline | Recovery paths are explicit, not assumed | +| **Universal Viewer** | Implemented (build) | Spacebar quick preview, PDF/Office/SVG, dockable viewer | Preview is part of every workflow | +| **Smart Collections** | **Not started** | Metadata-backed cross-folder groups, rules, dynamic refresh | *Key differentiator: organize without relocating* | +| **Search (OmniSearch)** | Not started | Filename + content + metadata, saved searches, Everything integration | First-class search workspace | +| **Duplicate finder** | Not started | Name/size/hash + perceptual media matching, review-before-delete | Safe by default | +| **Directory compare/sync** | Not started | Two-way sync with conflict preview, conservative defaults | Explicit before implicit | +| **Project workspaces** | **Not started** | Saved folder pairs, pinned files, saved searches, notes, repeatable actions | *Key differentiator: project as first-class concept* | +| **Embedded terminal** | Not started | PowerShell/CMD/Git Bash/WSL panel, working-directory sync | Developer workflow | +| **Git integration** | Not started | Branch/status columns, diff, ahead/behind | Developer workflow | +| **Themes/customization** | Partial (Dark/Light/System tokens) | Mica/Acrylic, export/import setup, configurable shortcuts | Calm default, reveal complexity on demand | +| **Shell integration** | Not started | Context-menu actions, compact menu option | Respect Windows policy | + +--- + +## UI mockup — default workspace (text layout) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ █ MERIDIAN · PROJECT: "Q4 Launch" [● active] ⭒ Settings ✕ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Copy ▾ Move ▾ New folder Rename Preview Properties Archive ▾ │ +│ Tools ▾ Delete│ +├──────────┬──────────────────────────────────────────────────────────────────┤ +│ PROJECTS │ LEFT PANE — "source" │ │ +│ ▼ Q4 Launch RIGHT PANE — "destination" │ │ +│ • assets/ 📁 report (2).docx [blue tag] │ │ +│ • drafts/ budget_Q4.xlsx [green tag] │ │ +│ • ▼ images/ notes.md │ │ +│ • hero.png contract.pdf │ │ +│ • ▼ notes/ [3 selected · 2.4 MB] │ │ +│ • meeting.txt ⏳ Renaming 2 of 3 items… │ │ +│ Settings [Cancel] │ │ +├──────────┴──────────────────────────────────────────────────────────────────┤ +│ Status: Renaming 2 items · 45% · report (2).docx → report_Q4.docx ⚠ Undo │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Key UI distinctions from OneCommander:** +- The project name is persistent in the header, not buried in a favorites sidebar. +- Tag colors appear on rows as small badges (already implemented). +- The status bar shows per-operation progress with an undo affordance, not just "Ready." +- The transfer strip is always visible below the panes when anything is queued. +- No ribbon, no icon-only mystery buttons, no copied artwork. + +--- + +## Project workspace spec (next flagship feature) + +A **project** is a saved workspace that remembers: + +| Element | Purpose | +|---------|---------| +| Name + color + optional notes | Identity, shown in header | +| Folder pairs (left/right) | The locations the user switches between | +| Pinned files | Quick-open within the project | +| Saved searches | Queries that refresh as files change | +| Layout + view preferences | Split ratio, density, columns per pane | +| Repeatable actions | Named copy/move/rename plans with preview | + +**Workflow:** Open project → both panes restore to their folders → tags/colors show status → run a queued operation → undo from History if needed → close project, state persists. + +**Storage:** `%LOCALAPPDATA%\Meridian\projects\.json`, atomic write, migration-tolerant schema. + +--- + +## Implemented → Roadmap traceability + +| Roadmap item | Implemented | Evidence | +|--------------|-------------|----------| +| Phase 1: Paired workspace tabs + session persistence | Partial | `WorkspaceStore`, single session | +| Phase 1: Central transfer queue | Implemented | `TransferCoordinator`, `TransferExecutor` | +| Phase 1: Rename with validation + no-overwrite | Implemented (queued + undo) | `TransferJobKind.Rename`, undo planner | +| Phase 1: Delete to Recycle Bin | Implemented | `FileOperations`, confirmed dialog | +| Phase 1: Properties dialog | Implemented | Basic metadata | +| Phase 1: Installer + portable artifacts | Implemented | `packaging/Meridian.iss`, CI | +| Phase 2: Color labels/tags | Implemented | `FileTagStore`, `FileTagPalette`, UI badges | +| Phase 2: Folder DNA | Implemented (summary) | `FolderDna`, immediate listing only | +| Phase 2: Color labels → ratings/ADS storage | Planned | Roadmap Phase 2 | +| Phase 3: Batch rename with preview | Implemented (basic) | Preview dialog + queue | +| Phase 3: Directory compare/sync | Planned | Roadmap Phase 3 | +| Phase 4: Search/metadata | Planned | Roadmap Phase 4 | +| Phase 5: Preview/viewer | Partial | `QuickPreviewOverlay`, Universal Viewer | +| Phase 6: Archives | Implemented (read-only + create/extract) | `Archives/*` | +| Phase 7: Developer tools | Planned | Roadmap Phase 7 | +| Phase 8: Customization | Partial | Theme/density/accent settings | + +--- + +## Next implementation priorities (my decision) + +1. **Smart Collections** (Phase 2) — metadata-backed cross-folder groups. Organize without relocating files; dynamic refresh; tags + rules. *This is the clearest OneCommander differentiator.* +2. **Project workspaces** (Phase 2/7) — named workspace snapshots with folder pairs, pinned files, saved searches. +3. **Duplicate finder** (Phase 4) — name/size/hash with review-before-delete. +4. **Directory compare/sync** (Phase 3) — conflict preview before two-way sync. +5. **Spacebar quick preview** (Phase 5) — inline preview without opening a dialog. + +I will implement **Smart Collections** next. From 97e11c785b6c3e88ba2ef074d54ea85c80aa5d14 Mon Sep 17 00:00:00 2001 From: SeanShaffer88 <252387677+SeanShaffer88@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:43:05 +0000 Subject: [PATCH 5/9] Fix build: remove WinUI dependency from FileTagPalette FileTagPalette used Windows.UI.Color, which doesn't exist in Meridian.Core (a plain class library without WinUI). Colors are now stored as hex strings in Core, and FileTagToBrushConverter parses them into SolidColorBrush in the App project. Also added the missing using Meridian.Models; so FileTagColor resolves. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../Converters/FileTagToBrushConverter.cs | 33 ++++++++++++++++-- src/Meridian.Core/Services/FileTagPalette.cs | 34 +++++++++---------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/Meridian.App/Converters/FileTagToBrushConverter.cs b/src/Meridian.App/Converters/FileTagToBrushConverter.cs index 65a99dcc..071264e6 100644 --- a/src/Meridian.App/Converters/FileTagToBrushConverter.cs +++ b/src/Meridian.App/Converters/FileTagToBrushConverter.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Media; using Meridian.Models; @@ -8,16 +9,42 @@ namespace Meridian.Converters; /// Converts a to the -/// used to paint a tag indicator, or null when the item is untagged. +/// used to paint a tag indicator, or null when the item is untagged. Colors are +/// stored as hex strings in so Core stays free of +/// WinUI dependencies; this converter parses them into brushes. public sealed class FileTagToBrushConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, string language) { var tag = value as FileTagColor?; - var color = FileTagPalette.ColorFor(tag); - return color is { } c ? new SolidColorBrush(c) : null; + var hex = FileTagPalette.HexFor(tag); + if (string.IsNullOrEmpty(hex) || string.Equals(hex, "transparent", StringComparison.OrdinalIgnoreCase)) + return null; + try + { + var color = ColorFromHex(hex); + return new SolidColorBrush(color); + } + catch + { + // A malformed hex string must not break rendering; fall back to no tag. + return null; + } } public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotSupportedException(); + + private static Color ColorFromHex(string hex) + { + var h = hex.TrimStart('#'); + byte a = 0xFF, r = 0, g = 0, b = 0; + if (h.Length == 6) + { + r = byte.Parse(h.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + g = byte.Parse(h.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + b = byte.Parse(h.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } + return Color.FromArgb(a, r, g, b); + } } diff --git a/src/Meridian.Core/Services/FileTagPalette.cs b/src/Meridian.Core/Services/FileTagPalette.cs index d3967247..742baec7 100644 --- a/src/Meridian.Core/Services/FileTagPalette.cs +++ b/src/Meridian.Core/Services/FileTagPalette.cs @@ -1,28 +1,30 @@ -using Windows.UI; +using Meridian.Models; namespace Meridian.Services; /// Maps each to a restrained, -/// theme-independent color and its plain-text label. +/// theme-independent hex color and its plain-text label. Colors are stored +/// as hex strings so the Core library stays free of WinUI dependencies; +/// the UI layer converts them to brushes. public static class FileTagPalette { - private static readonly (FileTagColor Tag, Color Color, string Label)[] Entries = + private static readonly (FileTagColor Tag, string Hex, string Label)[] Entries = [ - (FileTagColor.None, Colors.Transparent, "No tag"), - (FileTagColor.Red, ColorFromArgb(0xFF, 0xE4, 0x58, 0x4C), "Red tag"), - (FileTagColor.Orange, ColorFromArgb(0xFF, 0xE8, 0x94, 0x3C), "Orange tag"), - (FileTagColor.Yellow, ColorFromArgb(0xFF, 0xE5, 0xC0, 0x3C), "Yellow tag"), - (FileTagColor.Green, ColorFromArgb(0xFF, 0x6F, 0xB0, 0x5A), "Green tag"), - (FileTagColor.Blue, ColorFromArgb(0xFF, 0x4C, 0x8B, 0xF5), "Blue tag"), - (FileTagColor.Purple, ColorFromArgb(0xFF, 0xB5, 0x7B, 0xDD), "Purple tag"), - (FileTagColor.Gray, ColorFromArgb(0xFF, 0x8A, 0x8F, 0x98), "Gray tag"), + (FileTagColor.None, "transparent", "No tag"), + (FileTagColor.Red, "#E4584C", "Red tag"), + (FileTagColor.Orange, "#E8943C", "Orange tag"), + (FileTagColor.Yellow, "#E5C03C", "Yellow tag"), + (FileTagColor.Green, "#6FB05A", "Green tag"), + (FileTagColor.Blue, "#4C8BF5", "Blue tag"), + (FileTagColor.Purple, "#B57BDD", "Purple tag"), + (FileTagColor.Gray, "#8A8F98", "Gray tag"), ]; - /// The color to paint a tag indicator, or null when untagged. - public static Color? ColorFor(FileTagColor? tag) + /// The hex color to paint a tag indicator, or null when untagged. + public static string? HexFor(FileTagColor? tag) { if (tag is null or FileTagColor.None) return null; - return EntryFor(tag.Value).Color; + return EntryFor(tag.Value).Hex; } /// The plain-text label for a tag, used by accessibility names. @@ -35,13 +37,11 @@ public static string LabelFor(FileTagColor? tag) /// True when a tag is set. public static bool IsTagged(FileTagColor? tag) => tag is not null and not FileTagColor.None; - private static (FileTagColor Tag, Color Color, string Label) EntryFor(FileTagColor tag) + private static (FileTagColor Tag, string Hex, string Label) EntryFor(FileTagColor tag) { foreach (var entry in Entries) if (entry.Tag == tag) return entry; // A future enum value without a palette entry renders as untagged. return Entries[0]; } - - private static Color ColorFromArgb(byte a, byte r, byte g, byte b) => Color.FromArgb(a, r, g, b); } From 34f236df856a7816abdb5f7a98d02df25e519354 Mon Sep 17 00:00:00 2001 From: SeanShaffer88 <252387677+SeanShaffer88@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:48:33 +0000 Subject: [PATCH 6/9] Fix test compile: remove WinUI dependency from FileTagPaletteTests The core test project targets plain net10.0 without WinUI, so FileTagPaletteTests using Windows.UI.Color would not compile. Rewrote the color-distinctness test to compare hex strings instead. Also fixed RenameItems_CaseOnlyChangeRenamesInPlace to check the directory listing ordinally instead of File.Exists(source), which on Windows' case-insensitive filesystem still matches the renamed file. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../Meridian.Core.Tests/FileOperationsTests.cs | 7 +++++-- .../Meridian.Core.Tests/FileTagPaletteTests.cs | 18 +++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/Meridian.Core.Tests/FileOperationsTests.cs b/tests/Meridian.Core.Tests/FileOperationsTests.cs index ee69682c..f429f7f7 100644 --- a/tests/Meridian.Core.Tests/FileOperationsTests.cs +++ b/tests/Meridian.Core.Tests/FileOperationsTests.cs @@ -411,8 +411,11 @@ public void RenameItems_CaseOnlyChangeRenamesInPlace() Assert.Single(results); Assert.Null(results[0].Error); - Assert.False(File.Exists(source)); - Assert.True(File.Exists(Path.Combine(parent, "casing.txt"))); + // On case-insensitive filesystems File.Exists(source) still matches + // the renamed file, so check the directory listing ordinally instead. + var after = Directory.GetFiles(parent); + Assert.DoesNotContain(source, after, StringComparer.Ordinal); + Assert.Contains(Path.Combine(parent, "casing.txt"), after, StringComparer.Ordinal); } [Fact] diff --git a/tests/Meridian.Core.Tests/FileTagPaletteTests.cs b/tests/Meridian.Core.Tests/FileTagPaletteTests.cs index f001f766..84747093 100644 --- a/tests/Meridian.Core.Tests/FileTagPaletteTests.cs +++ b/tests/Meridian.Core.Tests/FileTagPaletteTests.cs @@ -1,28 +1,28 @@ using Meridian.Models; using Meridian.Services; -using Windows.UI; namespace Meridian.Core.Tests; public sealed class FileTagPaletteTests { [Fact] - public void ColorFor_ReturnsNullForNone() + public void HexFor_ReturnsNullForNone() { - Assert.Null(FileTagPalette.ColorFor(null)); - Assert.Null(FileTagPalette.ColorFor(FileTagColor.None)); + Assert.Null(FileTagPalette.HexFor(null)); + Assert.Null(FileTagPalette.HexFor(FileTagColor.None)); } [Fact] - public void ColorFor_ReturnsADistinctColorForEachTag() + public void HexFor_ReturnsADistinctHexColorForEachTag() { var tags = Enum.GetValues().Where(tag => tag != FileTagColor.None); - var colors = new HashSet(); + var colors = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var tag in tags) { - var color = FileTagPalette.ColorFor(tag); - Assert.NotNull(color); - Assert.True(colors.Add(color.Value), $"The {tag} tag shares its color with another tag."); + var hex = FileTagPalette.HexFor(tag); + Assert.NotNull(hex); + Assert.False(hex.Equals("transparent", StringComparison.OrdinalIgnoreCase)); + Assert.True(colors.Add(hex), $"The {tag} tag shares its color with another tag."); } } From 8990c1d174d030e7f0892bfc12baece0a12f870f Mon Sep 17 00:00:00 2001 From: SeanShaffer88 <252387677+SeanShaffer88@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:54:50 +0000 Subject: [PATCH 7/9] Fix broken rename-collision test: destination must differ from source RenameCollisionKeepBoth_UsesTheNextFreeNumberedName renamed report.txt to report.txt (the same path), so the executor fails the item immediately with "The name is unchanged" while the test asserted Completed. Rework it as a real collision: an occupied destination parks the item, resolving with a new name lands the rename under the next free number without replacing anything. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../TransferExecutorTests.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/Meridian.Core.Tests/TransferExecutorTests.cs b/tests/Meridian.Core.Tests/TransferExecutorTests.cs index c59fe3d2..b10760d0 100644 --- a/tests/Meridian.Core.Tests/TransferExecutorTests.cs +++ b/tests/Meridian.Core.Tests/TransferExecutorTests.cs @@ -417,24 +417,31 @@ public async Task Rename_OneMissingSource_DoesNotAbortTheJob() } [Fact] - public async Task RenameCollisionKeepBoth_UsesTheNextFreeNumberedName() + public async Task RenameCollisionKeepBoth_ParksAndRenamesToNextFreeName() { using var fixture = new Fixture(); var source = Path.Combine(fixture.VolumeA, "report.txt"); await File.WriteAllTextAsync(source, "new content"); - await File.WriteAllTextAsync(Path.Combine(fixture.VolumeA, "report (2).txt"), "existing"); + var occupied = Path.Combine(fixture.VolumeA, "report (2).txt"); + await File.WriteAllTextAsync(occupied, "existing"); var job = TransferJob.Create(TransferJobKind.Rename, [source], string.Empty, CollisionPolicy.KeepBoth); job.Items = new List { - new TransferItem { SourcePath = source, DestinationPath = Path.Combine(fixture.VolumeA, "report.txt"), IsDirectory = false, Size = 0 }, + new TransferItem { SourcePath = source, DestinationPath = occupied, IsDirectory = false, Size = 0 }, }; + // A rename cannot overwrite an occupied name, so the item parks for a decision. var result = await new TransferExecutor().ExecuteAsync(job); + Assert.Equal(TransferJobState.WaitingOnConflict, result.State); + var waiting = result.Items.Single(item => item.State == TransferItemState.WaitingOnConflict); - Assert.Equal(TransferJobState.Completed, result.State); - Assert.Equal("new content", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "report.txt"))); + waiting.ResolveConflict(new CollisionDecision(CollisionDecisionKind.Rename, Path.Combine(fixture.VolumeA, "report (3).txt"), "test")); + + var second = await new TransferExecutor().ExecuteAsync(result); + Assert.Equal(TransferJobState.Completed, second.State); Assert.Equal("new content", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "report (3).txt"))); + Assert.Equal("existing", await File.ReadAllTextAsync(occupied)); // never replaced Assert.False(File.Exists(source)); } From cdbfb671aff68221da1d14722dd49b0ad7c0871c Mon Sep 17 00:00:00 2001 From: Sean Shaffer Date: Tue, 3 Nov 2026 18:30:20 -0600 Subject: [PATCH 8/9] Persist rename checkpoints and reconcile recoverable operations --- src/Meridian.App/App.xaml | 6 +- src/Meridian.App/MainWindow.Transfers.cs | 16 + src/Meridian.App/MainWindow.xaml | 52 ++- src/Meridian.App/MainWindow.xaml.cs | 13 +- .../Services/TransferCoordinator.cs | 22 +- src/Meridian.Core/Services/FileTagStore.cs | 116 ++++-- .../Transfers/ITransferFileOperations.cs | 4 + src/Meridian.Core/Transfers/RenameIdentity.cs | 35 ++ .../Transfers/RenamePersistenceException.cs | 3 + .../Transfers/TransferExecutor.cs | 133 ++++++- src/Meridian.Core/Transfers/TransferModel.cs | 3 + .../Transfers/TransferQueueStore.cs | 38 +- src/Meridian.Core/Transfers/UndoPlanner.cs | 31 +- .../RenameRecoveryTests.cs | 372 ++++++++++++++++++ 14 files changed, 751 insertions(+), 93 deletions(-) create mode 100644 src/Meridian.Core/Transfers/RenameIdentity.cs create mode 100644 src/Meridian.Core/Transfers/RenamePersistenceException.cs create mode 100644 tests/Meridian.Core.Tests/RenameRecoveryTests.cs diff --git a/src/Meridian.App/App.xaml b/src/Meridian.App/App.xaml index e307af22..1fa83dd2 100644 --- a/src/Meridian.App/App.xaml +++ b/src/Meridian.App/App.xaml @@ -4,9 +4,6 @@ - - -