Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/Stampeded.Core/Diff/NoTextualDiff.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System.Globalization;

namespace Stampeded.Core.Diff;

/// <summary>
/// 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.
/// </summary>
public static class NoTextualDiff
{
/// <summary>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.</summary>
public static bool Applies(FileDiff file) => file.IsBinary || file.Hunks.Count == 0;

/// <summary>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.</summary>
public static string Badge(FileDiff file)
=> !Applies(file) ? "" : file.IsBinary ? "binary" : "no lines";

/// <summary>
/// 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.
/// </summary>
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",
};

/// <summary>
/// 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.
/// </summary>
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))})",
_ => "",
};

/// <summary>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.</summary>
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]}");
}
}
23 changes: 23 additions & 0 deletions src/Stampeded.Core/Git/GitService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,29 @@ public async Task<IReadOnlyList<FileDiff>> DiffWorkingTreeAsync(
public Task<string> ShowFileAsync(string rev, string path, CancellationToken ct = default)
=> RunAsync(ct, "show", $"{rev}:{path}");

/// <summary>
/// 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.
/// </summary>
public async Task<long?> 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;
}
}

/// <summary>A whole commit as a patch - its message and its diff, the way git prints it.</summary>
public Task<string> ShowCommitAsync(string rev, CancellationToken ct = default)
=> RunAsync(ct, "show", rev);
Expand Down
10 changes: 10 additions & 0 deletions src/Stampeded/Documents/MarkdownDocumentView.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:md="https://github.com/whistyun/Markdown.Avalonia"
xmlns:documents="using:Stampeded.Documents"
x:Class="Stampeded.Documents.MarkdownDocumentView"
x:DataType="documents:MarkdownDocumentViewModel">
<!-- The renderer brings its own scrolling, so this is the page's one scroll region - the
same arrangement the overview's description uses. -->
<md:MarkdownScrollViewer x:Name="Rendered" Markdown="{Binding Text}" Padding="12,8" />
</UserControl>
30 changes: 30 additions & 0 deletions src/Stampeded/Documents/MarkdownDocumentView.axaml.cs
Original file line number Diff line number Diff line change
@@ -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);
}
};
}
}
17 changes: 17 additions & 0 deletions src/Stampeded/Documents/MarkdownDocumentViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Dock.Model.Mvvm.Controls;

namespace Stampeded.Documents;

/// <summary>
/// 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.
/// </summary>
public class MarkdownDocumentViewModel(string text) : Document
{
public string Text { get; } = text;

/// <summary>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.</summary>
public string TabTooltip { get; init; } = "";
}
5 changes: 5 additions & 0 deletions src/Stampeded/Documents/ReviewGestures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/Stampeded/KeyboardShortcuts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/Stampeded/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@
ToggleType="CheckBox"
ToolTip="Read every change as two panes instead of one interleaved document" />
<NativeMenuItem Header="Blame Margin ( b )" CommandParameter="BlameItem" Click="OnToggleBlame" ToggleType="CheckBox" />
<!-- A tab of its own rather than a mode of the diff: the diff shows the source, and a
document is reviewed for both. Enabled only where there is something to render,
which the menu can see only as it opens. -->
<NativeMenuItem Header="Markdown Preview ( m )" CommandParameter="MarkdownPreviewItem"
Click="OnMarkdownPreview"
ToolTip="Render the file in front as markdown, beside its diff" />
<NativeMenuItem Header="Document Tabs in Several Rows" CommandParameter="MultiRowTabsItem"
Click="OnToggleMultiRowTabs" ToggleType="CheckBox" />
<!-- Hidden outside a debug build, where the renderer behind it does not exist. -->
Expand Down
10 changes: 9 additions & 1 deletion src/Stampeded/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand Down
5 changes: 5 additions & 0 deletions src/Stampeded/Panes/PrFilesPaneView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
reader picks the next file by, in the list they pick it from. -->
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="3"
VerticalAlignment="Center" Margin="4,0">
<!-- Where the counts would be, for a file that has none to give: grey, because it
says what the file is rather than how much of it moved. -->
<TextBlock FontSize="10" Foreground="#8B949E" Text="{Binding Entry.NoDiffBadge}"
IsVisible="{Binding Entry.NoDiffBadge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
ToolTip.Tip="No textual diff: a binary file, or one that changed without its lines changing" />
<TextBlock FontSize="10" Foreground="#2EA043" Text="{Binding Entry.AddedText}"
IsVisible="{Binding Entry.AddedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
ToolTip.Tip="Lines added" />
Expand Down
6 changes: 6 additions & 0 deletions src/Stampeded/Panes/PrFilesPaneViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ public string CoverageBadge {
/// <summary>"new!" when the latest push touched this file after it was last reviewed.</summary>
public string SinceBadge { get; init; } = "";

/// <summary>"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.</summary>
public string NoDiffBadge { get; init; } = "";

/// <summary>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.</summary>
Expand Down Expand Up @@ -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);
Expand Down
83 changes: 83 additions & 0 deletions src/Stampeded/ReviewWorkspace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -801,6 +806,84 @@ void SetGeneratedStatus(string status, bool done)
static string ReadOrEmpty(string? path) => path is null ? "" : File.ReadAllText(path);
}

/// <summary>
/// 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.
/// </summary>
async Task<Documents.IDiffDocument?> 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;
}

/// <summary>Whether a path is markdown, and so has a rendering worth looking at beside its
/// source.</summary>
public static bool IsMarkdown(string path) => MarkdownExtensions.Contains(Path.GetExtension(path));

static readonly IReadOnlySet<string> MarkdownExtensions =
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".md", ".markdown" };

/// <summary>
/// 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.
/// </summary>
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)" : "")}");
}

/// <summary>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
Expand Down
1 change: 1 addition & 0 deletions src/Stampeded/ViewLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading