From 671e46f8b377f9d8bbf7b48e702b644a9a7be2f0 Mon Sep 17 00:00:00 2001 From: leo Date: Tue, 27 Feb 2024 21:13:52 +0800 Subject: [PATCH] feature: supports line staging/unstaging in working copy diff view --- src/Commands/Apply.cs | 3 +- src/Commands/Diff.cs | 12 +- src/Commands/QueryStagedFileBlobGuid.cs | 23 ++ src/Models/DiffOption.cs | 9 +- src/Models/DiffResult.cs | 13 +- src/Resources/Locales/en_US.axaml | 3 + src/Resources/Locales/zh_CN.axaml | 3 + src/ViewModels/Apply.cs | 2 +- src/ViewModels/DiffContext.cs | 12 + src/Views/TextDiffView.axaml.cs | 409 ++++++++++++++++++++++-- 10 files changed, 443 insertions(+), 46 deletions(-) create mode 100644 src/Commands/QueryStagedFileBlobGuid.cs diff --git a/src/Commands/Apply.cs b/src/Commands/Apply.cs index 3619d963..1f550de4 100644 --- a/src/Commands/Apply.cs +++ b/src/Commands/Apply.cs @@ -1,11 +1,12 @@ namespace SourceGit.Commands { public class Apply : Command { - public Apply(string repo, string file, bool ignoreWhitespace, string whitespaceMode) { + public Apply(string repo, string file, bool ignoreWhitespace, string whitespaceMode, string extra) { WorkingDirectory = repo; Context = repo; Args = "apply "; if (ignoreWhitespace) Args += "--ignore-whitespace "; else Args += $"--whitespace={whitespaceMode} "; + if (!string.IsNullOrEmpty(extra)) Args += $"{extra} "; Args += $"\"{file}\""; } } diff --git a/src/Commands/Diff.cs b/src/Commands/Diff.cs index 983b60c1..51a592de 100644 --- a/src/Commands/Diff.cs +++ b/src/Commands/Diff.cs @@ -65,11 +65,11 @@ namespace SourceGit.Commands { _oldLine = int.Parse(match.Groups[1].Value); _newLine = int.Parse(match.Groups[2].Value); - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, "", "")); + _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0)); } else { if (line.Length == 0) { ProcessInlineHighlights(); - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Normal, "", $"{_oldLine}", $"{_newLine}")); + _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Normal, "", _oldLine, _newLine)); _oldLine++; _newLine++; return; @@ -77,10 +77,10 @@ namespace SourceGit.Commands { var ch = line[0]; if (ch == '-') { - _deleted.Add(new Models.TextDiffLine(Models.TextDiffLineType.Deleted, line.Substring(1), $"{_oldLine}", "")); + _deleted.Add(new Models.TextDiffLine(Models.TextDiffLineType.Deleted, line.Substring(1), _oldLine, 0)); _oldLine++; } else if (ch == '+') { - _added.Add(new Models.TextDiffLine(Models.TextDiffLineType.Added, line.Substring(1), "", $"{_newLine}")); + _added.Add(new Models.TextDiffLine(Models.TextDiffLineType.Added, line.Substring(1), 0, _newLine)); _newLine++; } else if (ch != '\\') { ProcessInlineHighlights(); @@ -88,7 +88,7 @@ namespace SourceGit.Commands { if (match.Success) { _oldLine = int.Parse(match.Groups[1].Value); _newLine = int.Parse(match.Groups[2].Value); - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, "", "")); + _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0)); } else { if (line.StartsWith(PREFIX_LFS)) { _result.IsLFS = true; @@ -96,7 +96,7 @@ namespace SourceGit.Commands { return; } - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Normal, line.Substring(1), $"{_oldLine}", $"{_newLine}")); + _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Normal, line.Substring(1), _oldLine, _newLine)); _oldLine++; _newLine++; } diff --git a/src/Commands/QueryStagedFileBlobGuid.cs b/src/Commands/QueryStagedFileBlobGuid.cs new file mode 100644 index 00000000..4a1c30e2 --- /dev/null +++ b/src/Commands/QueryStagedFileBlobGuid.cs @@ -0,0 +1,23 @@ +using System.Text.RegularExpressions; + +namespace SourceGit.Commands { + public class QueryStagedFileBlobGuid : Command { + private static readonly Regex REG_FORMAT = new Regex(@"^\d+\s+([0-9a-f]+)\s+.*$"); + + public QueryStagedFileBlobGuid(string repo, string file) { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-files -s -- \"{file}\""; + } + + public string Result() { + var rs = ReadToEnd(); + var match = REG_FORMAT.Match(rs.StdOut.Trim()); + if (match.Success) { + return match.Groups[1].Value; + } + + return string.Empty; + } + } +} diff --git a/src/Models/DiffOption.cs b/src/Models/DiffOption.cs index 3a26145f..caf25b5f 100644 --- a/src/Models/DiffOption.cs +++ b/src/Models/DiffOption.cs @@ -3,6 +3,8 @@ using System.Text; namespace SourceGit.Models { public class DiffOption { + public Change WorkingCopyChange => _workingCopyChange; + public bool IsUnstaged => _isUnstaged; public List Revisions => _revisions; public string Path => _path; public string OrgPath => _orgPath; @@ -13,6 +15,9 @@ namespace SourceGit.Models { /// /// public DiffOption(Change change, bool isUnstaged) { + _workingCopyChange = change; + _isUnstaged = isUnstaged; + if (isUnstaged) { switch (change.WorkTree) { case ChangeState.Added: @@ -47,7 +52,7 @@ namespace SourceGit.Models { } /// - /// Diff with filepath. + /// Diff with filepath. Used by FileHistories /// /// /// @@ -87,6 +92,8 @@ namespace SourceGit.Models { return builder.ToString(); } + private Change _workingCopyChange = null; + private bool _isUnstaged = false; private string _orgPath = string.Empty; private string _path = string.Empty; private string _extra = string.Empty; diff --git a/src/Models/DiffResult.cs b/src/Models/DiffResult.cs index debbd48d..18108fb1 100644 --- a/src/Models/DiffResult.cs +++ b/src/Models/DiffResult.cs @@ -18,16 +18,19 @@ namespace SourceGit.Models { public class TextDiffLine { public TextDiffLineType Type { get; set; } = TextDiffLineType.None; public string Content { get; set; } = ""; - public string OldLine { get; set; } = ""; - public string NewLine { get; set; } = ""; + public int OldLineNumber { get; set; } = 0; + public int NewLineNumber { get; set; } = 0; public List Highlights { get; set; } = new List(); + public string OldLine => OldLineNumber == 0 ? string.Empty : OldLineNumber.ToString(); + public string NewLine => NewLineNumber == 0 ? string.Empty : NewLineNumber.ToString(); + public TextDiffLine() { } - public TextDiffLine(TextDiffLineType type, string content, string oldLine, string newLine) { + public TextDiffLine(TextDiffLineType type, string content, int oldLine, int newLine) { Type = type; Content = content; - OldLine = oldLine; - NewLine = newLine; + OldLineNumber = oldLine; + NewLineNumber = newLine; } } diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 4c898bf5..4e2ea7a7 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -228,6 +228,9 @@ Stash {0} files... Save As Patch... Assume unchaged + Stage Changes in Selected Line(s) + Discard Changes in Selected Line(s) + Unstage Changes in Selected Line(s) Delete Branch Branch : diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index ed0c57ac..5850a2fc 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -227,6 +227,9 @@ 贮藏选中的 {0} 个文件... 另存为补丁... 不跟踪此文件的更改 + 暂存选中的更改 + 放弃选中的更改 + 从暂存中移除选中的更改 确定要删除此分支吗? 分支名 : diff --git a/src/ViewModels/Apply.cs b/src/ViewModels/Apply.cs index 0c977e58..937ebe70 100644 --- a/src/ViewModels/Apply.cs +++ b/src/ViewModels/Apply.cs @@ -54,7 +54,7 @@ namespace SourceGit.ViewModels { ProgressDescription = "Apply patch..."; return Task.Run(() => { - var succ = new Commands.Apply(_repo.FullPath, _patchFile, _ignoreWhiteSpace, SelectedWhiteSpaceMode.Arg).Exec(); + var succ = new Commands.Apply(_repo.FullPath, _patchFile, _ignoreWhiteSpace, SelectedWhiteSpaceMode.Arg, null).Exec(); CallUIThread(() => _repo.SetWatcherEnabled(true)); return succ; }); diff --git a/src/ViewModels/DiffContext.cs b/src/ViewModels/DiffContext.cs index da8fa957..6e83cc0c 100644 --- a/src/ViewModels/DiffContext.cs +++ b/src/ViewModels/DiffContext.cs @@ -5,6 +5,18 @@ using System.Threading.Tasks; namespace SourceGit.ViewModels { public class DiffContext : ObservableObject { + public string RepositoryPath { + get => _repo; + } + + public Models.Change WorkingCopyChange { + get => _option.WorkingCopyChange; + } + + public bool IsUnstaged { + get => _option.IsUnstaged; + } + public string FilePath { get => _option.Path; } diff --git a/src/Views/TextDiffView.axaml.cs b/src/Views/TextDiffView.axaml.cs index 69b0ac84..1592bfa6 100644 --- a/src/Views/TextDiffView.axaml.cs +++ b/src/Views/TextDiffView.axaml.cs @@ -4,6 +4,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Styling; +using Avalonia.VisualTree; using AvaloniaEdit; using AvaloniaEdit.Document; using AvaloniaEdit.Editing; @@ -11,12 +12,27 @@ using AvaloniaEdit.Rendering; using AvaloniaEdit.TextMate; using AvaloniaEdit.Utils; using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; +using System.Text.RegularExpressions; using TextMateSharp.Grammars; namespace SourceGit.Views { + public class TextDiffUnifiedSelection { + public int StartLine { get; set; } = 0; + public int EndLine { get; set; } = 0; + public bool HasChanges { get; set; } = false; + public bool HasLeftChanges { get; set; } = false; + public int IgnoredAdds { get; set; } = 0; + public int IgnoredDeletes { get; set; } = 0; + + public bool IsInRange(int idx) { + return idx >= StartLine - 1 && idx < EndLine; + } + } + public class CombinedTextDiffPresenter : TextEditor { public class LineNumberMargin : AbstractMargin { public LineNumberMargin(CombinedTextDiffPresenter editor, bool isOldLine) { @@ -231,24 +247,23 @@ namespace SourceGit.Views { } private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e) { - var selected = SelectedText; - if (string.IsNullOrEmpty(selected)) return; + var selection = TextArea.Selection; + if (selection.IsEmpty) return; - var icon = new Avalonia.Controls.Shapes.Path(); - icon.Width = 10; - icon.Height = 10; - icon.Stretch = Stretch.Uniform; - icon.Data = App.Current?.FindResource("Icons.Copy") as StreamGeometry; + var menu = new ContextMenu(); + var parentView = this.FindAncestorOfType(); + if (parentView != null) { + parentView.FillContextMenuForWorkingCopyChange(menu, selection.StartPosition.Line, selection.EndPosition.Line, false); + } var copy = new MenuItem(); copy.Header = App.Text("Copy"); - copy.Icon = icon; + copy.Icon = App.CreateMenuIcon("Icons.Copy"); copy.Click += (o, ev) => { - App.CopyText(selected); + App.CopyText(SelectedText); ev.Handled = true; }; - var menu = new ContextMenu(); menu.Items.Add(copy); menu.Open(TextArea.TextView); e.Handled = true; @@ -265,7 +280,7 @@ namespace SourceGit.Views { } UpdateGrammar(); - Text = builder.ToString(); + Text = builder.ToString(); } else { Text = string.Empty; } @@ -484,7 +499,7 @@ namespace SourceGit.Views { public SingleSideTextDiffPresenter() : base(new TextArea(), new TextDocument()) { IsReadOnly = true; ShowLineNumbers = false; - WordWrap = false; + WordWrap = false; } protected override void OnLoaded(RoutedEventArgs e) { @@ -527,24 +542,23 @@ namespace SourceGit.Views { } private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e) { - var selected = SelectedText; - if (string.IsNullOrEmpty(selected)) return; + var selection = TextArea.Selection; + if (selection.IsEmpty) return; - var icon = new Avalonia.Controls.Shapes.Path(); - icon.Width = 10; - icon.Height = 10; - icon.Stretch = Stretch.Uniform; - icon.Data = App.Current?.FindResource("Icons.Copy") as StreamGeometry; + var menu = new ContextMenu(); + var parentView = this.FindAncestorOfType(); + if (parentView != null) { + parentView.FillContextMenuForWorkingCopyChange(menu, selection.StartPosition.Line, selection.EndPosition.Line, IsOld); + } var copy = new MenuItem(); copy.Header = App.Text("Copy"); - copy.Icon = icon; + copy.Icon = App.CreateMenuIcon("Icons.Copy"); copy.Click += (o, ev) => { - App.CopyText(selected); + App.CopyText(SelectedText); ev.Handled = true; }; - var menu = new ContextMenu(); menu.Items.Add(copy); menu.Open(TextArea.TextView); e.Handled = true; @@ -617,18 +631,150 @@ namespace SourceGit.Views { set => SetValue(UseCombinedProperty, value); } - public static readonly StyledProperty SyncScrollOffsetProperty = - AvaloniaProperty.Register(nameof(SyncScrollOffset), Vector.Zero); - - public Vector SyncScrollOffset { - get => GetValue(SyncScrollOffsetProperty); - set => SetValue(SyncScrollOffsetProperty, value); - } - public TextDiffView() { InitializeComponent(); } + public void FillContextMenuForWorkingCopyChange(ContextMenu menu, int startLine, int endLine, bool isOldSide) { + var parentView = this.FindAncestorOfType(); + if (parentView == null) return; + + var ctx = parentView.DataContext as ViewModels.DiffContext; + if (ctx == null) return; + + var change = ctx.WorkingCopyChange; + if (change == null) return; + + if (startLine > endLine) { + var tmp = startLine; + startLine = endLine; + endLine = tmp; + } + + var selection = GetUnifiedSelection(startLine, endLine, isOldSide); + if (!selection.HasChanges) return; + + // If all changes has been selected the use method provided by ViewModels.WorkingCopy. + // Otherwise, use `git apply` + if (!selection.HasLeftChanges) { + var workcopyView = this.FindAncestorOfType(); + if (workcopyView == null) return; + + if (ctx.IsUnstaged) { + var stage = new MenuItem(); + stage.Header = App.Text("FileCM.StageSelectedLines"); + stage.Icon = App.CreateMenuIcon("Icons.File.Add"); + stage.Click += (_, e) => { + var workcopy = workcopyView.DataContext as ViewModels.WorkingCopy; + workcopy.StageChanges(new List { change }); + e.Handled = true; + }; + + var discard = new MenuItem(); + discard.Header = App.Text("FileCM.DiscardSelectedLines"); + discard.Icon = App.CreateMenuIcon("Icons.Undo"); + discard.Click += (_, e) => { + var workcopy = workcopyView.DataContext as ViewModels.WorkingCopy; + workcopy.Discard(new List { change }); + e.Handled = true; + }; + + menu.Items.Add(stage); + menu.Items.Add(discard); + } else { + var unstage = new MenuItem(); + unstage.Header = App.Text("FileCM.UnstageSelectedLines"); + unstage.Icon = App.CreateMenuIcon("Icons.File.Remove"); + unstage.Click += (_, e) => { + var workcopy = workcopyView.DataContext as ViewModels.WorkingCopy; + workcopy.UnstageChanges(new List { change }); + e.Handled = true; + }; + menu.Items.Add(unstage); + } + } else { + var repoView = this.FindAncestorOfType(); + if (repoView == null) return; + + if (ctx.IsUnstaged) { + var stage = new MenuItem(); + stage.Header = App.Text("FileCM.StageSelectedLines"); + stage.Icon = App.CreateMenuIcon("Icons.File.Add"); + stage.Click += (_, e) => { + var repo = repoView.DataContext as ViewModels.Repository; + repo.SetWatcherEnabled(false); + + var tmpFile = Path.GetTempFileName(); + if (change.WorkTree == Models.ChangeState.Untracked) { + GenerateNewPatchFromSelection(change, null, selection, false, tmpFile); + } else if (UseCombined) { + var treeGuid = new Commands.QueryStagedFileBlobGuid(ctx.RepositoryPath, change.Path).Result(); + GenerateCombinedPatchFromSelection(change, treeGuid, selection, false, tmpFile); + } + + new Commands.Apply(ctx.RepositoryPath, tmpFile, true, "nowarn", "--cache --index").Exec(); + File.Delete(tmpFile); + + repo.RefreshWorkingCopyChanges(); + repo.SetWatcherEnabled(true); + e.Handled = true; + }; + + var discard = new MenuItem(); + discard.Header = App.Text("FileCM.DiscardSelectedLines"); + discard.Icon = App.CreateMenuIcon("Icons.Undo"); + discard.Click += (_, e) => { + var repo = repoView.DataContext as ViewModels.Repository; + repo.SetWatcherEnabled(false); + + var tmpFile = Path.GetTempFileName(); + if (change.WorkTree == Models.ChangeState.Untracked) { + GenerateNewPatchFromSelection(change, null, selection, true, tmpFile); + } else if (UseCombined) { + var treeGuid = new Commands.QueryStagedFileBlobGuid(ctx.RepositoryPath, change.Path).Result(); + GenerateCombinedPatchFromSelection(change, treeGuid, selection, true, tmpFile); + } + + new Commands.Apply(ctx.RepositoryPath, tmpFile, true, "nowarn", "--reverse").Exec(); + File.Delete(tmpFile); + + repo.RefreshWorkingCopyChanges(); + repo.SetWatcherEnabled(true); + e.Handled = true; + }; + + menu.Items.Add(stage); + menu.Items.Add(discard); + } else { + var unstage = new MenuItem(); + unstage.Header = App.Text("FileCM.UnstageSelectedLines"); + unstage.Icon = App.CreateMenuIcon("Icons.File.Remove"); + unstage.Click += (_, e) => { + var repo = repoView.DataContext as ViewModels.Repository; + repo.SetWatcherEnabled(false); + + var treeGuid = new Commands.QueryStagedFileBlobGuid(ctx.RepositoryPath, change.Path).Result(); + var tmpFile = Path.GetTempFileName(); + if (change.Index == Models.ChangeState.Added) { + GenerateNewPatchFromSelection(change, treeGuid, selection, true, tmpFile); + } else if (UseCombined) { + GenerateCombinedPatchFromSelection(change, treeGuid, selection, true, tmpFile); + } + + new Commands.Apply(ctx.RepositoryPath, tmpFile, true, "nowarn", "--cache --index --reverse").Exec(); + File.Delete(tmpFile); + + repo.RefreshWorkingCopyChanges(); + repo.SetWatcherEnabled(true); + e.Handled = true; + }; + menu.Items.Add(unstage); + } + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); @@ -636,11 +782,210 @@ namespace SourceGit.Views { if (TextDiff == null) { Content = null; } else if (UseCombined) { - Content = new ViewModels.TwoSideTextDiff(TextDiff); - } else { Content = TextDiff; + } else { + Content = new ViewModels.TwoSideTextDiff(TextDiff); } } } + + private TextDiffUnifiedSelection GetUnifiedSelection(int startLine, int endLine, bool isOldSide) { + var rs = new TextDiffUnifiedSelection(); + if (Content is Models.TextDiff combined) { + rs.StartLine = startLine; + rs.EndLine = endLine; + + for (int i = 0; i < startLine - 1; i++) { + var line = combined.Lines[i]; + if (line.Type == Models.TextDiffLineType.Added) { + rs.HasLeftChanges = true; + rs.IgnoredAdds++; + } else if (line.Type == Models.TextDiffLineType.Deleted) { + rs.HasLeftChanges = true; + rs.IgnoredDeletes++; + } + } + + for (int i = startLine - 1; i < endLine; i++) { + var line = combined.Lines[i]; + if (line.Type == Models.TextDiffLineType.Added || line.Type == Models.TextDiffLineType.Deleted) { + rs.HasChanges = true; + break; + } + } + + if (!rs.HasLeftChanges) { + for (int i = endLine; i < combined.Lines.Count; i++) { + var line = combined.Lines[i]; + if (line.Type == Models.TextDiffLineType.Added || line.Type == Models.TextDiffLineType.Deleted) { + rs.HasLeftChanges = true; + break; + } + } + } + } else if (Content is ViewModels.TwoSideTextDiff twoSides) { + + } + + return rs; + } + + private void GenerateNewPatchFromSelection(Models.Change change, string fileBlobGuid, TextDiffUnifiedSelection selection, bool revert, string output) { + var isTracked = !string.IsNullOrEmpty(fileBlobGuid); + var fileGuid = isTracked ? fileBlobGuid.Substring(0, 8) : "00000000"; + + var builder = new StringBuilder(); + builder.Append("diff --git a/").Append(change.Path).Append(" b/").Append(change.Path).Append('\n'); + if (!revert && !isTracked) builder.Append("new file mode 100644\n"); + builder.Append("index 00000000...").Append(fileGuid).Append('\n'); + builder.Append("--- ").Append((revert || isTracked) ? $"a/{change.Path}\n" : "/dev/null\n"); + builder.Append("+++ b/").Append(change.Path).Append('\n'); + + var additions = selection.EndLine - selection.StartLine; + if (selection.StartLine != 1) additions++; + + if (revert) { + var totalLines = TextDiff.Lines.Count - 1; + builder.Append($"@@ -0,").Append(totalLines - additions).Append(" +0,").Append(totalLines).Append(" @@"); + for (int i = 1; i <= totalLines; i++) { + var line = TextDiff.Lines[i]; + if (line.Type != Models.TextDiffLineType.Added) continue; + builder.Append(selection.IsInRange(i) ? "\n+" : "\n ").Append(line.Content); + } + } else { + builder.Append("@@ -0,0 +0,").Append(additions).Append(" @@"); + for (int i = selection.StartLine - 1; i < selection.EndLine; i++) { + var line = TextDiff.Lines[i]; + if (line.Type != Models.TextDiffLineType.Added) continue; + builder.Append("\n+").Append(line.Content); + } + } + + builder.Append("\n\\ No newline at end of file\n"); + File.WriteAllText(output, builder.ToString()); + } + + private void GenerateCombinedPatchFromSelection(Models.Change change, string fileTreeGuid, TextDiffUnifiedSelection selection, bool revert, string output) { + var orgFile = !string.IsNullOrEmpty(change.OriginalPath) ? change.OriginalPath : change.Path; + var indicatorRegex = new Regex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@"); + var diff = TextDiff; + + var builder = new StringBuilder(); + builder.Append("diff --git a/").Append(change.Path).Append(" b/").Append(change.Path).Append('\n'); + builder.Append("index 00000000...").Append(fileTreeGuid).Append(" 100644\n"); + builder.Append("--- a/").Append(orgFile).Append('\n'); + builder.Append("+++ b/").Append(change.Path).Append('\n'); + + // If last line of selection is a change. Find one more line. + var tail = null as string; + if (selection.EndLine < diff.Lines.Count) { + var lastLine = diff.Lines[selection.EndLine - 1]; + if (lastLine.Type == Models.TextDiffLineType.Added || lastLine.Type == Models.TextDiffLineType.Deleted) { + for (int i = selection.EndLine; i < diff.Lines.Count; i++) { + var line = diff.Lines[i]; + if (line.Type == Models.TextDiffLineType.Indicator) break; + if (line.Type == Models.TextDiffLineType.Normal || line.Type == Models.TextDiffLineType.Deleted) { + tail = line.Content; + break; + } + } + } + } + + // If the first line is not indicator. + if (diff.Lines[selection.StartLine - 1].Type != Models.TextDiffLineType.Indicator) { + var indicator = selection.StartLine - 1; + for (int i = selection.StartLine - 2; i >= 0; i--) { + var line = diff.Lines[i]; + if (line.Type == Models.TextDiffLineType.Indicator) { + indicator = i; + break; + } + } + + var ignoreAdds = 0; + var ignoreRemoves = 0; + for (int i = 0; i < indicator; i++) { + var line = diff.Lines[i]; + if (line.Type == Models.TextDiffLineType.Added) { + ignoreAdds++; + } else if (line.Type == Models.TextDiffLineType.Deleted) { + ignoreRemoves++; + } + } + + for (int i = indicator; i < selection.StartLine - 1; i++) { + var line = diff.Lines[i]; + if (line.Type == Models.TextDiffLineType.Indicator) { + ProcessIndicatorForPatch(builder, line, i, selection.StartLine, selection.EndLine, ignoreRemoves, ignoreAdds, tail != null); + } else if (line.Type == Models.TextDiffLineType.Added) { + // Ignores + } else if (line.Type == Models.TextDiffLineType.Deleted || line.Type == Models.TextDiffLineType.Normal) { + // Traits ignored deleted as normal. + builder.Append("\n ").Append(line.Content); + } + } + } + + // Outputs the selected lines. + for (int i = selection.StartLine - 1; i < selection.EndLine; i++) { + var line = diff.Lines[i]; + if (line.Type == Models.TextDiffLineType.Indicator) { + if (!ProcessIndicatorForPatch(builder, line, i, selection.StartLine, selection.EndLine, selection.IgnoredDeletes, selection.IgnoredAdds, tail != null)) { + break; + } + } else if (line.Type == Models.TextDiffLineType.Normal) { + builder.Append("\n ").Append(line.Content); + } else if (line.Type == Models.TextDiffLineType.Added) { + builder.Append("\n+").Append(line.Content); + } else if (line.Type == Models.TextDiffLineType.Deleted) { + builder.Append("\n-").Append(line.Content); + } + } + + builder.Append("\n ").Append(tail); + builder.Append("\n"); + File.WriteAllText(output, builder.ToString()); + } + + private bool ProcessIndicatorForPatch(StringBuilder builder, Models.TextDiffLine indicator, int idx, int start, int end, int ignoreRemoves, int ignoreAdds, bool tailed) { + var indicatorRegex = new Regex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@"); + var diff = TextDiff; + + var match = indicatorRegex.Match(indicator.Content); + var oldStart = int.Parse(match.Groups[1].Value); + var newStart = int.Parse(match.Groups[2].Value) + ignoreRemoves - ignoreAdds; + var oldCount = 0; + var newCount = 0; + for (int i = idx + 1; i < end; i++) { + var test = diff.Lines[i]; + if (test.Type == Models.TextDiffLineType.Indicator) break; + + if (test.Type == Models.TextDiffLineType.Normal) { + oldCount++; + newCount++; + } else if (test.Type == Models.TextDiffLineType.Added) { + if (i >= start - 1) newCount++; + + if (i == end - 1 && tailed) { + newCount++; + oldCount++; + } + } else if (test.Type == Models.TextDiffLineType.Deleted) { + if (i < start - 1) newCount++; + oldCount++; + + if (i == end - 1 && tailed) { + newCount++; + oldCount++; + } + } + } + + if (oldCount == 0 && newCount == 0) return false; + + builder.Append($"@@ -{oldStart},{oldCount} +{newStart},{newCount} @@"); + return true; + } } }