From 4d28b7e8d58e0569c7501e186f2ef168df8e578f Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 17 Sep 2026 17:45:22 +0200 Subject: [PATCH 1/2] Say what a file with no diff is, instead of opening it blank Two kinds of file reach a review with no lines in them: a binary one, which git compares byte by byte and reports only as differing, and one that changed without its content changing - a rename, or a permission bit. Both were built as a diff of two empty sides. That opens as a blank document, which a reader cannot tell from a tool that failed to load the file, and the changed-files list showed them with no counts at all, beside files that genuinely changed nothing. Now the tab says which of the two it is, and for a binary file how the sides compare in size - whether the image got bigger is the whole of what a review can say about it, and git already knows it without reading the blob. The list carries the same word where the counts would be. It is still the file's own tab, with the file's own path, so it can be marked viewed and stepped past like any other: a binary file is part of a change and a reader ticking files off should not have to skip it. Claude-Session: https://claude.ai/code/session_01EgJ3xXgcismuaFmKhF6KD9 Assisted-by: Claude:claude-opus-5:Claude Code --- src/Stampeded.Core/Diff/NoTextualDiff.cs | 86 +++++++++++++++++++ src/Stampeded.Core/Git/GitService.cs | 23 +++++ src/Stampeded/Panes/PrFilesPaneView.axaml | 5 ++ src/Stampeded/Panes/PrFilesPaneViewModel.cs | 6 ++ src/Stampeded/ReviewWorkspace.cs | 34 ++++++++ .../NoTextualDiffTests.cs | 84 ++++++++++++++++++ 6 files changed, 238 insertions(+) create mode 100644 src/Stampeded.Core/Diff/NoTextualDiff.cs create mode 100644 tests/Stampeded.Core.Tests/NoTextualDiffTests.cs diff --git a/src/Stampeded.Core/Diff/NoTextualDiff.cs b/src/Stampeded.Core/Diff/NoTextualDiff.cs new file mode 100644 index 0000000..f7b807e --- /dev/null +++ b/src/Stampeded.Core/Diff/NoTextualDiff.cs @@ -0,0 +1,86 @@ +using System.Globalization; + +namespace Stampeded.Core.Diff; + +/// +/// What to say about a file the diff has nothing to show for. +/// +/// Two kinds reach a review with no lines in them: a binary file, which git compares byte by +/// byte and reports only as differing, and a file that changed without its content changing - +/// a rename, or a permission bit. Both used to open as an empty document, which reads as a +/// tool that failed rather than as a file with nothing to read, and the file list showed them +/// with no counts at all beside files that genuinely changed nothing. +/// +public static class NoTextualDiff +{ + /// Whether this file has no lines to show. A generated file is never one of + /// these: its two sides are read from disk and compared here, so it either has hunks or + /// was left out of the change altogether. + public static bool Applies(FileDiff file) => file.IsBinary || file.Hunks.Count == 0; + + /// The badge the file list carries for such a file, or empty for one with lines. + /// Short, because it stands where the line counts would. + public static string Badge(FileDiff file) + => !Applies(file) ? "" : file.IsBinary ? "binary" : "no lines"; + + /// + /// The page shown in place of the diff. Sizes are what a reader actually wants to know + /// about a binary change - whether the image got bigger - and are null for the side that + /// does not have the file. + /// + public static string Describe(FileDiff file, long? baseSize, long? headSize) + { + var text = new System.Text.StringBuilder(); + text.Append(file.Path).Append("\n\n"); + text.Append(file.IsBinary + ? "Binary file: git compares the bytes and reports only that they differ, so there\n" + + "is no textual diff to read.\n\n" + : "No lines changed. A file reaches a change without any when it was renamed, or\n" + + "when only its permissions moved.\n\n"); + text.Append(Kind(file)); + if (file.Kind == FileChangeKind.Renamed) + text.Append(" from ").Append(file.OldPath); + text.Append('.'); + if (Sizes(baseSize, headSize) is { Length: > 0 } sizes) + text.Append(" ").Append(sizes); + return text.Append('\n').ToString(); + } + + static string Kind(FileDiff file) => file.Kind switch { + FileChangeKind.Added => "Added", + FileChangeKind.Deleted => "Deleted", + FileChangeKind.Renamed => "Renamed", + _ => "Modified", + }; + + /// + /// How the two sides compare, said the way a reader would: one size for a file that only + /// one revision has, and the difference spelled out for one both have - "grew by 2.5 KB" + /// is the fact, where two absolute numbers are the arithmetic to get there. + /// + static string Sizes(long? baseSize, long? headSize) => (baseSize, headSize) switch { + (null, { } head) => Size(head), + ({ } start, null) => "was " + Size(start), + ({ } start, { } end) when start == end => Size(end) + ", unchanged in size", + ({ } start, { } end) => $"{Size(start)} -> {Size(end)} ({(end > start ? "+" : "-")}{Size(Math.Abs(end - start))})", + _ => "", + }; + + /// A byte count as a person reads one. Binary in the sense the file managers use, + /// because that is what the reader will see if they check. + public static string Size(long bytes) + { + string[] units = ["bytes", "KB", "MB", "GB"]; + double size = bytes; + int unit = 0; + while (size >= 1024 && unit < units.Length - 1) + { + size /= 1024; + unit++; + } + // Whole bytes: a file of 512.0 bytes is 512 bytes, and the decimal is noise. + return unit == 0 + ? string.Create(CultureInfo.InvariantCulture, $"{bytes} {units[0]}") + : string.Create(CultureInfo.InvariantCulture, $"{size:0.#} {units[unit]}"); + } +} diff --git a/src/Stampeded.Core/Git/GitService.cs b/src/Stampeded.Core/Git/GitService.cs index 0e688a3..8a9a431 100644 --- a/src/Stampeded.Core/Git/GitService.cs +++ b/src/Stampeded.Core/Git/GitService.cs @@ -328,6 +328,29 @@ public async Task> DiffWorkingTreeAsync( public Task ShowFileAsync(string rev, string path, CancellationToken ct = default) => RunAsync(ct, "show", $"{rev}:{path}"); + /// + /// How many bytes a revision's copy of a file has, or null when that revision does not + /// have it - which is the ordinary answer for the base side of an addition and the head + /// side of a deletion, not a failure. + /// + /// Asked of the object database rather than read, because the file this is wanted for is + /// the one that cannot be read as text: how much a binary file grew is the whole of what + /// a review can say about it. + /// + public async Task BlobSizeAsync(string rev, string path, CancellationToken ct = default) + { + try + { + return long.TryParse((await RunAsync(ct, "cat-file", "-s", $"{rev}:{path}")).Trim(), out long size) + ? size + : null; + } + catch (ToolFailedException) + { + return null; + } + } + /// A whole commit as a patch - its message and its diff, the way git prints it. public Task ShowCommitAsync(string rev, CancellationToken ct = default) => RunAsync(ct, "show", rev); diff --git a/src/Stampeded/Panes/PrFilesPaneView.axaml b/src/Stampeded/Panes/PrFilesPaneView.axaml index 8a9a41a..09b6d1b 100644 --- a/src/Stampeded/Panes/PrFilesPaneView.axaml +++ b/src/Stampeded/Panes/PrFilesPaneView.axaml @@ -42,6 +42,11 @@ reader picks the next file by, in the list they pick it from. --> + + diff --git a/src/Stampeded/Panes/PrFilesPaneViewModel.cs b/src/Stampeded/Panes/PrFilesPaneViewModel.cs index 10e9985..2b0cac3 100644 --- a/src/Stampeded/Panes/PrFilesPaneViewModel.cs +++ b/src/Stampeded/Panes/PrFilesPaneViewModel.cs @@ -47,6 +47,11 @@ public string CoverageBadge { /// "new!" when the latest push touched this file after it was last reviewed. public string SinceBadge { get; init; } = ""; + /// "binary" or "no lines" for a file the diff has nothing to show for. It stands + /// where the line counts would, which is otherwise empty for such a file and reads as a + /// change of no size rather than one with nothing to read. + public string NoDiffBadge { get; init; } = ""; + /// How much of the file changed, the way the diff counts it. The size of a file's /// change is what a reader picks the next one by, and the list was the one place that said /// only which files, not how much of them. @@ -194,6 +199,7 @@ void Rebuild() CommentBadge = badge, CommentsSettled = settled, SinceBadge = markTouched && workspace.IsTouchedSinceLastPass(file.Path) ? "new!" : "", + NoDiffBadge = Core.Diff.NoTextualDiff.Badge(file), }; entry.PropertyChanged += OnEntryChanged; Files.Add(entry); diff --git a/src/Stampeded/ReviewWorkspace.cs b/src/Stampeded/ReviewWorkspace.cs index cc641e5..6fd6519 100644 --- a/src/Stampeded/ReviewWorkspace.cs +++ b/src/Stampeded/ReviewWorkspace.cs @@ -771,6 +771,11 @@ void SetGeneratedStatus(string status, bool done) return ShowDiffDocument(file, ReadOrEmpty(generated.BaseFile), ReadOrEmpty(generated.HeadFile)); if (BaseSha is null || HeadSha is null) return null; + // A binary file, or one that changed without its lines changing, has nothing to draw. + // Built as a diff of two empty sides it opened as a blank document, which reads as a + // tool that failed rather than as a file with nothing in it to read. + if (NoTextualDiff.Applies(file)) + return await ShowNoTextualDiffAsync(file, record); string oldText = ""; if (file.Kind != FileChangeKind.Added && !file.IsBinary) { @@ -801,6 +806,35 @@ void SetGeneratedStatus(string status, bool done) static string ReadOrEmpty(string? path) => path is null ? "" : File.ReadAllText(path); } + /// + /// The page a file with no lines opens as: what kind of nothing it is, and for a binary one + /// how the two sides compare in size, which is the only thing a review can say about it. + /// + /// It takes the file's own tab and carries the file's own path, so it is a file of the + /// review like any other: it can be marked viewed, stepped past, and commented on where the + /// host allows one. + /// + async Task ShowNoTextualDiffAsync(FileDiff file, bool record) + { + long? baseSize = file.Kind == FileChangeKind.Added || BaseSha is not { } baseSha + ? null + : await Git.BlobSizeAsync(baseSha, file.OldPath); + long? headSize = file.Kind == FileChangeKind.Deleted || HeadSha is not { } headSha + ? null + : await Git.BlobSizeAsync(headSha, file.NewPath); + string described = NoTextualDiff.Describe(file, baseSize, headSize); + CliLog.Write("review", $"{file.Path}: {NoTextualDiff.Badge(file)}, nothing to diff"); + var document = ShowDocument("diff:" + file.Path, () => { + var page = Stampeded.Documents.DiffDocumentViewModel.ForSource(file.Path, described); + page.Title = Path.GetFileName(file.Path); + page.TabTooltipOverride = file.Path + " - " + NoTextualDiff.Badge(file); + return page; + }); + if (record && document is not null) + RecordArrival("diff:" + file.Path); + return document; + } + /// The head side of a file whose earlier version cannot be read, as a source view: /// there is nothing to compare it with, and saying so beats drawing a diff that is not /// one. It takes the file's own tab, so reopening the file replaces it once the base is diff --git a/tests/Stampeded.Core.Tests/NoTextualDiffTests.cs b/tests/Stampeded.Core.Tests/NoTextualDiffTests.cs new file mode 100644 index 0000000..bea67a4 --- /dev/null +++ b/tests/Stampeded.Core.Tests/NoTextualDiffTests.cs @@ -0,0 +1,84 @@ +using NUnit.Framework; + +using Stampeded.Core.Diff; + +namespace Stampeded.Core.Tests; + +/// +/// What a file with nothing to read says for itself. Both kinds used to open as an empty +/// document, which a reader cannot tell from a tool that failed. +/// +public class NoTextualDiffTests +{ + static FileDiff Binary(FileChangeKind kind = FileChangeKind.Modified, string path = "docs/logo.png") + => new(path, path, kind, IsBinary: true, []); + + static FileDiff Textual(FileChangeKind kind, string oldPath, string newPath) + => new(oldPath, newPath, kind, IsBinary: false, []); + + [Test] + public void AppliesToABinaryFileAndToOneWithNoHunks() + { + Assert.That(NoTextualDiff.Applies(Binary()), Is.True); + Assert.That(NoTextualDiff.Applies(Textual(FileChangeKind.Renamed, "a.md", "b.md")), Is.True); + Assert.That(NoTextualDiff.Applies( + new("a.cs", "a.cs", FileChangeKind.Modified, false, [new DiffHunk(1, 1, 1, 1, "", [])])), + Is.False, "a file with hunks has something to read"); + } + + [Test] + public void SaysWhichKindOfNothingItIs() + { + Assert.That(NoTextualDiff.Badge(Binary()), Is.EqualTo("binary")); + Assert.That(NoTextualDiff.Badge(Textual(FileChangeKind.Renamed, "a.md", "b.md")), Is.EqualTo("no lines")); + Assert.That(NoTextualDiff.Badge( + new("a.cs", "a.cs", FileChangeKind.Modified, false, [new DiffHunk(1, 1, 1, 1, "", [])])), + Is.Empty); + } + + [Test] + public void ComparesTheTwoSidesOfABinaryChange() + { + string described = NoTextualDiff.Describe(Binary(), baseSize: 12 * 1024, headSize: 14 * 1024); + + Assert.That(described, Does.Contain("Binary file")); + Assert.That(described, Does.Contain("Modified.")); + Assert.That(described, Does.Contain("12 KB -> 14 KB (+2 KB)"), + "the direction and the difference, not two numbers to subtract"); + } + + [Test] + public void NamesOnlyTheSideThatHasTheFile() + { + Assert.That(NoTextualDiff.Describe(Binary(FileChangeKind.Added), null, 2048), + Does.Contain("Added. 2 KB")); + Assert.That(NoTextualDiff.Describe(Binary(FileChangeKind.Deleted), 2048, null), + Does.Contain("Deleted. was 2 KB")); + } + + [Test] + public void SaysWhereARenameCameFrom() + { + string described = NoTextualDiff.Describe( + Textual(FileChangeKind.Renamed, "docs/old-guide.md", "docs/guide.md"), null, null); + + Assert.That(described, Does.Contain("No lines changed")); + Assert.That(described, Does.Contain("Renamed from docs/old-guide.md.")); + } + + [Test] + public void ASizeThatDidNotMoveSaysSo() + { + // A binary file whose bytes differ at the same length - a rebuilt asset, a re-encoded + // image - would otherwise read as "4 KB -> 4 KB" and look like a mistake. + Assert.That(NoTextualDiff.Describe(Binary(), 4096, 4096), Does.Contain("4 KB, unchanged in size")); + } + + [TestCase(0, "0 bytes")] + [TestCase(512, "512 bytes")] + [TestCase(1024, "1 KB")] + [TestCase(1536, "1.5 KB")] + [TestCase(1024 * 1024, "1 MB")] + public void ReadsASizeTheWayAPersonWould(long bytes, string expected) + => Assert.That(NoTextualDiff.Size(bytes), Is.EqualTo(expected)); +} From f1c9195f556785a4331a71d32798850381cbd475 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 17 Sep 2026 17:46:06 +0200 Subject: [PATCH 2/2] Render a markdown file beside its diff A change to a README or a document is read for what it will look like as much as for what it says, and the diff can only show the source: a reader deciding whether a table still lines up, whether a link points where it claims, or how a nested list comes out had nowhere in the tool to find out. The preview is a tab of its own rather than a mode of the diff, so the source and the rendering can be open at once - which is the arrangement the question is usually asked in. It renders the head side, because that is what the change produces, and the base side for a file the change deletes, which has no head side left to render. Nothing new draws it: the renderer, the link command, the selection handling and the emphasis repair are the ones the overview's description already uses, so a preview picks up whatever those learn. On 'm', and in the View menu, where it is enabled only for a file there is something to render for - offered on a .cs file it would do nothing, which reads as a broken command rather than one that does not apply. Typing 'm' into a comment still types an m: the gesture handlers already stand aside for a text box. Claude-Session: https://claude.ai/code/session_01EgJ3xXgcismuaFmKhF6KD9 Assisted-by: Claude:claude-opus-5:Claude Code --- .../Documents/MarkdownDocumentView.axaml | 10 ++++ .../Documents/MarkdownDocumentView.axaml.cs | 30 ++++++++++++ .../Documents/MarkdownDocumentViewModel.cs | 17 +++++++ src/Stampeded/Documents/ReviewGestures.cs | 5 ++ src/Stampeded/KeyboardShortcuts.cs | 1 + src/Stampeded/MainWindow.axaml | 6 +++ src/Stampeded/MainWindow.axaml.cs | 10 +++- src/Stampeded/ReviewWorkspace.cs | 49 +++++++++++++++++++ src/Stampeded/ViewLocator.cs | 1 + 9 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/Stampeded/Documents/MarkdownDocumentView.axaml create mode 100644 src/Stampeded/Documents/MarkdownDocumentView.axaml.cs create mode 100644 src/Stampeded/Documents/MarkdownDocumentViewModel.cs diff --git a/src/Stampeded/Documents/MarkdownDocumentView.axaml b/src/Stampeded/Documents/MarkdownDocumentView.axaml new file mode 100644 index 0000000..d3cc803 --- /dev/null +++ b/src/Stampeded/Documents/MarkdownDocumentView.axaml @@ -0,0 +1,10 @@ + + + + diff --git a/src/Stampeded/Documents/MarkdownDocumentView.axaml.cs b/src/Stampeded/Documents/MarkdownDocumentView.axaml.cs new file mode 100644 index 0000000..22db5db --- /dev/null +++ b/src/Stampeded/Documents/MarkdownDocumentView.axaml.cs @@ -0,0 +1,30 @@ +using Avalonia.Controls; + +namespace Stampeded.Documents; + +public partial class MarkdownDocumentView : UserControl +{ + public MarkdownDocumentView() + { + InitializeComponent(); + // A document under review is full of links - to its own sections, to issues, to the + // sites it cites - and without an engine that runs them a link renders as a link and + // does nothing when pressed. + Rendered.Engine = Editor.MarkdownLinks.NewEngine(); + // A preview is read to be quoted from, in a comment about the paragraph it shows. The + // renderer paints a selection but carries nothing that copies one, and the blocks it + // draws never take focus, so the gesture is handled by the page. + Controls.MarkdownSelection.Enable(this); + // The renderer draws the markers of an emphasis reaching around a link or a code span + // instead of the emphasis itself; they are paired again from the pieces it left, once + // it has drawn. Posted, because the rendered blocks exist only after the layout pass. + Rendered.PropertyChanged += (_, e) => { + if (e.Property == global::Markdown.Avalonia.MarkdownScrollViewer.MarkdownProperty) + { + Avalonia.Threading.Dispatcher.UIThread.Post( + () => Controls.MarkdownEmphasis.Repair(Rendered), + Avalonia.Threading.DispatcherPriority.Loaded); + } + }; + } +} diff --git a/src/Stampeded/Documents/MarkdownDocumentViewModel.cs b/src/Stampeded/Documents/MarkdownDocumentViewModel.cs new file mode 100644 index 0000000..d1b4bba --- /dev/null +++ b/src/Stampeded/Documents/MarkdownDocumentViewModel.cs @@ -0,0 +1,17 @@ +using Dock.Model.Mvvm.Controls; + +namespace Stampeded.Documents; + +/// +/// A markdown file as it renders, in a tab of its own beside the diff of its source. A change +/// to a README or a document is read for what it will look like as much as for what it says, +/// and the diff can only show the source. +/// +public class MarkdownDocumentViewModel(string text) : Document +{ + public string Text { get; } = text; + + /// What the tab strip says about this tab on hover. The title is a file name and + /// several documents can share one; the path is what tells them apart. + public string TabTooltip { get; init; } = ""; +} diff --git a/src/Stampeded/Documents/ReviewGestures.cs b/src/Stampeded/Documents/ReviewGestures.cs index ebc83bb..2728387 100644 --- a/src/Stampeded/Documents/ReviewGestures.cs +++ b/src/Stampeded/Documents/ReviewGestures.cs @@ -64,6 +64,11 @@ public static bool Handle(KeyEventArgs e, IReviewDocumentView view) case (Key.C, KeyModifiers.None): view.CommentAtCaretCommand(); return true; + // The workspace rather than the view: a preview is a document of its own and needs + // nothing from the caret, so both layouts offer it without implementing anything. + case (Key.M, KeyModifiers.None): + App.Workspace?.OpenMarkdownPreviewAsync().HandleExceptions(); + return true; case (Key.Left, KeyModifiers.Alt): workspace?.GoBackAsync().HandleExceptions(); return true; diff --git a/src/Stampeded/KeyboardShortcuts.cs b/src/Stampeded/KeyboardShortcuts.cs index de6e4c3..626e95c 100644 --- a/src/Stampeded/KeyboardShortcuts.cs +++ b/src/Stampeded/KeyboardShortcuts.cs @@ -31,6 +31,7 @@ F12 go to definition Alt+Left back Alt+Right forward b blame margin on or off + m render the markdown file in front, in a tab of its own Esc clear highlighted occurrences Saying something diff --git a/src/Stampeded/MainWindow.axaml b/src/Stampeded/MainWindow.axaml index ee423fb..096335f 100644 --- a/src/Stampeded/MainWindow.axaml +++ b/src/Stampeded/MainWindow.axaml @@ -162,6 +162,12 @@ ToggleType="CheckBox" ToolTip="Read every change as two panes instead of one interleaved document" /> + + diff --git a/src/Stampeded/MainWindow.axaml.cs b/src/Stampeded/MainWindow.axaml.cs index 2b75cba..dc37394 100644 --- a/src/Stampeded/MainWindow.axaml.cs +++ b/src/Stampeded/MainWindow.axaml.cs @@ -16,7 +16,7 @@ public partial class MainWindow : Window readonly NativeMenuItem recentMenu, buildSolutionMenu, exitItem, nextHunkItem, prevHunkItem, nextUncoveredItem, historyOfSelectionItem, backItem, forwardItem, - sideBySideItem, blameItem, multiRowTabsItem, pointerCrossHairItem, debugHereItem, + sideBySideItem, blameItem, markdownPreviewItem, multiRowTabsItem, pointerCrossHairItem, debugHereItem, lightThemeItem, darkThemeItem; public MainWindow() @@ -36,6 +36,7 @@ NativeMenuItem Named(string key) => FindMenuItem(menu, i => key.Equals(i.Command forwardItem = Named("ForwardItem"); sideBySideItem = Named("SideBySideItem"); blameItem = Named("BlameItem"); + markdownPreviewItem = Named("MarkdownPreviewItem"); multiRowTabsItem = Named("MultiRowTabsItem"); pointerCrossHairItem = Named("PointerCrossHairItem"); debugHereItem = Named("DebugHereItem"); @@ -193,6 +194,9 @@ protected override void OnKeyDown(KeyEventArgs e) case (Key.C, KeyModifiers.None): View?.CommentAtCaretCommand(); break; + case (Key.M, KeyModifiers.None): + App.Workspace?.OpenMarkdownPreviewAsync().HandleExceptions(); + break; case (Key.F12, KeyModifiers.None): View?.GoToDefinitionCommand(); break; @@ -289,6 +293,7 @@ async Task PromptUrlAsync() void OnToggleViewed(object? s, EventArgs e) => App.Workspace?.ToggleViewedAndAdvanceAsync().HandleExceptions(); void OnToggleOverview(object? s, EventArgs e) => App.Workspace?.ToggleOverviewAsync().HandleExceptions(); void OnToggleBlame(object? s, EventArgs e) => View?.ToggleBlameCommand(); + void OnMarkdownPreview(object? s, EventArgs e) => App.Workspace?.OpenMarkdownPreviewAsync().HandleExceptions(); void OnCommentAtCaret(object? s, EventArgs e) => View?.CommentAtCaretCommand(); void OnGoToDefinition(object? s, EventArgs e) => View?.GoToDefinitionCommand(); void OnFindReferences(object? s, EventArgs e) => View?.FindReferencesCommand(); @@ -508,6 +513,9 @@ internal void RefreshMenus() sideBySideItem.IsChecked = DiffLayoutPreference.SideBySide; blameItem.IsEnabled = Has(ReviewCommands.ToggleBlame); blameItem.IsChecked = view?.BlameVisible ?? false; + // Only where there is something to render. Offered on a .cs file it would do nothing, + // which reads as a broken command rather than one that does not apply here. + markdownPreviewItem.IsEnabled = file is not null && ReviewWorkspace.IsMarkdown(file.Path); debugHereItem.IsEnabled = Has(ReviewCommands.DebugHere); historyOfSelectionItem.IsEnabled = Has(ReviewCommands.HistoryOfSelection); } diff --git a/src/Stampeded/ReviewWorkspace.cs b/src/Stampeded/ReviewWorkspace.cs index 6fd6519..f5b9efe 100644 --- a/src/Stampeded/ReviewWorkspace.cs +++ b/src/Stampeded/ReviewWorkspace.cs @@ -835,6 +835,55 @@ void SetGeneratedStatus(string status, bool done) return document; } + /// Whether a path is markdown, and so has a rendering worth looking at beside its + /// source. + public static bool IsMarkdown(string path) => MarkdownExtensions.Contains(Path.GetExtension(path)); + + static readonly IReadOnlySet MarkdownExtensions = + new HashSet(StringComparer.OrdinalIgnoreCase) { ".md", ".markdown" }; + + /// + /// Opens the rendered form of a markdown file beside its diff. A change to a README or a + /// document is read for what it will look like as much as for what it says, and the diff + /// shows the source - so the preview is a tab of its own rather than a mode of the diff, + /// and both can be open at once. + /// + /// The head side, because that is what the change produces; the base side for a file the + /// change deletes, which has no head side to render. + /// + public async Task OpenMarkdownPreviewAsync(FileDiff? file = null) + { + if ((file ?? CurrentFile) is not { } target) + { + StatusMessage?.Invoke("No file is open to preview."); + return; + } + if (!IsMarkdown(target.Path)) + { + StatusMessage?.Invoke($"{Path.GetFileName(target.Path)} is not markdown, so there is nothing to render."); + return; + } + bool oldSide = target.Kind == FileChangeKind.Deleted; + string? text = oldSide + ? BaseSha is { } baseSha ? await Blobs.ReadAsync(baseSha, target.OldPath) : null + : await ReadHeadFileAsync(target.NewPath); + if (text is null) + { + StatusMessage?.Invoke($"{target.Path} is not in the {(oldSide ? "base" : "head")} revision."); + return; + } + string id = "md:" + target.Path; + // Rendered from the revision on screen, so a preview left open while the scope moves + // under it would be showing another revision's text under this one's name. + if (Factory is not null && Documents?.VisibleDockables?.FirstOrDefault(d => d.Id == id) is { } stale) + Factory.CloseDockable(stale); + ShowDocument(id, () => new Stampeded.Documents.MarkdownDocumentViewModel(text) { + Title = Path.GetFileName(target.Path) + (oldSide ? " (preview @ base)" : " (preview)"), + TabTooltip = target.Path + " rendered", + }); + CliLog.Write("action", $"markdown preview {target.Path}{(oldSide ? " (base)" : "")}"); + } + /// The head side of a file whose earlier version cannot be read, as a source view: /// there is nothing to compare it with, and saying so beats drawing a diff that is not /// one. It takes the file's own tab, so reopening the file replaces it once the base is diff --git a/src/Stampeded/ViewLocator.cs b/src/Stampeded/ViewLocator.cs index ff0b65f..321996c 100644 --- a/src/Stampeded/ViewLocator.cs +++ b/src/Stampeded/ViewLocator.cs @@ -17,6 +17,7 @@ public class ViewLocator : IDataTemplate [typeof(DiffDocumentViewModel)] = () => new Documents.DiffDocumentView(), [typeof(SideBySideDocumentViewModel)] = () => new Documents.SideBySideDocumentView(), [typeof(TextDocumentViewModel)] = () => new Documents.TextDocumentView(), + [typeof(MarkdownDocumentViewModel)] = () => new Documents.MarkdownDocumentView(), [typeof(StartDocumentViewModel)] = () => new Documents.StartDocumentView(), [typeof(OverviewDocumentViewModel)] = () => new Documents.OverviewDocumentView(), [typeof(ReviewDocumentViewModel)] = () => new Documents.ReviewDocumentView(),