diff --git a/CHANGELOG.md b/CHANGELOG.md index 068144b..83d581d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The flight log grows with the window, and its text can be copied or saved.** Drag Fido taller and + every spare pixel now goes to the **flight log** instead of to a gap above it — the upper section + keeps the room its content needs and the log takes the rest; shrink the window and the log falls back + to its compact box while the upper section scrolls as before. Two new buttons on the **Flight log** + rule lift the narration out: **copy** puts the whole log (every line, not just the visible ones) on + the clipboard as plain text, and **save** writes it to a text file you pick, suggesting a dated name + like `fido-flight-log-20260806-142317.txt`. Both are disabled until there's something to hand over, + and both confirm themselves in the log. + - **Copy the selected working-tree path to the clipboard.** The OPEN strip now has a small **copy button** beside the path, and the ellipsised card and strip paths carry a **tooltip with the full path** — so a long worktree path (previously truncated and un-selectable) can be read in full and diff --git a/Docs/Features.md b/Docs/Features.md index 8e16e44..3b1cf02 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -275,6 +275,21 @@ green `✓` for successes, plain `▸` for actions — and failures call it stra lines for a branch that isn't checked out anywhere, a tool that can't be located, or a delete that went wrong. +The panel **grows with the window**: drag Fido's bottom edge down and every spare pixel +goes to the log rather than to a gap above it — the upper section keeps as much room as +its content needs, and the log takes the rest. Shrink the window again and the log falls +back to its compact box while the upper section scrolls. + +Two buttons on the **Flight log** rule take the narration with you: + +- **Copy** puts the whole log — every line, not just the visible ones — on the clipboard + as plain text. +- **Save** writes it to a text file you pick, suggesting a dated name like + `fido-flight-log-20260806-142317.txt`. + +Both are disabled until there's something to hand over, and each confirms itself in the +log (`📋 Copied 8 flight-log line(s) to the clipboard.`, `✓ Flight log saved to …`). + ### Keyboard & shortcuts - The **branch** field is focused on launch. Typing debounces into a scan; **Enter** diff --git a/src/Services/AvaloniaDialogService.cs b/src/Services/AvaloniaDialogService.cs index a2137e9..5334edc 100644 --- a/src/Services/AvaloniaDialogService.cs +++ b/src/Services/AvaloniaDialogService.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Platform.Storage; using Fido.Models; using Fido.Views; @@ -16,4 +17,29 @@ public Task ConfirmForceDeleteWorktreeFolderAsync(WorktreeForceDelete requ public Task ShowSettingsAsync(AppConfig config, ConfigService configService) => new SettingsDialog(config, configService).ShowDialog(_owner); + + public async Task PickFlightLogPathAsync(string suggestedFileName) + { + var storage = _owner.StorageProvider; + if (!storage.CanSave) return null; + + var file = await storage.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save flight log", + SuggestedFileName = suggestedFileName, + DefaultExtension = "txt", + ShowOverwritePrompt = true, + FileTypeChoices = + [ + new FilePickerFileType("Text file") + { + Patterns = ["*.txt"], + MimeTypes = ["text/plain"], + }, + ], + }); + + // A picked file is written by path — Fido isn't sandboxed, and the log is plain text. + return file?.TryGetLocalPath(); + } } diff --git a/src/Services/IDialogService.cs b/src/Services/IDialogService.cs index df82cf3..ad74c87 100644 --- a/src/Services/IDialogService.cs +++ b/src/Services/IDialogService.cs @@ -19,4 +19,10 @@ public interface IDialogService /// Opens the settings dialog (modal). Task ShowSettingsAsync(AppConfig config, ConfigService configService); + + /// + /// Asks where to save the flight log, offering . Returns the + /// chosen path, or null when the user cancelled (or the platform has no save picker). + /// + Task PickFlightLogPathAsync(string suggestedFileName); } diff --git a/src/Theme/FidoStyles.axaml b/src/Theme/FidoStyles.axaml index d50b0a7..228808b 100644 --- a/src/Theme/FidoStyles.axaml +++ b/src/Theme/FidoStyles.axaml @@ -566,8 +566,8 @@ - - - - - - - + + + diff --git a/src/ViewModels/MainWindowViewModel.cs b/src/ViewModels/MainWindowViewModel.cs index 0a56f0b..46d6e48 100644 --- a/src/ViewModels/MainWindowViewModel.cs +++ b/src/ViewModels/MainWindowViewModel.cs @@ -16,6 +16,10 @@ namespace Fido.ViewModels; /// public sealed class MainWindowViewModel : ObservableObject { + public MainWindowViewModel() => + // The log's copy/save actions are gated on there being something to hand over. + Log.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLog)); + // --- Inputs ----------------------------------------------------------------------- private string _branchName = ""; @@ -531,6 +535,15 @@ static void Replace(ObservableCollection target, IEnumerable sou /// Color-coded flight-log lines. public ObservableCollection Log { get; } = new(); + /// True once the log has a line in it — enables the copy/save actions above the panel. + public bool HasLog => Log.Count > 0; + + /// + /// The whole flight log as plain text, one line per entry — what the copy-to-clipboard and + /// save-to-file actions hand over. Colour levels are presentation only and don't survive the trip. + /// + public string LogText => string.Join(Environment.NewLine, Log.Select(line => line.Text)); + public void AppendLog(string message) { if (Dispatcher.UIThread.CheckAccess()) diff --git a/src/Views/MainWindow.axaml b/src/Views/MainWindow.axaml index eed60f3..e424204 100644 --- a/src/Views/MainWindow.axaml +++ b/src/Views/MainWindow.axaml @@ -14,12 +14,14 @@ Background="{DynamicResource FidoWindowBg}"> - + The upper stack scrolls if the discovery list outgrows the window; the flight log sits near the + bottom and takes whatever vertical slack the window has beyond the upper stack (see + MainWindow.UpdateFlightLogHeight); the auto-close countdown pins beneath it. --> + - - + + @@ -292,7 +294,7 @@ - + + - + @@ -464,7 +484,7 @@ - diff --git a/src/Views/MainWindow.axaml.cs b/src/Views/MainWindow.axaml.cs index cd95747..da92712 100644 --- a/src/Views/MainWindow.axaml.cs +++ b/src/Views/MainWindow.axaml.cs @@ -21,6 +21,13 @@ public partial class MainWindow : Window /// How long the branch box stays quiet before a scan fires (Enter fires immediately). internal static readonly TimeSpan ScanDebounce = TimeSpan.FromMilliseconds(600); + /// + /// The flight log's content-sized ceiling from the redesign: with the window only as tall as its + /// content the panel grows with the lines up to this, and no further. Past it the panel is sized by + /// the window's slack instead — see . + /// + private const double LogContentMaxHeight = 150; + private readonly MainWindowViewModel _vm = new(); private readonly ConfigService _configService; private readonly GitService _git; @@ -56,6 +63,13 @@ public partial class MainWindow : Window /// Live while a post-launch auto-close countdown is running; cancelling it aborts the close. private CancellationTokenSource? _closeCountdown; + /// + /// The discovery scan a CLI-supplied branch kicks off on open. Tests await this instead of starting a + /// scan of their own: whichever scan lands first consumes the run's one-shots (the auto-open, the + /// unknown-tool report) and a superseding scan clears the log, so racing it is a coin toss. + /// + internal Task StartupScan { get; private set; } = Task.CompletedTask; + public MainWindow() : this(FidoServices.CreateDefault()) { } @@ -104,6 +118,11 @@ internal MainWindow(FidoServices services) _opener = new OpenerService(_git, services.Finder, services.WorkingTreeFinder, _vm.AppendLog, _vm.AppendLiveLog, gitHub: services.GitHub); _vm.Log.CollectionChanged += (_, _) => Dispatcher.UIThread.Post(ScrollLogToEnd, DispatcherPriority.Background); + // The flight log absorbs whatever vertical room the rest of the screen doesn't need, so a taller + // window means a taller log rather than a gap above it. Re-run after every layout pass: the + // window can be resized, and the upper stack's own height moves with the discovery results. + LayoutUpdated += (_, _) => UpdateFlightLogHeight(); + var startup = ApplyStartupArgs(); Opened += (_, _) => { @@ -122,7 +141,7 @@ internal MainWindow(FidoServices services) _autoOpenTool = startup.Tool; _startupUnknownToolSlug = startup.UnknownToolSlug; _startupPreferFolder = startup.PreferFolder; - _ = RunDiscoveryAsync(); + StartupScan = RunDiscoveryAsync(); } }; } @@ -524,6 +543,123 @@ internal async Task CopySelectedPathAsync() } } + // --- Flight log ----------------------------------------------------------------------- + + private void ScrollLogToEnd() => LogScroller.Offset = new Vector(0, LogScroller.Extent.Height); + + /// + /// Hands the flight log every pixel the rest of the screen isn't using, so making the window taller + /// grows the log panel rather than opening a gap above it. With no slack to give — a short window, or + /// a discovery list filling it — the panel falls back to the redesign's content-sized 104…150 box and + /// the upper section scrolls as before. + /// + private void UpdateFlightLogHeight() + { + // While the window is still auto-sizing to its content there is no slack by definition — the + // height is whatever the screen asked for — and taking any would start a fight the two can't + // finish: the window trails the panel by a layout pass, so it would shrink back to the panel's + // old height, hand out that difference again, and never settle. Avalonia drops SizeToContent the + // moment the user drags an edge, which is exactly when there is room to give. + if (SizeToContent is SizeToContent.Height or SizeToContent.WidthAndHeight) + { + ResetFlightLogHeight(); + return; + } + + // The upper section scrolls, so it never needs more than its content's height: what's left of the + // window after that, the log's own label row, and the countdown bar belongs to the panel. Both + // subtrahends are *desired* heights — unchanged by what the panel is actually given — so with the + // window's height fixed this lands in one further layout pass. + var chrome = LogRegion.DesiredSize.Height - LogPanel.DesiredSize.Height; // label row + gaps + margin + var spare = RootGrid.Bounds.Height + - UpperStack.DesiredSize.Height + - CountdownBar.DesiredSize.Height + - chrome; + + if (spare <= LogContentMaxHeight) + { + ResetFlightLogHeight(); + return; + } + + // Sub-pixel drift isn't worth a whole layout pass to chase. + if (Math.Abs(spare - LogPanel.Height) < 1) return; + LogPanel.MaxHeight = double.PositiveInfinity; + LogPanel.Height = spare; + } + + /// Back to the redesign's content-sized panel: 104px, growing with the lines to 150px. + private void ResetFlightLogHeight() + { + LogPanel.Height = double.NaN; + LogPanel.MaxHeight = LogContentMaxHeight; + } + + private async void OnCopyLogClick(object? sender, RoutedEventArgs e) => await CopyFlightLogAsync(); + + private async void OnSaveLogClick(object? sender, RoutedEventArgs e) => await SaveFlightLogAsync(); + + /// + /// Copies the whole flight log to the clipboard as plain text — the panel shows only its last few + /// lines, and a launch that went wrong is worth pasting somewhere. Best-effort: a missing or throwing + /// clipboard is reported, never crashes the async-void click. Internal for tests. + /// + internal async Task CopyFlightLogAsync() + { + var text = _vm.LogText; + if (text.Length == 0) return; + var lines = _vm.Log.Count; + + var clipboard = Clipboard; + if (clipboard is null) + { + _vm.AppendLog("⚠ Clipboard unavailable — couldn't copy the flight log."); + return; + } + + try + { + await clipboard.SetTextAsync(text); + _vm.AppendLog($"📋 Copied {lines} flight-log line(s) to the clipboard."); + } + catch (Exception ex) + { + _vm.AppendLog($"⚠ Couldn't copy the flight log: {ex.Message}"); + } + } + + /// + /// Saves the flight log to a text file the user picks. Cancelling the picker is silent — nothing was + /// asked for; a save that fails says so in the log itself. Internal for tests. + /// + internal async Task SaveFlightLogAsync() + { + var text = _vm.LogText; + if (text.Length == 0) return; + + string? path; + try + { + path = await _dialogs.PickFlightLogPathAsync($"fido-flight-log-{DateTime.Now:yyyyMMdd-HHmmss}.txt"); + } + catch (Exception ex) + { + _vm.AppendLog($"⚠ Couldn't open the save dialog: {ex.Message}"); + return; + } + if (string.IsNullOrWhiteSpace(path)) return; + + try + { + await File.WriteAllTextAsync(path, text + Environment.NewLine); + _vm.AppendLog($"✓ Flight log saved to {path}"); + } + catch (Exception ex) + { + _vm.AppendLog($"⚠ Couldn't save the flight log: {ex.Message}"); + } + } + // --- Default tool popover / settings --------------------------------------------------- /// Rebuilds the gear popover's radio list from config, ticking the persisted default. @@ -781,8 +917,6 @@ private void ReportUnknownTool(string slug) /// didn't match, and whether --folder asked the run to start on the Folder chip. private sealed record StartupPlan(bool BranchProvided, Editor? Tool, string? UnknownToolSlug, bool PreferFolder); - private void ScrollLogToEnd() => LogScroller.Offset = new Vector(0, LogScroller.Extent.Height); - // --- Auto-close ----------------------------------------------------------------------- /// diff --git a/tests/Fido.Tests/E2E/FlightLogTests.cs b/tests/Fido.Tests/E2E/FlightLogTests.cs new file mode 100644 index 0000000..56fdf19 --- /dev/null +++ b/tests/Fido.Tests/E2E/FlightLogTests.cs @@ -0,0 +1,191 @@ +using System.IO; +using Avalonia.Controls; +using Avalonia.Input.Platform; +using Avalonia.Threading; +using Fido.Tests.Infrastructure; + +namespace Fido.Tests.E2E; + +/// +/// The flight log at the bottom of the main screen: it takes whatever vertical room the window has +/// spare (so a taller window means a taller log, not a gap above it), and its narration can be lifted +/// out — the whole log to the clipboard, or saved to a text file the user picks. +/// +[NotInParallel] +public class FlightLogTests +{ + [Test] + public async Task Copying_the_flight_log_puts_every_line_on_the_clipboard() + { + using var world = new TestRepoWorld(); + var origin = world.CreateOrigin("Foo", "Foo"); + var root = world.SearchRoot("root"); + world.Clone(origin, root, "Foo"); + + var services = world.BuildServices([root], new FakeEditorLauncher(), new FakeDialogService()); + + await Harness.WithWindow(services, async window => + { + await window.Discover("main"); + var lines = window.Vm().Log.Count; + await Assert.That(lines).IsGreaterThan(1); + + await window.CopyFlightLogAsync(); + + var clipboard = TopLevel.GetTopLevel(window)?.Clipboard; + await Assert.That(clipboard).IsNotNull(); + var copied = await clipboard!.TryGetTextAsync(); + + // Everything the panel had — the mission-control preamble through the scan result. + await Assert.That(copied).IsNotNull(); + await Assert.That(copied!).Contains("🚀 Going around the horn…"); + await Assert.That(copied).Contains("✓ Found 1 location(s) for 'main'."); + await Assert.That(copied.Split('\n').Length).IsEqualTo(lines); + + // The copy itself is narrated, and doesn't ride along in what was copied. + await Assert.That(window.LogText()).Contains($"📋 Copied {lines} flight-log line(s) to the clipboard."); + await Assert.That(copied.Contains("flight-log line(s)")).IsFalse(); + }); + } + + [Test] + public async Task An_empty_flight_log_has_nothing_to_copy_or_save() + { + using var world = new TestRepoWorld(); + var root = world.SearchRoot("root"); + var dialogs = new FakeDialogService(); + var services = world.BuildServices([root], new FakeEditorLauncher(), dialogs); + + await Harness.WithWindow(services, async window => + { + // Idle: nothing has been scanned, so the log is empty and both actions are disabled. + await Assert.That(window.Vm().HasLog).IsFalse(); + await Assert.That(window.FindControl public Func OnConfirmForceDelete { get; set; } = _ => false; + /// + /// Flight-log save responder, handed the suggested file name; defaults to returning null — the user + /// cancelled the picker. Return a path to have the log written there. + /// + public Func OnPickFlightLogPath { get; set; } = _ => null; + public List ForceDeleteConfirmations { get; } = new(); + + /// Every suggested file name the flight-log save picker was opened with, in order. + public List FlightLogSaveRequests { get; } = new(); + public int SettingsShownCount { get; private set; } public Task ConfirmForceDeleteWorktreeFolderAsync(WorktreeForceDelete request) @@ -31,4 +41,10 @@ public Task ShowSettingsAsync(AppConfig config, ConfigService configService) SettingsShownCount++; return Task.CompletedTask; } + + public Task PickFlightLogPathAsync(string suggestedFileName) + { + FlightLogSaveRequests.Add(suggestedFileName); + return Task.FromResult(OnPickFlightLogPath(suggestedFileName)); + } } diff --git a/tests/Fido.Tests/Infrastructure/Harness.cs b/tests/Fido.Tests/Infrastructure/Harness.cs index 9d6d3f8..12c7f1e 100644 --- a/tests/Fido.Tests/Infrastructure/Harness.cs +++ b/tests/Fido.Tests/Infrastructure/Harness.cs @@ -25,11 +25,18 @@ public static Task OnUi(Func body) => } }); - /// Builds and shows a real MainWindow with the injected services, runs , then closes it. - public static Task WithWindow(FidoServices services, Func body) => + /// + /// Builds and shows a real MainWindow with the injected services, runs , then + /// closes it. sees the window before it is shown — the hook for anything + /// that must be watched from the first frame, since a CLI-driven run can launch its tool and close the + /// window while the window is being shown, before is ever reached. + /// + public static Task WithWindow(FidoServices services, Func body, + Action? beforeShow = null) => Ui.On(async () => { var window = new MainWindow(services); + beforeShow?.Invoke(window); window.Show(); UiTestExtensions.Pump(); try