diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1127a2b..5d56eae 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- A command panel now docks under the panes (View menu or F4): type a command and it runs in the active pane's folder through PowerShell (or the Command Prompt where PowerShell is absent), with its output shown in the panel as it runs, a stop button for the running command, and Up/Down recall of earlier commands. The panel follows the active pane, so its folder is always the folder you are looking at. It runs commands and shows their output; programs that wait for typed answers still belong in a full terminal — "Open terminal here" remains for those. + +- The Recycle Bin is now browsable like a folder: a Places entry in the sidebar opens it in a pane, showing every item in the signed-in user's bin across all drives with its original location and deletion time. Items can be restored to where they were deleted from (with the name numbered if something already sits there), deleted permanently after a confirmation, or the whole bin emptied; file operations that cannot apply to recycled items step aside while a pane shows the bin, and a tab left on the bin reopens on it. + - The dark theme is now charcoal with a crimson accent: layered charcoal surfaces (#232323 window, #2B2B2B panels, #333333 raised areas, #404040 borders) replace the near-black ones, the default accent is crimson (#DC143C, listed as "Crimson" in Settings; a saved "Red" choice lands on it), the panel hairlines carry the same crimson, and text on the accent is now white. The red-gray selection, green readouts, and the light theme's surfaces are unchanged. - Each pane now keeps its own folder tabs, living in the path-bar area: inactive tabs sit as compact chips above the bar, the active tab is the breadcrumb bar itself, and new-tab and close-tab buttons sit at the bar's right end. Every tab remembers its own back/forward history, the session restores both panes' tabs, and the shortcuts are Ctrl+T (new tab), Ctrl+W (close tab) and Ctrl+Tab / Ctrl+Shift+Tab (cycle); middle-clicking a chip closes that tab. Closed tabs are remembered (the last 50, with their history) and Ctrl+Shift+T reopens the most recently closed one. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0f8c4c8..3471356 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,9 +10,9 @@ See [STATUS.md](STATUS.md) for the dated implemented/CI/runtime inventory. Check Meridian's feature target is KDE's Dolphin, adapted to Windows. Already at parity: dual-pane (split) browsing, per-pane tabs with closed-tab history, clickable breadcrumbs, view modes remembered per folder, a Places-style sidebar, previews, batch rename, sorting and grouping, and content search. The remaining parity work, in planned order: -1. **Virtual locations** — one location layer under the panes, so the Recycle Bin, recent files, search results, and archives browse like folders (builds on Phase 6's archive-as-folder and virtual-view items). +1. **Virtual locations** — one location layer under the panes, so the Recycle Bin, recent files, search results, and archives browse like folders (builds on Phase 6's archive-as-folder and virtual-view items). _Started: the Recycle Bin browses as a virtual location with restore, permanent delete, and empty._ 2. **Indexed search** — a USN-journal-backed index for instant name search, growing toward content (Phase 4). -3. **Embedded terminal panel** that follows the current folder (Phase 7). +3. **Embedded terminal panel** that follows the current folder (Phase 7). _Started: a docked command panel (F4) runs commands in the active pane's folder and streams their output; a fully interactive terminal remains._ 4. **Tags, ratings, and comments** stored locally and searchable, with optional NTFS alternate-data-stream storage investigated for portability (Phase 2). 5. **Network protocols** — SMB browsing first, then optional SFTP, FTP/FTPS, WebDAV, and SCP clients and optional cloud providers, all behind provider interfaces; local file management never depends on them (Phase 6). @@ -222,10 +222,11 @@ Avoid starting large Phase 2/3 experiments until these release blockers are reso - [ ] Improve Android MTP/PTP and supported Apple-device browsing/transfers where Windows APIs permit - [ ] WSL distribution navigation and \wsl$ support; investigate safe read/browse support for EXT4 VHDX images (distribution navigation and \wsl$ browsing delivered; EXT4 VHDX investigation remains) - [ ] Investigate virtual views for Windows Registry hives and running processes, with strict read-only defaults and prominent warnings +- [x] Recycle Bin as a browsable virtual location, with restore, permanent delete, and empty (the first virtual location; Registry/process views remain under the investigation item above) ## Phase 7 — Developer and power-user tools -- [ ] Embedded terminal panel for PowerShell, Command Prompt, Git Bash, or WSL, with explicit working-directory synchronization +- [ ] Embedded terminal panel for PowerShell, Command Prompt, Git Bash, or WSL, with explicit working-directory synchronization (command panel delivered: PowerShell/Command Prompt commands run in the active pane's folder with streamed output and working-directory follow; a fully interactive terminal, Git Bash, and WSL remain open) - [ ] Git repository badges/columns for branch, working-tree status, staged/unstaged changes, ahead/behind, and diff actions (per-file working-tree status column delivered; branch display, ahead/behind counts, and diff actions remain) - [x] Path-copy utilities: Windows, POSIX, UNC, quoted path, filename, directory, file URI, and SHA-256 - [ ] Integrated side-by-side text diff and practical binary comparison diff --git a/src/Meridian.App/MainWindow.BatchRename.cs b/src/Meridian.App/MainWindow.BatchRename.cs index 0bb4658..fac94bf 100644 --- a/src/Meridian.App/MainWindow.BatchRename.cs +++ b/src/Meridian.App/MainWindow.BatchRename.cs @@ -29,6 +29,11 @@ public sealed partial class MainWindow /// single-item rename exactly as it was, warning included. private async Task RenameEntryAsync(PaneState pane) { + if (pane.IsRecycleBin) + { + SetStatus("Recycled items keep the name they were deleted with — restore an item to rename it.", StatusSeverity.Warning); + return; + } if (pane.SelectionList.SelectedItems.Count >= 2) await BatchRenameAsync(pane); else diff --git a/src/Meridian.App/MainWindow.Clipboard.cs b/src/Meridian.App/MainWindow.Clipboard.cs index e0ec292..9aec698 100644 --- a/src/Meridian.App/MainWindow.Clipboard.cs +++ b/src/Meridian.App/MainWindow.Clipboard.cs @@ -103,7 +103,7 @@ private void ApplyCutDimming() /// private async Task PasteClipboardAsFileAsync(PaneState pane) { - if (pane.IsHome || string.IsNullOrWhiteSpace(pane.CurrentPath)) + if (pane.IsHome || pane.IsRecycleBin || string.IsNullOrWhiteSpace(pane.CurrentPath)) { SetStatus("Open a folder to paste into.", StatusSeverity.Warning); return; @@ -195,7 +195,7 @@ private async Task SetClipboardFromPaneAsync(PaneState pane, bool cut) private async Task PasteIntoPaneAsync(PaneState pane) { - if (pane.IsHome || string.IsNullOrWhiteSpace(pane.CurrentPath)) + if (pane.IsHome || pane.IsRecycleBin || string.IsNullOrWhiteSpace(pane.CurrentPath)) { SetStatus("Open a folder to paste into.", StatusSeverity.Warning); return; diff --git a/src/Meridian.App/MainWindow.Preview.cs b/src/Meridian.App/MainWindow.Preview.cs index 5c6c9cc..c4e94a1 100644 --- a/src/Meridian.App/MainWindow.Preview.cs +++ b/src/Meridian.App/MainWindow.Preview.cs @@ -158,6 +158,13 @@ private async Task PopulatePreviewPanelAsync(FileEntry selected, StackPanel pane private async Task OpenSelectedAsync(PaneState pane) { if (pane.SelectionList.SelectedItem is not FileEntry selected) return; + if (pane.IsRecycleBin) + { + // Opening a recycled item restores it — its bin data path is + // never launched or navigated into directly. + await RestoreSelectedAsync(pane); + return; + } if (selected.IsDirectory) { // In the columns layout a selected folder is already cascaded into @@ -186,6 +193,11 @@ private async Task MoveSelectedAsync(PaneState source, PaneState destination) SetStatus("Open a folder in the other pane first — Home is not a move destination.", StatusSeverity.Warning); return; } + if (source.IsRecycleBin || destination.IsRecycleBin) + { + SetStatus("Recycled items cannot be moved directly — restore an item to bring it back, or delete it permanently.", StatusSeverity.Warning); + return; + } if (string.IsNullOrWhiteSpace(destination.CurrentPath)) return; var confirmedPaths = selection.Select(item => item.FullPath).ToArray(); @@ -243,6 +255,7 @@ private async Task CopySelectedAsync(PaneState source, PaneState destination) var selection = GetSelectedEntries(source); if (selection.Count == 0) { SetStatus($"Select an item in the {source.Name.ToLowerInvariant()} pane first.", StatusSeverity.Warning); return; } if (destination.IsHome) { SetStatus("Open a folder in the other pane first \u2014 Home is not a copy destination.", StatusSeverity.Warning); return; } + if (source.IsRecycleBin || destination.IsRecycleBin) { SetStatus("Recycled items cannot be copied directly — restore an item to bring it back, or delete it permanently.", StatusSeverity.Warning); return; } if (string.IsNullOrWhiteSpace(destination.CurrentPath)) return; var sourcePaths = selection.Select(item => item.FullPath).ToArray(); diff --git a/src/Meridian.App/MainWindow.RecycleBin.cs b/src/Meridian.App/MainWindow.RecycleBin.cs new file mode 100644 index 0000000..7b22c25 --- /dev/null +++ b/src/Meridian.App/MainWindow.RecycleBin.cs @@ -0,0 +1,250 @@ +using System.Security.Principal; +using Meridian.Models; +using Meridian.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Meridian; + +/// +/// Recycle Bin browsing: a pane can navigate to the bin sentinel and see +/// every item in the signed-in user's bin across all ready drives, newest +/// deletion first. Entries carry their original path; Restore moves an +/// item back (numbering the name when something already sits there), +/// Delete permanently removes the data for good, and Empty clears the +/// whole bin. All three act on the real bin files through the Core +/// scanner's pairing, so what Meridian shows is exactly what Windows +/// holds. +/// +public sealed partial class MainWindow +{ + private async Task NavigateRecycleBinAsync(PaneState pane, bool addHistory = true) + { + CancelFolderSizeMeasurement(pane); + pane.ReadCancellation?.Cancel(); + pane.ReadCancellation?.Dispose(); + pane.ReadCancellation = new CancellationTokenSource(); + var token = pane.ReadCancellation.Token; + if (addHistory && !string.IsNullOrWhiteSpace(pane.CurrentPath) && + !string.Equals(pane.CurrentPath, RecycleBinPath, StringComparison.OrdinalIgnoreCase)) + { + pane.BackHistory.Push(pane.CurrentPath); + pane.ForwardHistory.Clear(); + } + pane.CurrentPath = RecycleBinPath; + ResetFind(pane); + SetPaneStatus(pane, "Reading the Recycle Bin…"); + IReadOnlyList recycled; + try + { + recycled = await Task.Run(ScanRecycleBin, token); + } + catch (OperationCanceledException) { return false; } + if (token.IsCancellationRequested) return false; + var entries = recycled.Select(RecycledEntryFor).ToArray(); + RebuildBreadcrumbs(pane); + UpdatePaneHeader(pane); + pane.AllItems = entries; + ApplyFilter(pane); + ApplyPaneMode(pane); + ClearColumns(pane); + RefreshSidebarSelection(); + RefreshPreviewBlock(pane); + var totalBytes = recycled.Sum(item => item.SizeBytes); + SetPaneStatus(pane, entries.Length == 0 + ? "Recycle Bin · empty" + : $"Recycle Bin · {entries.Length:N0} items · {DirectoryReader.FormatSize(totalBytes)}"); + SetStatus("Ready · Local-only · No telemetry"); + SyncActiveTabFromPane(pane); + RebuildTabStrip(pane); + if (!_restoringSession) await PersistSessionAsync(); + UpdatePaneWatch(pane); + return true; + } + + private static IReadOnlyList ScanRecycleBin() + { + var sid = WindowsIdentity.GetCurrent().User?.Value; + if (string.IsNullOrEmpty(sid)) return Array.Empty(); + var all = new List(); + foreach (var drive in DriveInfo.GetDrives()) + { + if (!drive.IsReady) continue; + all.AddRange(RecycleBinScanner.Scan(RecycleBinScanner.BinFolderFor(drive.RootDirectory.FullName, sid))); + } + all.Sort((a, b) => b.DeletedUtc.CompareTo(a.DeletedUtc)); + return all; + } + + private static FileEntry RecycledEntryFor(RecycledItem item) + { + var original = item.OriginalPath; + var cut = original.LastIndexOf('\\'); + var name = cut >= 0 && cut < original.Length - 1 ? original[(cut + 1)..] : original; + var dot = name.LastIndexOf('.'); + var type = item.IsDirectory ? "Folder" : dot > 0 ? $"{name[(dot + 1)..].ToUpperInvariant()} file" : "File"; + return new FileEntry + { + Name = name, + FullPath = item.DataPath, + IsDirectory = item.IsDirectory, + TypeLabel = type, + SizeLabel = item.IsDirectory ? "—" : DirectoryReader.FormatSize(item.SizeBytes), + SizeBytes = item.IsDirectory ? 0 : item.SizeBytes, + ModifiedUtc = item.DeletedUtc, + ModifiedLabel = item.DeletedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"), + AgeLabel = RelativeAge.Format(item.DeletedUtc, DateTime.UtcNow), + OriginalPath = original + }; + } + + /// The $I info file paired with a bin entry's data file. + private static string InfoPathFor(FileEntry entry) => + Path.Combine(Path.GetDirectoryName(entry.FullPath)!, "$I" + Path.GetFileName(entry.FullPath)[2..]); + + private async Task RestoreSelectedAsync(PaneState pane) + { + var selection = GetSelectedEntries(pane); + if (selection.Count == 0) + { + SetStatus("Select a recycled item to restore.", StatusSeverity.Warning); + return; + } + var restored = 0; + var failed = new List(); + await Task.Run(() => + { + foreach (var entry in selection) + { + try + { + var target = entry.OriginalPath!; + var parent = Path.GetDirectoryName(target); + if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(parent); + if (File.Exists(target) || Directory.Exists(target)) + target = FreeRestoredName(target); + if (entry.IsDirectory) Directory.Move(entry.FullPath, target); + else File.Move(entry.FullPath, target); + File.Delete(InfoPathFor(entry)); + restored++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + failed.Add($"{entry.Name}: {ex.Message}"); + } + } + }); + if (failed.Count == 0) + SetStatus($"Restored {restored:N0} item{(restored == 1 ? "" : "s")} from the Recycle Bin.", StatusSeverity.Success); + else + SetStatus($"Restored {restored:N0}; {failed.Count:N0} could not be restored · {failed[0]}", StatusSeverity.Error); + await NavigateRecycleBinAsync(pane, addHistory: false); + } + + /// "name (restored).ext", then "name (restored 2).ext", while the plain name is taken. + private static string FreeRestoredName(string target) + { + var parent = Path.GetDirectoryName(target)!; + var name = Path.GetFileNameWithoutExtension(target); + var extension = Path.GetExtension(target); + for (var n = 1; ; n++) + { + var suffix = n == 1 ? " (restored)" : $" (restored {n})"; + var candidate = Path.Combine(parent, name + suffix + extension); + if (!File.Exists(candidate) && !Directory.Exists(candidate)) return candidate; + } + } + + private async Task DeleteRecycledPermanentlyAsync(PaneState pane) + { + var selection = GetSelectedEntries(pane); + if (selection.Count == 0) + { + SetStatus("Select a recycled item to delete permanently.", StatusSeverity.Warning); + return; + } + var dialog = new ContentDialog + { + Title = "Delete permanently?", + Content = selection.Count == 1 + ? $"Delete \"{selection[0].Name}\" for good?\n\n{selection[0].OriginalPath}\n\nThis cannot be undone." + : $"Delete {selection.Count:N0} recycled items for good?\n\nThis cannot be undone.", + PrimaryButtonText = "Delete permanently", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = RootGrid.XamlRoot + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + var deleted = 0; + var failed = new List(); + await Task.Run(() => + { + foreach (var entry in selection) + { + try + { + if (entry.IsDirectory) + { + // Recycled folders can hold read-only files; clear + // the flags so the recursive delete can finish. + foreach (var file in Directory.EnumerateFiles(entry.FullPath, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(entry.FullPath, recursive: true); + } + else + { + File.SetAttributes(entry.FullPath, FileAttributes.Normal); + File.Delete(entry.FullPath); + } + File.Delete(InfoPathFor(entry)); + deleted++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + failed.Add($"{entry.Name}: {ex.Message}"); + } + } + }); + if (failed.Count == 0) + SetStatus($"Deleted {deleted:N0} item{(deleted == 1 ? "" : "s")} permanently.", StatusSeverity.Success); + else + SetStatus($"Deleted {deleted:N0}; {failed.Count:N0} could not be deleted · {failed[0]}", StatusSeverity.Error); + await NavigateRecycleBinAsync(pane, addHistory: false); + } + + private async void EmptyRecycleBin_Click(object sender, RoutedEventArgs e) + { + var pane = ReferenceEquals(sender, LeftEmptyBinMenuItem) ? _left : _right; + var dialog = new ContentDialog + { + Title = "Empty the Recycle Bin?", + Content = "Every item in the Recycle Bin, on every drive, is deleted for good.\n\nThis cannot be undone.", + PrimaryButtonText = "Empty Recycle Bin", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = RootGrid.XamlRoot + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + await Task.Run(() => + { + foreach (var item in ScanRecycleBin()) + { + try + { + if (item.IsDirectory) Directory.Delete(item.DataPath, recursive: true); + else File.Delete(item.DataPath); + File.Delete(item.InfoPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { /* reported by the refresh below */ } + } + }); + SetStatus("Recycle Bin emptied.", StatusSeverity.Success); + if (_left.IsRecycleBin) await NavigateRecycleBinAsync(_left, addHistory: false); + if (_right.IsRecycleBin) await NavigateRecycleBinAsync(_right, addHistory: false); + } + + private async void RestoreRecycledFromLeft_Click(object sender, RoutedEventArgs e) => await RestoreSelectedAsync(_left); + private async void RestoreRecycledFromRight_Click(object sender, RoutedEventArgs e) => await RestoreSelectedAsync(_right); + private async void DeleteRecycledFromLeft_Click(object sender, RoutedEventArgs e) => await DeleteRecycledPermanentlyAsync(_left); + private async void DeleteRecycledFromRight_Click(object sender, RoutedEventArgs e) => await DeleteRecycledPermanentlyAsync(_right); +} diff --git a/src/Meridian.App/MainWindow.Sidebar.cs b/src/Meridian.App/MainWindow.Sidebar.cs index dc97648..1ab1c14 100644 --- a/src/Meridian.App/MainWindow.Sidebar.cs +++ b/src/Meridian.App/MainWindow.Sidebar.cs @@ -42,6 +42,7 @@ private void BuildSidebar() AddSidebarItem(Symbol.Audio, "Music", KnownFolderPath(Environment.SpecialFolder.MyMusic), "Open the Music folder in the active pane"); AddSidebarItem(Symbol.Pictures, "Pictures", KnownFolderPath(Environment.SpecialFolder.MyPictures), "Open the Pictures folder in the active pane"); AddSidebarItem(Symbol.Video, "Videos", KnownFolderPath(Environment.SpecialFolder.MyVideos), "Open the Videos folder in the active pane"); + AddSidebarItem(Symbol.Delete, "Recycle Bin", RecycleBinPath, "Browse the Recycle Bin in the active pane — restore items or delete them for good"); AddSidebarSection("Favorites"); // Ungrouped favorites first (in saved order, exactly as the flat // list always showed them), then each group under its header. @@ -541,7 +542,8 @@ private void AddSidebarItem(Symbol glyph, string label, string targetPath, strin AutomationProperties.SetName(button, label); if (contextFlyout is not null) button.ContextFlyout = contextFlyout; button.Click += SidebarItem_Click; - if (!string.Equals(targetPath, HomePath, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(targetPath, HomePath, StringComparison.OrdinalIgnoreCase) && + !string.Equals(targetPath, RecycleBinPath, StringComparison.OrdinalIgnoreCase)) { button.AllowDrop = true; button.DragOver += SidebarItem_DragOver; diff --git a/src/Meridian.App/MainWindow.Tabs.cs b/src/Meridian.App/MainWindow.Tabs.cs index 8c45251..96b61e3 100644 --- a/src/Meridian.App/MainWindow.Tabs.cs +++ b/src/Meridian.App/MainWindow.Tabs.cs @@ -51,7 +51,9 @@ private void RebuildTabStrip(PaneState pane) if (i == set.ActiveIndex) continue; var index = i; var tab = set.Tabs[i]; - var label = PaneTabSet.LabelFor(tab.Path, HomePath); + var label = string.Equals(tab.Path, RecycleBinPath, StringComparison.OrdinalIgnoreCase) + ? "Recycle Bin" + : PaneTabSet.LabelFor(tab.Path, HomePath); var chip = new Button { Content = label, Padding = new Thickness(8, 2, 8, 2), FontSize = 12 }; ToolTipService.SetToolTip(chip, tab.Path + " — open this tab (middle-click closes it)"); AutomationProperties.SetName(chip, $"Tab {label}, {pane.Name} pane"); diff --git a/src/Meridian.App/MainWindow.Templates.cs b/src/Meridian.App/MainWindow.Templates.cs index 0de5cd5..7405a20 100644 --- a/src/Meridian.App/MainWindow.Templates.cs +++ b/src/Meridian.App/MainWindow.Templates.cs @@ -82,7 +82,7 @@ private void EnsureTemplatesFolder() private async Task CreateFromTemplateAsync(PaneState pane, string templatePath) { - if (pane.IsHome || string.IsNullOrWhiteSpace(pane.CurrentPath)) + if (pane.IsHome || pane.IsRecycleBin || string.IsNullOrWhiteSpace(pane.CurrentPath)) { SetStatus("Open a folder to create the file in.", StatusSeverity.Warning); return; diff --git a/src/Meridian.App/MainWindow.TerminalPanel.cs b/src/Meridian.App/MainWindow.TerminalPanel.cs new file mode 100644 index 0000000..63f0478 --- /dev/null +++ b/src/Meridian.App/MainWindow.TerminalPanel.cs @@ -0,0 +1,204 @@ +using System.Diagnostics; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.System; + +namespace Meridian; + +/// +/// The embedded command panel (F4): a docked strip under the panes with +/// a command line that runs in the active pane's folder and shows the +/// command's output inline. Commands run through Windows PowerShell when +/// it is present, else the Command Prompt; output streams in as it is +/// produced, and the stop button kills the whole process tree. This is a +/// command runner, not an interactive terminal — programs that wait for +/// typed answers cannot be driven here, and "Open terminal here" remains +/// the route for those. The panel follows the active pane: the folder +/// label always names the folder the next command will run in. +/// +public sealed partial class MainWindow +{ + private const int CommandOutputCap = 200_000; + + private Process? _commandProcess; + private int _commandRunId; + private readonly List _commandHistory = []; + private int _commandHistoryIndex = -1; + + private void CommandPanelAccelerator_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + args.Handled = true; + SetCommandPanelVisible(CommandPanel.Visibility != Visibility.Visible); + } + + private void CommandPanelToggle_Click(object sender, RoutedEventArgs e) => + SetCommandPanelVisible(CommandPanelToggle.IsChecked == true); + + private void CommandPanelClose_Click(object sender, RoutedEventArgs e) => SetCommandPanelVisible(false); + + private void SetCommandPanelVisible(bool visible) + { + CommandPanel.Visibility = visible ? Visibility.Visible : Visibility.Collapsed; + CommandPanelToggle.IsChecked = visible; + if (visible) + { + RefreshCommandPanelFolder(); + CommandPanelInput.Focus(FocusState.Programmatic); + } + } + + /// The folder the next command runs in: the active pane's folder, or the user profile at Home / in the bin. + private string CommandPanelFolder() + { + var path = _activePane?.CurrentPath; + return !string.IsNullOrWhiteSpace(path) && Directory.Exists(path) + ? path + : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + + private void RefreshCommandPanelFolder() + { + if (CommandPanel.Visibility != Visibility.Visible) return; + CommandPanelFolderLabel.Text = $"Command panel · runs in {CommandPanelFolder()}"; + } + + private void CommandPanelClear_Click(object sender, RoutedEventArgs e) => CommandPanelOutput.Text = string.Empty; + + private void CommandPanelCancel_Click(object sender, RoutedEventArgs e) + { + try { _commandProcess?.Kill(entireProcessTree: true); } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or NotSupportedException) { } + } + + private void CommandPanelInput_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Enter) + { + e.Handled = true; + var input = CommandPanelInput.Text.Trim(); + if (input.Length == 0) return; + CommandPanelInput.Text = string.Empty; + _commandHistory.Remove(input); + _commandHistory.Add(input); + _commandHistoryIndex = -1; + _ = RunPanelCommandAsync(input); + } + else if (e.Key == VirtualKey.Up && _commandHistory.Count > 0) + { + e.Handled = true; + _commandHistoryIndex = _commandHistoryIndex < 0 + ? _commandHistory.Count - 1 + : Math.Max(0, _commandHistoryIndex - 1); + CommandPanelInput.Text = _commandHistory[_commandHistoryIndex]; + CommandPanelInput.SelectionStart = CommandPanelInput.Text.Length; + } + else if (e.Key == VirtualKey.Down && _commandHistoryIndex >= 0) + { + e.Handled = true; + _commandHistoryIndex++; + if (_commandHistoryIndex >= _commandHistory.Count) + { + _commandHistoryIndex = -1; + CommandPanelInput.Text = string.Empty; + } + else + { + CommandPanelInput.Text = _commandHistory[_commandHistoryIndex]; + CommandPanelInput.SelectionStart = CommandPanelInput.Text.Length; + } + } + } + + private void AppendCommandOutput(string text) + { + if (text.Length == 0) return; + var combined = CommandPanelOutput.Text + text; + if (combined.Length > CommandOutputCap) + combined = "… earlier output trimmed …\n" + combined[^(CommandOutputCap - 32)..]; + CommandPanelOutput.Text = combined; + CommandPanelScroll.ChangeView(null, CommandPanelScroll.ScrollableHeight + 1000, null, disableAnimation: true); + } + + private async Task RunPanelCommandAsync(string input) + { + if (_commandProcess is not null) + { + AppendCommandOutput("A command is still running — stop it before starting another.\n"); + return; + } + var folder = CommandPanelFolder(); + var powerShell = Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"); + var usePowerShell = File.Exists(powerShell); + var start = new ProcessStartInfo + { + FileName = usePowerShell ? powerShell : "cmd.exe", + WorkingDirectory = folder, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + if (usePowerShell) + { + start.ArgumentList.Add("-NoProfile"); + start.ArgumentList.Add("-Command"); + start.ArgumentList.Add(input); + } + else + { + start.ArgumentList.Add("/c"); + start.ArgumentList.Add(input); + } + AppendCommandOutput($"\n{folder}> {input}\n"); + var runId = ++_commandRunId; + Process process; + try + { + process = Process.Start(start) ?? throw new InvalidOperationException("The command could not be started."); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or IOException) + { + AppendCommandOutput("Could not start: " + ex.Message + "\n"); + return; + } + _commandProcess = process; + CommandPanelCancelButton.IsEnabled = true; + process.OutputDataReceived += (_, args) => + { + if (args.Data is not null) + DispatcherQueue.TryEnqueue(() => { if (runId == _commandRunId) AppendCommandOutput(args.Data + "\n"); }); + }; + process.ErrorDataReceived += (_, args) => + { + if (args.Data is not null) + DispatcherQueue.TryEnqueue(() => { if (runId == _commandRunId) AppendCommandOutput(args.Data + "\n"); }); + }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + try + { + await process.WaitForExitAsync(); + // Give the final output events a moment to land before the close line. + await Task.Delay(60); + DispatcherQueue.TryEnqueue(() => + { + if (runId != _commandRunId) return; + AppendCommandOutput($"[finished with code {process.ExitCode}]\n"); + }); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + // The process was killed or never fully started; the close is enough. + } + finally + { + if (runId == _commandRunId) + { + _commandProcess = null; + DispatcherQueue.TryEnqueue(() => CommandPanelCancelButton.IsEnabled = false); + } + process.Dispose(); + } + } +} diff --git a/src/Meridian.App/MainWindow.Transfers.cs b/src/Meridian.App/MainWindow.Transfers.cs index 43863c3..4cbe488 100644 --- a/src/Meridian.App/MainWindow.Transfers.cs +++ b/src/Meridian.App/MainWindow.Transfers.cs @@ -570,6 +570,14 @@ private void PaneList_DragItemsStarting(object sender, DragItemsStartingEventArg string? sourceFolder = null; if (ReferenceEquals(sender, LeftItems)) { pane = _left; sourceFolder = _left.CurrentPath; } else if (ReferenceEquals(sender, RightItems)) { pane = _right; sourceFolder = _right.CurrentPath; } + // Recycled items are never dragged: their paths point inside the + // bin's storage, and a drop elsewhere would scatter $R-named files. + if (pane is not null && pane.IsRecycleBin) + { + e.Cancel = true; + SetStatus("Recycled items cannot be dragged out — restore an item to bring it back.", StatusSeverity.Warning); + return; + } else { foreach (var candidate in new[] { _left, _right }) @@ -728,7 +736,8 @@ private async Task HandleDropAsync(DragEventArgs e, string backgroundDestination if (list is not null && TryHitFolderRow(list, e, out var folderRow)) destination = folderRow; if (string.IsNullOrWhiteSpace(destination) - || string.Equals(destination, HomePath, StringComparison.OrdinalIgnoreCase)) + || string.Equals(destination, HomePath, StringComparison.OrdinalIgnoreCase) + || string.Equals(destination, RecycleBinPath, StringComparison.OrdinalIgnoreCase)) return null; var sources = await ResolveDragSourcePathsAsync(e); diff --git a/src/Meridian.App/MainWindow.xaml b/src/Meridian.App/MainWindow.xaml index f6cfa9f..68dcabb 100644 --- a/src/Meridian.App/MainWindow.xaml +++ b/src/Meridian.App/MainWindow.xaml @@ -6,7 +6,7 @@ xmlns:services="using:Meridian.Services" xmlns:local="using:Meridian" Title="Meridian"> - +