From cf667bdf77744a39b5b3fb9aafa11195e07aa13e Mon Sep 17 00:00:00 2001 From: Sean Shaffer Date: Tue, 3 Nov 2026 00:43:22 -0600 Subject: [PATCH 1/2] Round 10C: embedded command panel (F4) --- docs/CHANGELOG.md | 2 + docs/ROADMAP.md | 4 +- src/Meridian.App/MainWindow.TerminalPanel.cs | 204 ++++++++++++++++++ src/Meridian.App/MainWindow.xaml | 29 ++- src/Meridian.App/MainWindow.xaml.cs | 1 + src/Meridian.Core/Services/CommandLine.cs | 52 +++++ tests/Meridian.Core.Tests/CommandLineTests.cs | 45 ++++ 7 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 src/Meridian.App/MainWindow.TerminalPanel.cs create mode 100644 src/Meridian.Core/Services/CommandLine.cs create mode 100644 tests/Meridian.Core.Tests/CommandLineTests.cs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1127a2b..ccdaba5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## 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 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..1403052 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,7 +12,7 @@ Meridian's feature target is KDE's Dolphin, adapted to Windows. Already at parit 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). 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). @@ -225,7 +225,7 @@ Avoid starting large Phase 2/3 experiments until these release blockers are reso ## 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.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.xaml b/src/Meridian.App/MainWindow.xaml index f6cfa9f..cfa4e08 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"> - +