diff --git a/README.md b/README.md index 2ac5ba0723..d38b46603d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ +> [!NOTE] +> This is a fork of [SourceGit](https://github.com/sourcegit-scm/sourcegit) with two extra features: +> +> - **`Avatars` commit graph style**: branches/tags, graph and commit message in separate resizable columns, with author avatars as graph nodes ([sourcegit-scm/sourcegit#2779](https://github.com/sourcegit-scm/sourcegit/pull/2779)) +> - **Defaults for Fetch, Pull and Push**: one-click actions with saved default options, dropdowns for the option popups, and a spinner/checkmark under the buttons ([sourcegit-scm/sourcegit#2777](https://github.com/sourcegit-scm/sourcegit/pull/2777)) +> +> ![Avatars commit graph style](./screenshots/commit_graph_avatars.png) + # SourceGit - Opensource Git GUI client. [![stars](https://img.shields.io/github/stars/sourcegit-scm/sourcegit.svg)](https://github.com/sourcegit-scm/sourcegit/stargazers) diff --git a/screenshots/commit_graph_avatars.png b/screenshots/commit_graph_avatars.png new file mode 100644 index 0000000000..112056a077 Binary files /dev/null and b/screenshots/commit_graph_avatars.png differ diff --git a/src/App.axaml.cs b/src/App.axaml.cs index 66882b4869..5f4c7217e6 100644 --- a/src/App.axaml.cs +++ b/src/App.axaml.cs @@ -227,6 +227,12 @@ public static ViewModels.Launcher GetLauncher() return Current is App app ? app._launcher : null; } + public static async Task CopyTextAsync(string text) + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow.Clipboard: { } clipboard }) + await clipboard.SetTextAsync(text); + } + public static void Quit(int exitCode) { if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) diff --git a/src/Commands/QueryPullRequestTemplates.cs b/src/Commands/QueryPullRequestTemplates.cs new file mode 100644 index 0000000000..6e084b45dd --- /dev/null +++ b/src/Commands/QueryPullRequestTemplates.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + /// + /// Finds the pull/merge request templates (GitHub, GitLab, Gitea, Forgejo, Azure DevOps) in the given revision. + /// + public class QueryPullRequestTemplates + { + public QueryPullRequestTemplates(string repo, string revision) + { + _repo = repo; + _revision = revision; + } + + public async Task> GetResultAsync() + { + var templates = new List(); + + var lister = new QueryRevisionFileNames(_repo, _revision, LOOKUP_PATHS); + var files = await lister.GetResultAsync().ConfigureAwait(false); + foreach (var file in files) + { + if (!IsTemplate(file, out var isDefault)) + continue; + + using var stream = await QueryFileContent.RunAsync(_repo, _revision, file).ConfigureAwait(false); + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync().ConfigureAwait(false); + + var template = new Models.PullRequestTemplate() + { + Name = file, + Content = content.Trim(), + IsDefault = isDefault, + }; + + if (isDefault) + templates.Insert(0, template); + else + templates.Add(template); + } + + return templates; + } + + private static bool IsTemplate(string file, out bool isDefault) + { + isDefault = false; + + var path = file.ToLowerInvariant(); + if (!path.EndsWith(".md", StringComparison.Ordinal)) + return false; + + var name = Path.GetFileName(path); + var dir = Path.GetDirectoryName(path)?.Replace('\\', '/') ?? string.Empty; + + if (name.Equals("pull_request_template.md", StringComparison.Ordinal)) + { + isDefault = true; + return Array.IndexOf(SINGLE_TEMPLATE_DIRS, dir) >= 0; + } + + if (dir.Equals(".gitlab/merge_request_templates", StringComparison.Ordinal)) + { + isDefault = name.Equals("default.md", StringComparison.Ordinal); + return true; + } + + return Array.IndexOf(MULTIPLE_TEMPLATES_DIRS, dir) >= 0; + } + + private static readonly string[] LOOKUP_PATHS = [ + ".github", ".gitea", ".forgejo", ".gitlab/merge_request_templates", ".azuredevops", ".vsts", + "docs/pull_request_template.md", "docs/PULL_REQUEST_TEMPLATE.md", "pull_request_template.md", "PULL_REQUEST_TEMPLATE.md", + ]; + + private static readonly string[] SINGLE_TEMPLATE_DIRS = ["", ".github", ".gitea", ".forgejo", ".azuredevops", ".vsts", "docs"]; + private static readonly string[] MULTIPLE_TEMPLATES_DIRS = [".github/pull_request_template", ".gitea/pull_request_template", ".forgejo/pull_request_template"]; + + private readonly string _repo; + private readonly string _revision; + } +} diff --git a/src/Commands/QueryRemoteHead.cs b/src/Commands/QueryRemoteHead.cs new file mode 100644 index 0000000000..bdbeb0771c --- /dev/null +++ b/src/Commands/QueryRemoteHead.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryRemoteHead : Command + { + public QueryRemoteHead(string repo, string remote) + { + WorkingDirectory = repo; + Context = repo; + Args = $"symbolic-ref --short refs/remotes/{remote}/HEAD"; + + _remote = remote; + } + + /// + /// Returns the name of the remote's default branch (without the remote prefix), or null if unknown. + /// + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return null; + + var output = rs.StdOut.Trim(); + var prefix = $"{_remote}/"; + if (output.StartsWith(prefix, StringComparison.Ordinal)) + return output.Substring(prefix.Length); + + return null; + } + + private readonly string _remote; + } +} diff --git a/src/Commands/QueryRemotes.cs b/src/Commands/QueryRemotes.cs index 6adff9b36d..96e74141b1 100644 --- a/src/Commands/QueryRemotes.cs +++ b/src/Commands/QueryRemotes.cs @@ -66,6 +66,9 @@ public QueryRemotes(string repo) DisableAutoFetch = disableAutoFetchRemotes.Contains(name) }; + if (Models.Remote.TryGetSSHHost(url, out var host)) + Models.HTTPSValidator.Add(host); + remotes.Add(r); } diff --git a/src/Commands/QueryRevisionFileNames.cs b/src/Commands/QueryRevisionFileNames.cs index e4dcc25c51..bba42ee642 100644 --- a/src/Commands/QueryRevisionFileNames.cs +++ b/src/Commands/QueryRevisionFileNames.cs @@ -13,6 +13,13 @@ public QueryRevisionFileNames(string repo, string revision) Args = $"ls-tree -r --name-only {revision}"; } + public QueryRevisionFileNames(string repo, string revision, string[] paths) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-tree -r --name-only {revision} -- {string.Join(' ', paths)}"; + } + public async Task> GetResultAsync() { var outs = new List(); diff --git a/src/Converters/CommitGraphStyleConverters.cs b/src/Converters/CommitGraphStyleConverters.cs new file mode 100644 index 0000000000..ffa8505b13 --- /dev/null +++ b/src/Converters/CommitGraphStyleConverters.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Data.Converters; + +namespace SourceGit.Converters +{ + public static class CommitGraphStyleConverters + { + public static readonly FuncValueConverter IsClassic = + new(v => v == Models.CommitGraphStyle.Classic); + + public static readonly FuncValueConverter IsAvatars = + new(v => v == Models.CommitGraphStyle.Avatars); + + public static readonly FuncValueConverter ToRowHeight = + new(v => v == Models.CommitGraphStyle.Avatars ? 28 : 26); + + // Places a hit-area centered on the commit's avatar in the graph column. + public static readonly FuncValueConverter ToAvatarHitAreaMargin = + new(v => new Thickness(Views.CommitGraph.GetAvatarsStyleX(v) - 12, 0, 0, 0)); + + // Values: [Commit.LeftMargin, CommitGraphStyle]. Only the classic style draws the graph under the subject. + public static readonly FuncMultiValueConverter ToSubjectMargin = + new(v => + { + var values = new List(v); + if (values.Count == 2 && values[0] is double margin && values[1] is Models.CommitGraphStyle.Classic) + return new Thickness(margin, 0, 0, 0); + + return new Thickness(4, 0, 0, 0); + }); + } +} diff --git a/src/Converters/DoubleConverters.cs b/src/Converters/DoubleConverters.cs index 871a80b3e5..b68437b398 100644 --- a/src/Converters/DoubleConverters.cs +++ b/src/Converters/DoubleConverters.cs @@ -1,5 +1,4 @@ -using Avalonia; -using Avalonia.Data.Converters; +using Avalonia.Data.Converters; namespace SourceGit.Converters { @@ -16,8 +15,5 @@ public static class DoubleConverters public static readonly FuncValueConverter OneMinusToPercentage = new FuncValueConverter(v => ((1.0 - v) * 100).ToString("F0") + "%"); - - public static readonly FuncValueConverter ToLeftMargin = - new FuncValueConverter(v => new Thickness(v, 0, 0, 0)); } } diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index de299ed6b7..7b97ac4750 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -27,6 +27,7 @@ public class Commit : ObservableObject public bool IsMerged { get; set; } = false; public int Color { get; set; } = 0; public double LeftMargin { get; set; } = 0; + public double GraphNodeX { get; set; } = 0; public bool IsHighlightedInGraph { diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 916a79b1bb..2d18bc70b4 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -6,7 +6,7 @@ namespace SourceGit.Models { - public record CommitGraphLayout(double StartY, double ClipWidth, double RowHeight); + public record CommitGraphLayout(double StartY, double OffsetX, double ClipWidth, double RowHeight); public enum CommitGraphHighlighting { @@ -17,6 +17,12 @@ public enum CommitGraphHighlighting SelectedCommitsOnlyFirstParent, } + public enum CommitGraphStyle + { + Classic = 0, + Avatars, + } + public class CommitGraph { public static List Pens { get; } = []; @@ -65,11 +71,13 @@ public class Dot public Point Center; public int Color; public bool IsHighlighted; + public Commit Commit; } public List Paths { get; } = []; public List Links { get; } = []; public List Dots { get; } = []; + public double Width { get; private set; } = 0; public static CommitGraph Generate(List commits, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits) { @@ -198,7 +206,7 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna // Calculate link position of this commit. var position = new Point(major?.LastX ?? offsetX, offsetY); var dotColor = major?.Path.Color ?? 0; - var anchor = new Dot() { Center = position, Color = dotColor, IsHighlighted = isHighlighted }; + var anchor = new Dot() { Center = position, Color = dotColor, IsHighlighted = isHighlighted, Commit = commit }; if (commit.IsCurrentHead) anchor.Type = DotType.Head; else if (commit.Parents.Count > 1) @@ -249,7 +257,9 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna // Margins & colors (used by Views.Histories). commit.Color = dotColor; + commit.GraphNodeX = position.X; commit.LeftMargin = Math.Max(offsetX, maxOffsetOld) + halfWidth + 2; + temp.Width = Math.Max(temp.Width, commit.LeftMargin); } // Deal with curves haven't ended yet. diff --git a/src/Models/HTTPSValidator.cs b/src/Models/HTTPSValidator.cs index 014207c21c..253e8634fa 100644 --- a/src/Models/HTTPSValidator.cs +++ b/src/Models/HTTPSValidator.cs @@ -21,13 +21,7 @@ public static void Add(string host) _hosts.Add(host, true); // Well-known hosts always support HTTPS - if (host.Contains("github.com", StringComparison.Ordinal) || - host.Contains("gitlab", StringComparison.Ordinal) || - host.Contains("azure.com", StringComparison.Ordinal) || - host.Equals("gitee.com", StringComparison.Ordinal) || - host.Equals("bitbucket.org", StringComparison.Ordinal) || - host.Equals("gitea.org", StringComparison.Ordinal) || - host.Equals("gitcode.com", StringComparison.Ordinal)) + if (IsWellKnownHost(host)) return; } @@ -67,6 +61,20 @@ public static void Add(string host) }); } + public static bool IsWellKnownHost(string host) + { + return host.Contains("github.com", StringComparison.Ordinal) || + host.Contains("gitlab", StringComparison.Ordinal) || + host.Contains("azure.com", StringComparison.Ordinal) || + host.Contains("visualstudio.com", StringComparison.Ordinal) || + host.Equals("gitee.com", StringComparison.Ordinal) || + host.Equals("bitbucket.org", StringComparison.Ordinal) || + host.Equals("gitea.org", StringComparison.Ordinal) || + host.Equals("gitea.com", StringComparison.Ordinal) || + host.Equals("codeberg.org", StringComparison.Ordinal) || + host.Equals("gitcode.com", StringComparison.Ordinal); + } + public static bool IsSupported(string host) { lock (_syncLock) diff --git a/src/Models/PullRequestPlatform.cs b/src/Models/PullRequestPlatform.cs new file mode 100644 index 0000000000..94c1e936f5 --- /dev/null +++ b/src/Models/PullRequestPlatform.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Serialization; + +namespace SourceGit.Models +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum PullRequestPlatformType + { + None = 0, + GitHub, + GitLab, + Gitea, + Forgejo, + BitbucketCloud, + AzureDevOps, + Gitee, + } + + public class PullRequestPlatform + { + public const int MaxURLLength = 7000; + + public static readonly List Supported = [ + new(PullRequestPlatformType.GitHub, "GitHub", true, true, true), + new(PullRequestPlatformType.GitLab, "GitLab", true, true, true), + new(PullRequestPlatformType.Gitea, "Gitea", true, true, true), + new(PullRequestPlatformType.Forgejo, "Forgejo / Codeberg", false, false, true), + new(PullRequestPlatformType.BitbucketCloud, "Bitbucket Cloud", false, false, false), + new(PullRequestPlatformType.AzureDevOps, "Azure DevOps", false, false, false), + new(PullRequestPlatformType.Gitee, "Gitee", false, false, false), + ]; + + public PullRequestPlatformType Type { get; } + public string Name { get; } + public bool SupportsTitle { get; } + public bool SupportsBody { get; } + public bool SupportsCrossRepo { get; } + + public PullRequestPlatform(PullRequestPlatformType type, string name, bool supportsTitle, bool supportsBody, bool supportsCrossRepo) + { + Type = type; + Name = name; + SupportsTitle = supportsTitle; + SupportsBody = supportsBody; + SupportsCrossRepo = supportsCrossRepo; + } + + public static PullRequestPlatform Get(PullRequestPlatformType type) + { + return Supported.Find(x => x.Type == type); + } + + public static PullRequestPlatformType Detect(string host) + { + if (string.IsNullOrEmpty(host)) + return PullRequestPlatformType.None; + + host = host.ToLowerInvariant(); + + if (host.Contains("github", StringComparison.Ordinal)) + return PullRequestPlatformType.GitHub; + + if (host.Contains("gitlab", StringComparison.Ordinal)) + return PullRequestPlatformType.GitLab; + + if (host.Equals("codeberg.org", StringComparison.Ordinal) || host.Contains("forgejo", StringComparison.Ordinal)) + return PullRequestPlatformType.Forgejo; + + if (host.Contains("gitea", StringComparison.Ordinal)) + return PullRequestPlatformType.Gitea; + + if (host.Equals("bitbucket.org", StringComparison.Ordinal)) + return PullRequestPlatformType.BitbucketCloud; + + if (host.Equals("dev.azure.com", StringComparison.Ordinal) || host.EndsWith("visualstudio.com", StringComparison.Ordinal)) + return PullRequestPlatformType.AzureDevOps; + + if (host.Equals("gitee.com", StringComparison.Ordinal)) + return PullRequestPlatformType.Gitee; + + return PullRequestPlatformType.None; + } + + public static PullRequestPlatform Resolve(Remote remote, RepositorySettings settings) + { + if (remote == null) + return null; + + if (settings != null && + settings.PullRequestPlatforms.TryGetValue(remote.Name, out var overrided) && + overrided != PullRequestPlatformType.None) + return Get(overrided); + + if (!remote.TryGetVisitURL(out var url)) + return null; + + return Get(Detect(new Uri(url).Host)); + } + + /// + /// Builds the URL of the platform's "create pull request" page. + /// + /// Web URL of the repository that receives the pull request + /// Web URL of the repository that contains the source branch (same as target if not a fork) + /// True if the title or body could not be passed by URL + public string BuildCreateURL(string targetBaseURL, string sourceBaseURL, string sourceBranch, string targetBranch, string title, string body, out bool contentDropped) + { + contentDropped = (!string.IsNullOrEmpty(title) && !SupportsTitle) || (!string.IsNullOrEmpty(body) && !SupportsBody); + title = SupportsTitle ? title : null; + body = SupportsBody ? body : null; + + var url = BuildCreateURLWithContent(targetBaseURL, sourceBaseURL, sourceBranch, targetBranch, title, body); + if (url.Length > MaxURLLength && !string.IsNullOrEmpty(body)) + { + contentDropped = true; + url = BuildCreateURLWithContent(targetBaseURL, sourceBaseURL, sourceBranch, targetBranch, title, null); + } + + if (url.Length > MaxURLLength && !string.IsNullOrEmpty(title)) + url = BuildCreateURLWithContent(targetBaseURL, sourceBaseURL, sourceBranch, targetBranch, null, null); + + return url; + } + + private string BuildCreateURLWithContent(string targetBaseURL, string sourceBaseURL, string sourceBranch, string targetBranch, string title, string body) + { + targetBaseURL = targetBaseURL.TrimEnd('/'); + sourceBaseURL = string.IsNullOrEmpty(sourceBaseURL) ? targetBaseURL : sourceBaseURL.TrimEnd('/'); + + var isCrossRepo = !sourceBaseURL.Equals(targetBaseURL, StringComparison.OrdinalIgnoreCase); + var builder = new StringBuilder(1024); + + switch (Type) + { + case PullRequestPlatformType.GitHub: + case PullRequestPlatformType.Gitea: + case PullRequestPlatformType.Forgejo: + { + var head = EscapePath(sourceBranch); + if (isCrossRepo) + head = $"{Uri.EscapeDataString(GetOwner(sourceBaseURL))}:{head}"; + + builder.Append(targetBaseURL).Append("/compare/").Append(EscapePath(targetBranch)).Append("...").Append(head); + builder.Append(Type == PullRequestPlatformType.GitHub ? "?quick_pull=1" : "?expand=1"); + AppendQuery(builder, "title", title); + AppendQuery(builder, "body", body); + break; + } + case PullRequestPlatformType.GitLab: + // For forks, GitLab creates the merge request from the source project, and the target project defaults to the upstream one. + builder.Append(sourceBaseURL).Append("/-/merge_requests/new?"); + builder.Append("merge_request%5Bsource_branch%5D=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "merge_request%5Btarget_branch%5D", targetBranch); + AppendQuery(builder, "merge_request%5Btitle%5D", title); + AppendQuery(builder, "merge_request%5Bdescription%5D", body); + break; + case PullRequestPlatformType.BitbucketCloud: + builder.Append(targetBaseURL).Append("/pull-requests/new?source=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "dest", targetBranch); + break; + case PullRequestPlatformType.AzureDevOps: + builder.Append(targetBaseURL).Append("/pullrequestcreate?sourceRef=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "targetRef", targetBranch); + break; + case PullRequestPlatformType.Gitee: + builder.Append(targetBaseURL).Append("/pulls/new?source=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "target", targetBranch); + break; + default: + return targetBaseURL; + } + + return builder.ToString(); + } + + private static void AppendQuery(StringBuilder builder, string key, string value) + { + if (string.IsNullOrEmpty(value)) + return; + + builder.Append('&').Append(key).Append('=').Append(Uri.EscapeDataString(value)); + } + + private static string EscapePath(string branch) + { + var segments = branch.Split('/'); + for (int i = 0; i < segments.Length; i++) + segments[i] = Uri.EscapeDataString(segments[i]); + return string.Join('/', segments); + } + + private static string GetOwner(string baseURL) + { + var path = new Uri(baseURL).AbsolutePath.Trim('/'); + var idx = path.IndexOf('/'); + return idx > 0 ? path.Substring(0, idx) : path; + } + } +} diff --git a/src/Models/PullRequestTemplate.cs b/src/Models/PullRequestTemplate.cs new file mode 100644 index 0000000000..6973f2e84f --- /dev/null +++ b/src/Models/PullRequestTemplate.cs @@ -0,0 +1,9 @@ +namespace SourceGit.Models +{ + public class PullRequestTemplate + { + public string Name { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public bool IsDefault { get; set; } = false; + } +} diff --git a/src/Models/Remote.cs b/src/Models/Remote.cs index ab16874d26..51be0add94 100644 --- a/src/Models/Remote.cs +++ b/src/Models/Remote.cs @@ -1,7 +1,6 @@ using System; using System.IO; using System.Text.RegularExpressions; -using System.Web; namespace SourceGit.Models { @@ -19,8 +18,11 @@ public partial class Remote [GeneratedRegex(@"^ssh://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/([a-zA-z0-9~%][\w\-\./~%]*)?[a-zA-Z0-9](\.git)?$")] private static partial Regex REG_SSH2(); - [GeneratedRegex(@"^git@([\w\.\-]+):([\w\.\-/~%]+/[\w\-\.%]+)\.git$")] - private static partial Regex REG_TO_VISIT_URL_CAPTURE(); + [GeneratedRegex(@"^[\w\-\.]+@([\w\.\-]+):/?([\w\.\-/~%]+/[\w\-\.%]+?)(\.git)?/?$")] + private static partial Regex REG_TO_VISIT_URL_CAPTURE_SCP(); + + [GeneratedRegex(@"^ssh://([\w\-\.]+@)?([\w\.\-]+)(\:[0-9]+)?/([\w\.\-/~%]+/[\w\-\.%]+?)(\.git)?/?$")] + private static partial Regex REG_TO_VISIT_URL_CAPTURE_SSH(); private static readonly Regex[] URL_FORMATS = [ REG_HTTPS(), @@ -78,64 +80,64 @@ public bool TryGetVisitURL(out string url) return true; } - var match = REG_TO_VISIT_URL_CAPTURE().Match(URL); - if (match.Success) + if (!TryParseSSH(URL, out var host, out var path)) + return false; + + // Azure DevOps uses a different path layout for SSH remotes. + // git@ssh.dev.azure.com:v3/{org}/{project}/{repo} => https://dev.azure.com/{org}/{project}/_git/{repo} + // {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo} => https://{org}.visualstudio.com/{project}/_git/{repo} + if (path.StartsWith("v3/", StringComparison.Ordinal)) { - var host = match.Groups[1].Value; - var supportHTTPS = HTTPSValidator.IsSupported(host); - var scheme = supportHTTPS ? "https" : "http"; - url = $"{scheme}://{host}/{match.Groups[2].Value}"; - return true; + var parts = path.Split('/'); + if (parts.Length == 4) + { + if (host.Equals("ssh.dev.azure.com", StringComparison.Ordinal)) + { + url = $"https://dev.azure.com/{parts[1]}/{parts[2]}/_git/{parts[3]}"; + return true; + } + + if (host.Equals("vs-ssh.visualstudio.com", StringComparison.Ordinal)) + { + url = $"https://{parts[1]}.visualstudio.com/{parts[2]}/_git/{parts[3]}"; + return true; + } + } } - return false; + var supportHTTPS = HTTPSValidator.IsWellKnownHost(host) || HTTPSValidator.IsSupported(host); + var scheme = supportHTTPS ? "https" : "http"; + url = $"{scheme}://{host}/{path}"; + return true; } - public bool TryGetCreatePullRequestURL(out string url, string mergeBranch) + public static bool TryGetSSHHost(string url, out string host) { - url = null; - - if (!TryGetVisitURL(out var baseURL)) - return false; - - var uri = new Uri(baseURL); - var host = uri.Host; - var encodedBranch = HttpUtility.UrlEncode(mergeBranch); - - if (host.Contains("github.com", StringComparison.Ordinal)) - { - url = $"{baseURL}/compare/{encodedBranch}?expand=1"; - return true; - } - - if (host.Contains("gitlab", StringComparison.Ordinal)) - { - url = $"{baseURL}/-/merge_requests/new?merge_request%5Bsource_branch%5D={encodedBranch}"; - return true; - } + return TryParseSSH(url, out host, out _); + } - if (host.Equals("gitee.com", StringComparison.Ordinal)) - { - url = $"{baseURL}/pulls/new?source={encodedBranch}"; - return true; - } + private static bool TryParseSSH(string url, out string host, out string path) + { + host = null; + path = null; - if (host.Equals("bitbucket.org", StringComparison.Ordinal)) - { - url = $"{baseURL}/pull-requests/new?source={encodedBranch}"; - return true; - } + if (string.IsNullOrEmpty(url)) + return false; - if (host.Equals("gitea.org", StringComparison.Ordinal)) + // NOTE: The port of SSH remotes is not used, since the port of web page is unknown. + var match = REG_TO_VISIT_URL_CAPTURE_SSH().Match(url); + if (match.Success) { - url = $"{baseURL}/compare/{encodedBranch}"; + host = match.Groups[2].Value; + path = match.Groups[4].Value; return true; } - if (host.Contains("azure.com", StringComparison.Ordinal) || - host.Contains("visualstudio.com", StringComparison.Ordinal)) + match = REG_TO_VISIT_URL_CAPTURE_SCP().Match(url); + if (match.Success) { - url = $"{baseURL}/pullrequestcreate?sourceRef={encodedBranch}"; + host = match.Groups[1].Value; + path = match.Groups[2].Value; return true; } diff --git a/src/Models/RepositorySettings.cs b/src/Models/RepositorySettings.cs index 506e6ded78..c171971e29 100644 --- a/src/Models/RepositorySettings.cs +++ b/src/Models/RepositorySettings.cs @@ -60,6 +60,12 @@ public AvaloniaList CustomActions set; } = []; + public Dictionary PullRequestPlatforms + { + get; + set; + } = []; + public static RepositorySettings Get(string gitCommonDir) { var fileInfo = new FileInfo(Path.Combine(gitCommonDir, "sourcegit.settings")); diff --git a/src/Models/RepositoryUIStates.cs b/src/Models/RepositoryUIStates.cs index 0b35f8d618..75d57c584f 100644 --- a/src/Models/RepositoryUIStates.cs +++ b/src/Models/RepositoryUIStates.cs @@ -45,6 +45,24 @@ public double AuthorColumnWidth set; } = 120; + public bool IsRefsColumnVisibleInHistory + { + get; + set; + } = true; + + public double RefsColumnWidth + { + get; + set; + } = 180; + + public double GraphColumnWidth + { + get; + set; + } = 0; + public bool EnableTopoOrderInHistory { get; @@ -117,12 +135,30 @@ public bool PreferRebaseInsteadOfMerge set; } = true; + public DealWithLocalChanges? DealWithLocalChangesOnPull + { + get; + set; + } = null; + public bool CheckSubmodulesOnPush { get; set; } = true; + public bool SetTrackingOnPush + { + get; + set; + } = false; + + public bool NoVerifyOnPush + { + get; + set; + } = false; + public bool PushAllTags { get; diff --git a/src/Resources/Icons.axaml b/src/Resources/Icons.axaml index a410aef45f..ab784000e1 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -122,6 +122,7 @@ M853 256h-43v512h43c47 0 85-38 85-85v-341c0-47-38-85-85-85zM725 768V171h128V85h-341v85H640v85H171c-47 0-85 38-85 85v341c0 47 38 85 85 85h469V853h-128v85h341v-85H725v-86zm-469-171v-171h384v171H256z M960 146v91C960 318 759 384 512 384S64 318 64 238V146C64 66 265 0 512 0s448 66 448 146zM960 352v206C960 638 759 704 512 704S64 638 64 558V352c96 66 272 97 448 97S864 418 960 352zm0 320v206C960 958 759 1024 512 1024S64 958 64 878V672c96 66 272 97 448 97S864 738 960 672z M883 567l-128-128c-17-17-43-17-60 0l-128 128c-17 17-17 43 0 60 17 17 43 17 60 0l55-55V683c0 21-21 43-43 43H418c-13-38-43-64-77-77V375c51-17 85-64 85-119 0-73-60-128-128-128-73 0-128 55-128 128 0 55 34 102 85 119v269c-51 17-85 64-85 119 0 73 55 128 128 128 55 0 102-34 119-85H640c73 0 128-55 128-128v-111l55 55c9 9 17 13 30 13 13 0 21-4 30-13 17-13 17-43 0-55zM299 213c26 0 43 17 43 43 0 21-21 43-43 43-26 0-43-21-43-43 0-26 17-43 43-43zm0 597c-26 0-43-21-43-43 0-26 17-43 43-43s43 17 43 43c0 21-17 43-43 43zM725 384c-73 0-128-60-128-128 0-73 55-128 128-128s128 55 128 128c0 68-55 128-128 128zm0-171c-26 0-43 17-43 43s17 43 43 43 43-17 43-43-17-43-43-43z + M512 112A400 400 0 1 1 112 512H192A320 320 0 1 0 512 192V272L352 152L512 32Z M293 122v244h439V146l171 175V829a73 73 0 01-73 73h-98V536H293v366H195a73 73 0 01-73-73V195a73 73 0 0173-73h98zm366 512v268H366V634h293zm-49 49h-195v73h195v-73zm49-561v171H366V122h293z M0 551V472c0-11 9-19 19-19h984c11 0 19 9 19 19v79c0 11-9 19-19 19H19c-11 0-19-9-19-19zM114 154v240c0 11-9 19-19 19H19C9 413 0 404 0 394V79C0 35 35 0 79 0h315c11 0 19 9 19 19v75c0 11-9 19-19 19H154c-21 0-39 18-39 39zm795 0c0-22-17-39-39-39h-240c-11 0-19-9-19-19V19c0-11 9-19 19-19h315c43 0 79 35 79 79v315c0 11-9 19-19 19h-75c-11 0-19-9-19-19l-1-240zm0 716v-240c0-11 9-19 19-19h75c11 0 19 9 19 19v315c0 43-35 79-79 79h-315c-11 0-19-9-19-19v-75c0-11 9-19 19-19H870c21-1 39-18 39-40zm-795 0c0 21 18 39 39 39h240c11 0 19 9 19 19v75c0 11-9 19-19 19H79C35 1023 0 988 0 945v-315c0-11 9-19 19-19h75c11 0 19 9 19 19V870z M155 143h715v81H155V143zm358 94 358 369H662l1 278H363V605H155l358-369z diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 76ac9e4dcb..140a5afa71 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -64,7 +64,7 @@ Compare with HEAD Compare with ${0}$ Copy Branch Name - Create PR... + Create PR: ${0}$ → ${1}$... Create PR for upstream ${0}$... Custom Action Delete ${0}$... @@ -325,6 +325,21 @@ Enter branch name. Create Local Branch Overwrite existing branch + Create Pull Request + The source branch has not been pushed yet. + This platform can not prefill the title and description. They will be copied to the clipboard so you can paste them on the opened page. + The source branch has commits that have not been pushed yet. + Title and description have been copied to the clipboard. Paste them on the opened page. + Description: + (None) + Platform: + Push the source branch before opening the PR + Push the local commits before opening the PR + Push To: + Source: + Target: + Template: + Title: Create Tag... New Tag At: GPG signing @@ -517,9 +532,12 @@ Advanced Options AUTHOR AUTHOR TIME + BRANCH / TAG COMMIT TIME + GRAPH GRAPH & SUBJECT SHA + COMMIT MESSAGE HIGHLIGHTS IN GRAPH All Current Branch Only @@ -662,6 +680,8 @@ {0} months ago {0} years ago Yesterday + Reset defaults + Save as default Preferences AI Additional Prompt (Use `-` to list your requirements) @@ -674,6 +694,9 @@ Reasoning Effort Level Server APPEARANCE + Commit Graph Style + Avatars (branches, graph and message in separate columns) + Classic Default Font Editor Tab Width Font Size @@ -850,6 +873,7 @@ First-parent only SHOW FLAGS Show lost commits + Show options... Show Submodules as Tree Show Tags as Tree SKIP diff --git a/src/ViewModels/CreatePullRequest.cs b/src/ViewModels/CreatePullRequest.cs new file mode 100644 index 0000000000..1e4ba1e3df --- /dev/null +++ b/src/ViewModels/CreatePullRequest.cs @@ -0,0 +1,469 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text; +using System.Threading.Tasks; + +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public class CreatePullRequest : Popup + { + public Models.Branch Source + { + get; + } + + public bool NeedsPush + { + get; + } + + public bool PushFirst + { + get; + set; + } + + public bool IsPushRemoteSelectable + { + get => _upstream == null; + } + + public List PushRemotes + { + get; + } + + public Models.Remote SelectedPushRemote + { + get => _selectedPushRemote; + set + { + if (SetProperty(ref _selectedPushRemote, value)) + RefreshTargetBranches(); + } + } + + public List TargetBranches + { + get => _targetBranches; + private set => SetProperty(ref _targetBranches, value); + } + + [Required(ErrorMessage = "Target branch is required!!!")] + [CustomValidation(typeof(CreatePullRequest), nameof(ValidateTarget))] + public Models.Branch SelectedTarget + { + get => _selectedTarget; + set + { + if (SetProperty(ref _selectedTarget, value, true)) + PostTargetChanged(); + } + } + + public List Platforms + { + get => Models.PullRequestPlatform.Supported; + } + + public bool IsPlatformSelectable + { + get => _isPlatformSelectable; + private set => SetProperty(ref _isPlatformSelectable, value); + } + + [Required(ErrorMessage = "Platform is required!!!")] + public Models.PullRequestPlatform SelectedPlatform + { + get => _selectedPlatform; + set + { + if (SetProperty(ref _selectedPlatform, value, true)) + { + OnPropertyChanged(nameof(IsClipboardHintVisible)); + ValidateProperty(_selectedTarget, nameof(SelectedTarget)); + } + } + } + + public bool IsClipboardHintVisible + { + get => _selectedPlatform != null && (!_selectedPlatform.SupportsTitle || !_selectedPlatform.SupportsBody); + } + + public List Templates + { + get => _templates; + private set => SetProperty(ref _templates, value); + } + + public Models.PullRequestTemplate SelectedTemplate + { + get => _selectedTemplate; + set + { + if (SetProperty(ref _selectedTemplate, value)) + UpdateDefaultContent(); + } + } + + [Required(ErrorMessage = "Title is required!!!")] + public string Title + { + get => _title; + set + { + if (SetProperty(ref _title, value, true)) + _isTitleEdited = true; + } + } + + public string Description + { + get => _description; + set + { + // NOTE: TextBox may normalize line endings of generated content, which should not be treated as an edit. + if (SetProperty(ref _description, value) && !_generatedDescription.Equals(value?.ReplaceLineEndings("\n"), StringComparison.Ordinal)) + _isDescriptionEdited = true; + } + } + + public CreatePullRequest(Repository repo, Models.Branch source, Models.Branch target) + { + _repo = repo; + Source = source; + + if (!string.IsNullOrEmpty(source.Upstream) && !source.IsUpstreamGone) + _upstream = repo.Branches.Find(x => x.FullName.Equals(source.Upstream, StringComparison.Ordinal)); + + NeedsPush = _upstream == null || source.Ahead.Count > 0; + PushFirst = NeedsPush; + + PushRemotes = repo.Remotes; + _selectedPushRemote = _upstream != null ? repo.Remotes.Find(x => x.Name.Equals(_upstream.Remote, StringComparison.Ordinal)) : GetDefaultPushRemote(target); + + RefreshTargetBranches(); + + UpdateDefaultContent(); + + if (target != null) + SelectedTarget = target; + else + SelectDefaultTarget(); + } + + /// + /// Checks if a pull request can be created for the given local branch's upstream. + /// + public static bool CanCreateForUpstream(Repository repo, Models.Branch upstream) + { + return upstream != null && GetRemoteHost(repo, upstream.Remote) != null; + } + + /// + /// Checks if a pull request can be created from the current branch into the given remote branch. + /// is true when the remote branch is the upstream of current branch, + /// in which case the pull request is created for the current branch with the remote's default branch as target. + /// + public static bool CanCreateInto(Repository repo, Models.Branch head, Models.Branch target, out bool isUpstreamOfHead) + { + isUpstreamOfHead = false; + + if (head is not { IsDetachedHead: false } || target == null || target.IsLocal) + return false; + + var targetHost = GetRemoteHost(repo, target.Remote); + if (targetHost == null) + return false; + + isUpstreamOfHead = target.FullName.Equals(head.Upstream, StringComparison.Ordinal); + if (isUpstreamOfHead) + return true; + + // Only allows pull requests between repositories on the same server. + var headRemote = repo.Branches.Find(x => x.FullName.Equals(head.Upstream, StringComparison.Ordinal))?.Remote; + var headHost = string.IsNullOrEmpty(headRemote) ? null : GetRemoteHost(repo, headRemote); + return headHost == null || headHost.Equals(targetHost, StringComparison.OrdinalIgnoreCase); + } + + public static ValidationResult ValidateTarget(Models.Branch target, ValidationContext ctx) + { + if (ctx.ObjectInstance is CreatePullRequest { SelectedPlatform: { SupportsCrossRepo: false } } creator && + target != null && + !target.Remote.Equals(creator._selectedPushRemote?.Name, StringComparison.Ordinal)) + { + return new ValidationResult($"{creator.SelectedPlatform.Name} does not support pull requests between different repositories here!"); + } + + return ValidationResult.Success; + } + + public override async Task Sure() + { + var sourceRemote = _selectedPushRemote; + var targetRemote = _repo.Remotes.Find(x => x.Name.Equals(_selectedTarget.Remote, StringComparison.Ordinal)); + if (sourceRemote == null || targetRemote == null) + return false; + + if (PushFirst && NeedsPush) + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = $"Push {Source.Name} -> {sourceRemote.Name} ..."; + + var log = _repo.CreateLog("Push"); + Use(log); + + var remoteBranch = _upstream ?? new Models.Branch() { Name = Source.Name, Remote = sourceRemote.Name }; + var succ = await new Commands.Push( + _repo.FullPath, + Source, + sourceRemote, + remoteBranch, + false, + _repo.Submodules.Count > 0, + _upstream == null, + false, + false).Use(log).ExecAsync(); + + log.Complete(); + if (!succ) + return false; + } + + if (_isPlatformSelectable) + { + _repo.Settings.PullRequestPlatforms[targetRemote.Name] = _selectedPlatform.Type; + _repo.Settings.Save(); + } + + if (!targetRemote.TryGetVisitURL(out var targetURL) || !sourceRemote.TryGetVisitURL(out var sourceURL)) + return false; + + var sourceBranch = _upstream?.Name ?? Source.Name; + var url = _selectedPlatform.BuildCreateURL(targetURL, sourceURL, sourceBranch, _selectedTarget.Name, _title, _description, out var contentDropped); + if (contentDropped) + { + var content = string.IsNullOrEmpty(_description) ? _title : $"{_title}\n\n{_description}"; + await App.CopyTextAsync(content); + _repo.SendNotification(App.Text("CreatePR.CopiedToClipboard")); + } + + Native.OS.OpenBrowser(url); + return true; + } + + private Models.Remote GetDefaultPushRemote(Models.Branch target) + { + var remotes = _repo.Remotes; + if (remotes.Count == 0) + return null; + + if (!string.IsNullOrEmpty(_repo.Settings.DefaultRemote)) + { + var preferred = remotes.Find(x => x.Name.Equals(_repo.Settings.DefaultRemote, StringComparison.Ordinal)); + if (preferred != null) + return preferred; + } + + // Prefer a remote other than the target's (usually the fork of current user). + if (target != null) + { + var other = remotes.Find(x => !x.Name.Equals(target.Remote, StringComparison.Ordinal)); + if (other != null) + return other; + + return remotes.Find(x => x.Name.Equals(target.Remote, StringComparison.Ordinal)); + } + + return remotes[0]; + } + + private void RefreshTargetBranches() + { + var host = GetHost(_selectedPushRemote); + var branches = new List(); + foreach (var b in _repo.Branches) + { + if (b.IsLocal || b == _upstream) + continue; + + var remote = _repo.Remotes.Find(x => x.Name.Equals(b.Remote, StringComparison.Ordinal)); + if (host == null || host.Equals(GetHost(remote), StringComparison.OrdinalIgnoreCase)) + branches.Add(b); + } + + TargetBranches = branches; + + if (_selectedTarget == null) + return; + + if (!branches.Contains(_selectedTarget)) + SelectedTarget = null; + else + ValidateProperty(_selectedTarget, nameof(SelectedTarget)); + } + + private void SelectDefaultTarget() + { + var remote = _selectedPushRemote?.Name; + if (string.IsNullOrEmpty(remote)) + return; + + Task.Run(async () => + { + var head = await new Commands.QueryRemoteHead(_repo.FullPath, remote).GetResultAsync().ConfigureAwait(false); + Dispatcher.UIThread.Post(() => + { + if (_selectedTarget != null) + return; + + string[] candidates = string.IsNullOrEmpty(head) ? ["main", "master", "develop"] : [head]; + foreach (var name in candidates) + { + var found = _targetBranches.Find(x => x.Remote.Equals(remote, StringComparison.Ordinal) && x.Name.Equals(name, StringComparison.Ordinal)); + if (found != null) + { + SelectedTarget = found; + return; + } + } + }); + }); + } + + private void PostTargetChanged() + { + var targetRemote = _selectedTarget != null ? _repo.Remotes.Find(x => x.Name.Equals(_selectedTarget.Remote, StringComparison.Ordinal)) : null; + if (targetRemote != null) + { + IsPlatformSelectable = targetRemote.TryGetVisitURL(out var url) && + Models.PullRequestPlatform.Detect(new Uri(url).Host) == Models.PullRequestPlatformType.None; + SelectedPlatform = Models.PullRequestPlatform.Resolve(targetRemote, _repo.Settings); + } + + _commits = []; + _commitsVersion++; + + if (_selectedTarget == null) + { + Templates = []; + UpdateDefaultContent(); + return; + } + + var version = _commitsVersion; + var target = _selectedTarget.FullName; + var source = Source.FullName; + var repo = _repo.FullPath; + Task.Run(async () => + { + var commits = await new Commands.QueryCommits(repo, $"--no-merges -n 200 {target}..{source}", false).GetResultAsync().ConfigureAwait(false); + var singleCommitMessage = commits.Count == 1 ? await new Commands.QueryCommitFullMessage(repo, commits[0].SHA).GetResultAsync().ConfigureAwait(false) : null; + var templates = await new Commands.QueryPullRequestTemplates(repo, target).GetResultAsync().ConfigureAwait(false); + if (templates.Count > 0) + templates.Insert(0, new Models.PullRequestTemplate() { Name = App.Text("CreatePR.NoTemplate") }); + + Dispatcher.UIThread.Post(() => + { + if (version != _commitsVersion) + return; + + commits.Reverse(); + _commits = commits; + _singleCommitMessage = singleCommitMessage; + + var selected = _selectedTemplate != null ? templates.Find(x => x.Name.Equals(_selectedTemplate.Name, StringComparison.Ordinal)) : null; + selected ??= templates.Count > 1 ? templates[1] : null; + + Templates = templates; + _selectedTemplate = selected; + OnPropertyChanged(nameof(SelectedTemplate)); + UpdateDefaultContent(); + }); + }); + } + + private void UpdateDefaultContent() + { + if (!_isTitleEdited) + { + SetProperty(ref _title, _commits.Count == 1 ? _commits[0].Subject : HumanizeBranchName(Source.Name), nameof(Title)); + ValidateProperty(_title, nameof(Title)); + } + + if (_isDescriptionEdited) + return; + + var builder = new StringBuilder(); + if (!string.IsNullOrEmpty(_selectedTemplate?.Content)) + builder.Append(_selectedTemplate.Content).Append("\n\n"); + + if (_commits.Count == 1) + { + // Body of the only commit (without its subject). + var msg = _singleCommitMessage ?? string.Empty; + var idx = msg.IndexOf('\n'); + if (idx > 0) + builder.Append(msg.Substring(idx + 1).Trim()); + } + else if (_commits.Count > 1) + { + builder.Append("## Commits\n\n"); + foreach (var c in _commits) + builder.Append("- ").Append(c.Subject).Append('\n'); + } + + _generatedDescription = builder.ToString().Trim(); + SetProperty(ref _description, _generatedDescription, nameof(Description)); + } + + private static string HumanizeBranchName(string name) + { + var idx = name.LastIndexOf('/'); + if (idx >= 0) + name = name.Substring(idx + 1); + + name = name.Replace('-', ' ').Replace('_', ' ').Trim(); + if (name.Length == 0) + return name; + + return char.ToUpper(name[0], CultureInfo.CurrentCulture) + name.Substring(1); + } + + private static string GetRemoteHost(Repository repo, string remoteName) + { + return GetHost(repo.Remotes.Find(x => x.Name.Equals(remoteName, StringComparison.Ordinal))); + } + + private static string GetHost(Models.Remote remote) + { + if (remote == null || !remote.TryGetVisitURL(out var url)) + return null; + + return new Uri(url).Host; + } + + private readonly Repository _repo = null; + private readonly Models.Branch _upstream = null; + private Models.Remote _selectedPushRemote = null; + private List _targetBranches = []; + private Models.Branch _selectedTarget = null; + private bool _isPlatformSelectable = false; + private Models.PullRequestPlatform _selectedPlatform = null; + private List _templates = []; + private Models.PullRequestTemplate _selectedTemplate = null; + private List _commits = []; + private string _singleCommitMessage = null; + private int _commitsVersion = 0; + private string _title = string.Empty; + private string _description = string.Empty; + private string _generatedDescription = string.Empty; + private bool _isTitleEdited = false; + private bool _isDescriptionEdited = false; + } +} diff --git a/src/ViewModels/Fetch.cs b/src/ViewModels/Fetch.cs index 330dd5d763..c790f1836e 100644 --- a/src/ViewModels/Fetch.cs +++ b/src/ViewModels/Fetch.cs @@ -19,11 +19,7 @@ public bool IsFetchAllRemoteVisible public bool FetchAllRemotes { get => _fetchAllRemotes; - set - { - if (SetProperty(ref _fetchAllRemotes, value) && IsFetchAllRemoteVisible) - _repo.UIStates.FetchAllRemotes = value; - } + set => SetProperty(ref _fetchAllRemotes, value); } public Models.Remote SelectedRemote @@ -34,14 +30,19 @@ public Models.Remote SelectedRemote public bool NoTags { - get => _repo.UIStates.FetchWithoutTags; - set => _repo.UIStates.FetchWithoutTags = value; + get => _noTags; + set => SetProperty(ref _noTags, value); } public bool Force { - get => _repo.UIStates.EnableForceOnFetch; - set => _repo.UIStates.EnableForceOnFetch = value; + get => _force; + set => SetProperty(ref _force, value); + } + + public override bool HasDefaultOptions + { + get => true; } public Fetch(Repository repo, Models.Remote preferredRemote = null) @@ -49,6 +50,8 @@ public Fetch(Repository repo, Models.Remote preferredRemote = null) _repo = repo; IsFetchAllRemoteVisible = repo.Remotes.Count > 1 && preferredRemote == null; _fetchAllRemotes = IsFetchAllRemoteVisible && _repo.UIStates.FetchAllRemotes; + _noTags = _repo.UIStates.FetchWithoutTags; + _force = _repo.UIStates.EnableForceOnFetch; CanTerminate = true; if (preferredRemote != null) @@ -66,6 +69,29 @@ public Fetch(Repository repo, Models.Remote preferredRemote = null) } } + public override void SaveOptionsAsDefault() + { + if (IsFetchAllRemoteVisible) + _repo.UIStates.FetchAllRemotes = _fetchAllRemotes; + + _repo.UIStates.FetchWithoutTags = _noTags; + _repo.UIStates.EnableForceOnFetch = _force; + _repo.UIStates.Save(); + } + + public override void ResetDefaultOptions() + { + var defaults = new Models.RepositoryUIStates(); + _repo.UIStates.FetchAllRemotes = defaults.FetchAllRemotes; + _repo.UIStates.FetchWithoutTags = defaults.FetchWithoutTags; + _repo.UIStates.EnableForceOnFetch = defaults.EnableForceOnFetch; + _repo.UIStates.Save(); + + FetchAllRemotes = IsFetchAllRemoteVisible && defaults.FetchAllRemotes; + NoTags = defaults.FetchWithoutTags; + Force = defaults.EnableForceOnFetch; + } + public override async Task Sure() { using var lockWatcher = _repo.LockWatcher(); @@ -74,19 +100,20 @@ public override async Task Sure() _repo.Histories.SelectedCommits.Count == 1 && _repo.Histories.SelectedCommits[0].IsCurrentHead; - var notags = _repo.UIStates.FetchWithoutTags; - var force = _repo.UIStates.EnableForceOnFetch; + var notags = _noTags; + var force = _force; var log = _repo.CreateLog("Fetch"); Use(log); _cancellation = new CancellationTokenSource(); var token = _cancellation.Token; + var succ = true; if (FetchAllRemotes) { foreach (var remote in _repo.Remotes) { - await new Commands.Fetch(_repo.FullPath, remote, notags, force) + succ &= await new Commands.Fetch(_repo.FullPath, remote, notags, force) .WithCancellation(token) .Use(log) .ExecAsync(); @@ -97,7 +124,7 @@ public override async Task Sure() } else { - await new Commands.Fetch(_repo.FullPath, SelectedRemote, notags, force) + succ = await new Commands.Fetch(_repo.FullPath, SelectedRemote, notags, force) .WithCancellation(token) .Use(log) .ExecAsync(); @@ -119,7 +146,7 @@ public override async Task Sure() _repo.MarkFetched(); _cancellation = null; - return true; + return succ; } public override void Terminate() @@ -130,6 +157,8 @@ public override void Terminate() private readonly Repository _repo = null; private bool _fetchAllRemotes = false; + private bool _noTags = false; + private bool _force = false; private CancellationTokenSource _cancellation = null; } } diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 128b53ff9d..edee0c2e7f 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -63,6 +63,19 @@ public bool IsAuthorColumnVisible } } + public bool IsRefsColumnVisible + { + get => _repo.UIStates.IsRefsColumnVisibleInHistory; + set + { + if (_repo.UIStates.IsRefsColumnVisibleInHistory != value) + { + _repo.UIStates.IsRefsColumnVisibleInHistory = value; + OnPropertyChanged(); + } + } + } + public bool IsSHAColumnVisible { get => _repo.UIStates.IsSHAColumnVisibleInHistory; @@ -210,6 +223,21 @@ public double AuthorColumnWidth set => _repo.UIStates.AuthorColumnWidth = value; } + public double RefsColumnWidth + { + get => _repo.UIStates.RefsColumnWidth; + set => _repo.UIStates.RefsColumnWidth = value; + } + + /// + /// Width of the graph column in `Avatars` style. Zero means fit to the graph. + /// + public double GraphColumnWidth + { + get => _repo.UIStates.GraphColumnWidth; + set => _repo.UIStates.GraphColumnWidth = value; + } + public bool IsOpenAsStandaloneVisible { get => DetailContext is CommitDetail or RevisionCompare; diff --git a/src/ViewModels/Popup.cs b/src/ViewModels/Popup.cs index 10bc44fe1b..ab3d055503 100644 --- a/src/ViewModels/Popup.cs +++ b/src/ViewModels/Popup.cs @@ -50,11 +50,24 @@ public void Cleanup() _log?.Unsubscribe(this); } + public virtual bool HasDefaultOptions + { + get => false; + } + public virtual bool CanStartDirectly() { return true; } + public virtual void SaveOptionsAsDefault() + { + } + + public virtual void ResetDefaultOptions() + { + } + public virtual Task Sure() { return null; diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 4803ec4296..fd0cf8a286 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -211,6 +211,12 @@ public bool UseCompactBranchNamesInGraph set => SetProperty(ref _useCompactBranchNamesInGraph, value); } + public Models.CommitGraphStyle CommitGraphStyle + { + get => _commitGraphStyle; + set => SetProperty(ref _commitGraphStyle, value); + } + public bool UseTwoColumnsLayoutInHistories { get => _useTwoColumnsLayoutInHistories; @@ -824,6 +830,7 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private bool _useAutoHideScrollBars = true; private bool _useGitHubStyleAvatar = true; private bool _useCompactBranchNamesInGraph = true; + private Models.CommitGraphStyle _commitGraphStyle = Models.CommitGraphStyle.Classic; private bool _check4UpdatesOnStartup = true; private double _lastCheckUpdateTime = 0; diff --git a/src/ViewModels/Pull.cs b/src/ViewModels/Pull.cs index 7aaf1be4f4..ffe259716a 100644 --- a/src/ViewModels/Pull.cs +++ b/src/ViewModels/Pull.cs @@ -47,14 +47,19 @@ public bool HasLocalChanges public Models.DealWithLocalChanges DealWithLocalChanges { - get; - set; + get => _dealWithLocalChanges; + set => SetProperty(ref _dealWithLocalChanges, value); } public bool UseRebase { - get => _repo.UIStates.PreferRebaseInsteadOfMerge; - set => _repo.UIStates.PreferRebaseInsteadOfMerge = value; + get => _useRebase; + set => SetProperty(ref _useRebase, value); + } + + public override bool HasDefaultOptions + { + get => true; } public Pull(Repository repo, Models.Branch specifiedRemoteBranch) @@ -63,9 +68,8 @@ public Pull(Repository repo, Models.Branch specifiedRemoteBranch) Current = repo.CurrentBranch; CanTerminate = true; - DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? - Models.DealWithLocalChanges.StashAndReapply : - Models.DealWithLocalChanges.DoNothing; + _dealWithLocalChanges = repo.UIStates.DealWithLocalChangesOnPull ?? GetPreferredDealWithLocalChanges(); + _useRebase = repo.UIStates.PreferRebaseInsteadOfMerge; if (specifiedRemoteBranch != null) { @@ -112,6 +116,24 @@ public Pull(Repository repo, Models.Branch specifiedRemoteBranch) } } + public override void SaveOptionsAsDefault() + { + _repo.UIStates.DealWithLocalChangesOnPull = _dealWithLocalChanges; + _repo.UIStates.PreferRebaseInsteadOfMerge = _useRebase; + _repo.UIStates.Save(); + } + + public override void ResetDefaultOptions() + { + var defaults = new Models.RepositoryUIStates(); + _repo.UIStates.DealWithLocalChangesOnPull = defaults.DealWithLocalChangesOnPull; + _repo.UIStates.PreferRebaseInsteadOfMerge = defaults.PreferRebaseInsteadOfMerge; + _repo.UIStates.Save(); + + DealWithLocalChanges = GetPreferredDealWithLocalChanges(); + UseRebase = defaults.PreferRebaseInsteadOfMerge; + } + public override async Task Sure() { using var lockWatcher = _repo.LockWatcher(); @@ -189,6 +211,13 @@ public override void Terminate() var _ = _cancellation?.CancelAsync(); } + private static Models.DealWithLocalChanges GetPreferredDealWithLocalChanges() + { + return Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; + } + private void PostRemoteSelected() { var remoteName = _selectedRemote.Name; @@ -214,6 +243,8 @@ private void PostRemoteSelected() private Models.Remote _selectedRemote = null; private List _remoteBranches = null; private Models.Branch _selectedBranch = null; + private Models.DealWithLocalChanges _dealWithLocalChanges = Models.DealWithLocalChanges.DoNothing; + private bool _useRebase = true; private CancellationTokenSource _cancellation = null; } } diff --git a/src/ViewModels/Push.cs b/src/ViewModels/Push.cs index ee7b660ac4..0274529d7d 100644 --- a/src/ViewModels/Push.cs +++ b/src/ViewModels/Push.cs @@ -87,14 +87,14 @@ public bool IsCheckSubmodulesVisible public bool CheckSubmodules { - get; - set; - } = true; + get => _checkSubmodules; + set => SetProperty(ref _checkSubmodules, value); + } public bool PushAllTags { - get => _repo.UIStates.PushAllTags; - set => _repo.UIStates.PushAllTags = value; + get => _pushAllTags; + set => SetProperty(ref _pushAllTags, value); } public bool ForcePush @@ -105,13 +105,22 @@ public bool ForcePush public bool NoVerify { - get; - set; + get => _noVerify; + set => SetProperty(ref _noVerify, value); + } + + public override bool HasDefaultOptions + { + get => true; } public Push(Repository repo, Models.Branch localBranch) { _repo = repo; + _tracking = repo.UIStates.SetTrackingOnPush; + _checkSubmodules = repo.UIStates.CheckSubmodulesOnPush; + _pushAllTags = repo.UIStates.PushAllTags; + _noVerify = repo.UIStates.NoVerifyOnPush; CanTerminate = true; // Gather all local branches and find current branch. @@ -162,6 +171,30 @@ public void PushToNewBranch(string name) SelectedRemoteBranch = fake; } + public override void SaveOptionsAsDefault() + { + _repo.UIStates.SetTrackingOnPush = _tracking; + _repo.UIStates.CheckSubmodulesOnPush = _checkSubmodules; + _repo.UIStates.PushAllTags = _pushAllTags; + _repo.UIStates.NoVerifyOnPush = _noVerify; + _repo.UIStates.Save(); + } + + public override void ResetDefaultOptions() + { + var defaults = new Models.RepositoryUIStates(); + _repo.UIStates.SetTrackingOnPush = defaults.SetTrackingOnPush; + _repo.UIStates.CheckSubmodulesOnPush = defaults.CheckSubmodulesOnPush; + _repo.UIStates.PushAllTags = defaults.PushAllTags; + _repo.UIStates.NoVerifyOnPush = defaults.NoVerifyOnPush; + _repo.UIStates.Save(); + + Tracking = defaults.SetTrackingOnPush; + CheckSubmodules = defaults.CheckSubmodulesOnPush; + PushAllTags = defaults.PushAllTags; + NoVerify = defaults.NoVerifyOnPush; + } + public override bool CanStartDirectly() { return !string.IsNullOrEmpty(_selectedRemoteBranch?.Head); @@ -357,6 +390,9 @@ private bool IsPreferredPushRemote(Models.Remote remote) private Models.Branch _selectedRemoteBranch = null; private bool _isSetTrackOptionVisible = false; private bool _tracking = false; + private bool _checkSubmodules = true; + private bool _pushAllTags = false; + private bool _noVerify = false; private CancellationTokenSource _cancellation = null; } } diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index aa58226d97..90c098932f 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -399,6 +399,42 @@ public bool IsAutoFetching private set => SetProperty(ref _isAutoFetching, value); } + public bool IsFetching + { + get => _isFetching; + private set => SetProperty(ref _isFetching, value); + } + + public bool IsFetchSucceeded + { + get => _isFetchSucceeded; + private set => SetProperty(ref _isFetchSucceeded, value); + } + + public bool IsPulling + { + get => _isPulling; + private set => SetProperty(ref _isPulling, value); + } + + public bool IsPullSucceeded + { + get => _isPullSucceeded; + private set => SetProperty(ref _isPullSucceeded, value); + } + + public bool IsPushing + { + get => _isPushing; + private set => SetProperty(ref _isPushing, value); + } + + public bool IsPushSucceeded + { + get => _isPushSucceeded; + private set => SetProperty(ref _isPushSucceeded, value); + } + public AvaloniaList IssueTrackers { get; @@ -494,7 +530,7 @@ public bool CanCreatePopup() if (page == null) return false; - return !_isAutoFetching && page.CanCreatePopup(); + return !_isAutoFetching && !_isFetching && !_isPulling && !_isPushing && page.CanCreatePopup(); } public void ShowPopup(Popup popup) @@ -662,7 +698,7 @@ public async Task FetchAsync(bool autoStart) } if (autoStart) - await ShowAndStartPopupAsync(new Fetch(this)); + await RunPopupInBackgroundAsync(new Fetch(this), v => IsFetching = v, v => IsFetchSucceeded = v); else ShowPopup(new Fetch(this)); } @@ -685,8 +721,8 @@ public async Task PullAsync(bool autoStart) } var pull = new Pull(this, null); - if (autoStart && pull.SelectedBranch != null) - await ShowAndStartPopupAsync(pull); + if (autoStart) + await RunPopupInBackgroundAsync(pull, v => IsPulling = v, v => IsPullSucceeded = v); else ShowPopup(pull); } @@ -709,7 +745,7 @@ public async Task PushAsync(bool autoStart) } if (autoStart) - await ShowAndStartPopupAsync(new Push(this, null)); + await RunPopupInBackgroundAsync(new Push(this, null), v => IsPushing = v, v => IsPushSucceeded = v); else ShowPopup(new Push(this, null)); } @@ -1888,6 +1924,46 @@ private void AutoFetchByTimer(object sender) } } + private async Task RunPopupInBackgroundAsync(Popup popup, Action setRunning, Action setSucceeded) + { + // Falls back to the popup when the options need user input. + if (!popup.CanStartDirectly() || !popup.Check()) + { + ShowPopup(popup); + return; + } + + setSucceeded(false); + setRunning(true); + + var succ = false; + try + { + succ = await popup.Sure(); + } + catch (Exception e) + { + Native.OS.LogException(e); + SendNotification(e.Message, true); + } + + popup.Cleanup(); + setRunning(false); + + if (succ) + { + // Briefly show the success badge on the toolbar button. + var key = popup.GetType(); + var version = _backgroundSuccessVersions.GetValueOrDefault(key) + 1; + _backgroundSuccessVersions[key] = version; + + setSucceeded(true); + await Task.Delay(3000); + if (_backgroundSuccessVersions[key] == version) + setSucceeded(false); + } + } + private async Task AutoFetchOnUIThread() { if (IsAutoFetching) @@ -1970,6 +2046,13 @@ private async Task AutoFetchOnUIThread() private string _navigateToCommitDelayed = string.Empty; private bool _isAutoFetching = false; + private bool _isFetching = false; + private bool _isPulling = false; + private bool _isPushing = false; + private bool _isFetchSucceeded = false; + private bool _isPullSucceeded = false; + private bool _isPushSucceeded = false; + private readonly Dictionary _backgroundSuccessVersions = new(); private Timer _autoFetchTimer = null; private DateTime _lastFetchTime = DateTime.MinValue; diff --git a/src/Views/Avatar.cs b/src/Views/Avatar.cs index 82fa0a8473..6ab95d641d 100644 --- a/src/Views/Avatar.cs +++ b/src/Views/Avatar.cs @@ -68,31 +68,7 @@ public override void Render(DrawingContext context) } else if (!_useGitHubStyleAvatar) { - var fallback = GetFallbackString(_user.Name); - var typeface = new Typeface("fonts:SourceGit#JetBrains Mono NL"); - var label = new FormattedText( - fallback, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - Math.Max(Bounds.Width * 0.65, 10), - Brushes.White); - - var chars = fallback.ToCharArray(); - var sum = 0; - foreach (var c in chars) - sum += Math.Abs(c); - - var bg = new LinearGradientBrush() - { - GradientStops = FALLBACK_GRADIENTS[sum % FALLBACK_GRADIENTS.Length], - StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), - EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), - }; - - Point textOrigin = new Point((Bounds.Width - label.Width) * 0.5, (Bounds.Height - label.Height) * 0.5); - context.DrawRectangle(bg, null, new Rect(0, 0, Bounds.Width, Bounds.Height), corner, corner); - context.DrawText(label, textOrigin); + DrawFallback(context, _user.Name, rect, corner); } else { @@ -275,7 +251,36 @@ private void OnContextRequested(object sender, ContextRequestedEventArgs e) menu.Open(this); } - private string GetFallbackString(string name) + public static void DrawFallback(DrawingContext context, string name, Rect rect, double corner) + { + var fallback = GetFallbackString(name); + var typeface = new Typeface("fonts:SourceGit#JetBrains Mono NL"); + var label = new FormattedText( + fallback, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + Math.Max(rect.Width * 0.65, 10), + Brushes.White); + + var chars = fallback.ToCharArray(); + var sum = 0; + foreach (var c in chars) + sum += Math.Abs(c); + + var bg = new LinearGradientBrush() + { + GradientStops = FALLBACK_GRADIENTS[sum % FALLBACK_GRADIENTS.Length], + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }; + + var textOrigin = new Point(rect.X + (rect.Width - label.Width) * 0.5, rect.Y + (rect.Height - label.Height) * 0.5); + context.DrawRectangle(bg, null, rect, corner, corner); + context.DrawText(label, textOrigin); + } + + private static string GetFallbackString(string name) { if (string.IsNullOrWhiteSpace(name)) return "?"; diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index 35d313e3a2..6451c313ce 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -1027,22 +1027,19 @@ private ContextMenu CreateContextMenuForLocalBranch(ViewModels.Repository repo, menu.Items.Add(createBranch); menu.Items.Add(createTag); - if (upstream != null) + if (ViewModels.CreatePullRequest.CanCreateForUpstream(repo, upstream)) { - var remote = repo.Remotes.Find(x => x.Name.Equals(upstream.Remote, StringComparison.Ordinal)); - if (remote != null && remote.TryGetCreatePullRequestURL(out var prURL, upstream.Name)) + var createPR = new MenuItem(); + createPR.Header = App.Text("BranchCM.CreatePRForUpstream", upstream.FriendlyName); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => { - var createPR = new MenuItem(); - createPR.Header = App.Text("BranchCM.CreatePRForUpstream", upstream.FriendlyName); - createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); - createPR.Click += (_, e) => - { - Native.OS.OpenBrowser(prURL); - e.Handled = true; - }; + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, branch, null)); + e.Handled = true; + }; - menu.Items.Add(createPR); - } + menu.Items.Add(createPR); } menu.Items.Add(new MenuItem() { Header = "-" }); TryToAddCustomActionsToBranchContextMenu(repo, menu, branch); @@ -1352,15 +1349,17 @@ public ContextMenu CreateContextMenuForRemoteBranch(ViewModels.Repository repo, menu.Items.Add(createBranch); menu.Items.Add(createTag); - var remote = repo.Remotes.Find(x => x.Name.Equals(branch.Remote, StringComparison.Ordinal)); - if (remote != null && remote.TryGetCreatePullRequestURL(out var prURL, branch.Name)) + var head = repo.CurrentBranch; + if (ViewModels.CreatePullRequest.CanCreateInto(repo, head, branch, out var isUpstreamOfHead)) { + // Clicking the upstream of current branch creates a PR for it, otherwise creates a PR from current branch into the clicked one. var createPR = new MenuItem(); - createPR.Header = App.Text("BranchCM.CreatePR"); + createPR.Header = isUpstreamOfHead ? App.Text("BranchCM.CreatePRForUpstream", name) : App.Text("BranchCM.CreatePR", head.Name, name); createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); createPR.Click += (_, e) => { - Native.OS.OpenBrowser(prURL); + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, head, isUpstreamOfHead ? null : branch)); e.Handled = true; }; diff --git a/src/Views/CommitGraph.cs b/src/Views/CommitGraph.cs index 88a8217c6b..1f90282187 100644 --- a/src/Views/CommitGraph.cs +++ b/src/Views/CommitGraph.cs @@ -1,10 +1,15 @@ -using Avalonia; +using System; +using System.Collections.Generic; + +using Avalonia; using Avalonia.Controls; +using Avalonia.Interactivity; using Avalonia.Media; +using Avalonia.Media.Imaging; namespace SourceGit.Views { - public class CommitGraph : Control + public class CommitGraph : Control, Models.IAvatarHost { public static readonly DirectProperty GraphProperty = AvaloniaProperty.RegisterDirect( @@ -39,6 +44,70 @@ public IBrush DotBrush set => SetValue(DotBrushProperty, value); } + public static readonly StyledProperty GraphStyleProperty = + AvaloniaProperty.Register(nameof(GraphStyle)); + + public Models.CommitGraphStyle GraphStyle + { + get => GetValue(GraphStyleProperty); + set => SetValue(GraphStyleProperty, value); + } + + public static readonly StyledProperty ShowRefConnectorsProperty = + AvaloniaProperty.Register(nameof(ShowRefConnectors)); + + public bool ShowRefConnectors + { + get => GetValue(ShowRefConnectorsProperty); + set => SetValue(ShowRefConnectorsProperty, value); + } + + public static readonly StyledProperty ShowTagsProperty = + AvaloniaProperty.Register(nameof(ShowTags), true); + + public bool ShowTags + { + get => GetValue(ShowTagsProperty); + set => SetValue(ShowTagsProperty, value); + } + + /// + /// Width required to draw the given graph without clipping in `Avatars` style. + /// + public static double GetAvatarsStyleWidth(Models.CommitGraph graph) + { + if (graph == null || graph.Width <= 0) + return 0; + + // `Width` is the right edge of the last lane plus 8px padding (in classic units). + return MapX(graph.Width - 8, AVATARS_LANE_SCALE) + AVATAR_RADIUS + 6; + } + + /// + /// X position of a graph node (generated in classic units) in `Avatars` style. + /// + public static double GetAvatarsStyleX(double x) + { + return MapX(x, AVATARS_LANE_SCALE); + } + + public static bool HasVisibleRefs(Models.Commit commit, bool showTags) + { + if (commit == null) + return false; + + if (showTags) + return commit.HasDecorators; + + foreach (var d in commit.Decorators) + { + if (d.Type != Models.DecoratorType.Tag) + return true; + } + + return false; + } + public override void Render(DrawingContext context) { base.Render(context); @@ -47,30 +116,73 @@ public override void Render(DrawingContext context) return; var startY = _layout.StartY; + var offsetX = _layout.OffsetX; var clipWidth = _layout.ClipWidth; var clipHeight = Bounds.Height; var rowHeight = _layout.RowHeight; var endY = startY + clipHeight + 28; + var isAvatars = GraphStyle == Models.CommitGraphStyle.Avatars; + var scale = isAvatars ? AVATARS_LANE_SCALE : 1.0; - using (context.PushClip(new Rect(0, 0, clipWidth, clipHeight))) - using (context.PushTransform(Matrix.CreateTranslation(0, -startY))) + using (context.PushClip(new Rect(offsetX, 0, clipWidth, clipHeight))) + using (context.PushTransform(Matrix.CreateTranslation(offsetX, -startY))) { - DrawCurves(context, _graph, startY, endY, rowHeight); - DrawAnchors(context, _graph, startY, endY, rowHeight); + if (isAvatars) + { + DrawRowDecorations(context, _graph, startY, endY, rowHeight, clipWidth); + DrawRoundedLines(context, _graph, startY, endY, rowHeight, scale); + DrawAvatarAnchors(context, _graph, startY, endY, rowHeight); + } + else + { + DrawCurves(context, _graph, startY, endY, rowHeight, scale); + DrawAnchors(context, _graph, startY, endY, rowHeight); + } } } + public void OnAvatarResourceChanged(string email, Bitmap image) + { + if (_avatars.ContainsKey(email)) + { + _avatars[email] = image; + InvalidateVisual(); + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + Models.AvatarManager.Instance.Subscribe(this); + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + Models.AvatarManager.Instance.Unsubscribe(this); + _avatars.Clear(); + } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == GraphProperty || change.Property == LayoutProperty || - change.Property == DotBrushProperty) + change.Property == DotBrushProperty || + change.Property == GraphStyleProperty || + change.Property == ShowRefConnectorsProperty || + change.Property == ShowTagsProperty) InvalidateVisual(); } - private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight) + private static double MapX(double x, double scale) + { + // Graph is generated with 12px lanes starting at x = 4. Stretch lanes horizontally for other styles. + return scale == 1.0 ? x : 4 + (x - 4) * scale; + } + + private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight, double scale) { var grayedPen = new Pen(new SolidColorBrush(Colors.Gray, 0.4), Models.CommitGraph.Pens[0].Thickness); @@ -87,8 +199,8 @@ private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double var geo = new StreamGeometry(); using (var ctx = geo.Open()) { - ctx.BeginFigure(new Point(link.Start.X, startY), false); - ctx.QuadraticBezierTo(new Point(link.Control.X, link.Control.Y * rowHeight), new Point(link.End.X, endY)); + ctx.BeginFigure(new Point(MapX(link.Start.X, scale), startY), false); + ctx.QuadraticBezierTo(new Point(MapX(link.Control.X, scale), link.Control.Y * rowHeight), new Point(MapX(link.End.X, scale), endY)); } var pen = link.IsHighlighted ? Models.CommitGraph.Pens[link.Color] : grayedPen; @@ -97,7 +209,7 @@ private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double foreach (var line in graph.Paths) { - var last = new Point(line.Points[0].X, line.Points[0].Y * rowHeight); + var last = new Point(MapX(line.Points[0].X, scale), line.Points[0].Y * rowHeight); var size = line.Points.Count; var endY = line.Points[size - 1].Y * rowHeight; @@ -115,7 +227,7 @@ private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double var ended = false; for (int i = 1; i < size; i++) { - var cur = new Point(line.Points[i].X, line.Points[i].Y * rowHeight); + var cur = new Point(MapX(line.Points[i].X, scale), line.Points[i].Y * rowHeight); if (cur.Y < top) { last = cur; @@ -165,6 +277,146 @@ private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double } } + /// + /// Same as `DrawCurves`, but uses horizontal/vertical lines with rounded corners instead of splines. + /// + private void DrawRoundedLines(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight, double scale) + { + var grayedPen = new Pen(new SolidColorBrush(Colors.Gray, 0.4), Models.CommitGraph.Pens[0].Thickness); + var vertices = new List(); + + foreach (var link in graph.Links) + { + var startY = link.Start.Y * rowHeight; + var endY = link.End.Y * rowHeight; + + if (endY < top) + continue; + if (startY > bottom) + break; + + // Leave the merge commit horizontally, then turn down into the parent's lane. + var start = new Point(MapX(link.Start.X, scale), startY); + var end = new Point(MapX(link.End.X, scale), endY); + vertices.Clear(); + vertices.Add(start); + vertices.Add(new Point(end.X, start.Y)); + vertices.Add(end); + + var pen = link.IsHighlighted ? Models.CommitGraph.Pens[link.Color] : grayedPen; + DrawRoundedPolyline(context, pen, vertices); + } + + foreach (var line in graph.Paths) + { + var last = new Point(MapX(line.Points[0].X, scale), line.Points[0].Y * rowHeight); + var size = line.Points.Count; + var endY = line.Points[size - 1].Y * rowHeight; + + if (endY < top) + continue; + if (last.Y > bottom) + break; + + vertices.Clear(); + for (int i = 1; i < size; i++) + { + var cur = new Point(MapX(line.Points[i].X, scale), line.Points[i].Y * rowHeight); + if (cur.Y < top) + { + last = cur; + continue; + } + + if (vertices.Count == 0) + vertices.Add(last); + + if (cur.X > last.X) + { + vertices.Add(new Point(cur.X, last.Y)); + } + else if (cur.X < last.X) + { + if (i < size - 1) + { + var midY = (last.Y + cur.Y) / 2; + vertices.Add(new Point(last.X, midY)); + vertices.Add(new Point(cur.X, midY)); + } + else + { + vertices.Add(new Point(last.X, cur.Y)); + } + } + + vertices.Add(cur); + + if (cur.Y > bottom) + break; + last = cur; + } + + var pen = line.IsHighlighted ? Models.CommitGraph.Pens[line.Color] : grayedPen; + DrawRoundedPolyline(context, pen, vertices); + } + } + + private static void DrawRoundedPolyline(DrawingContext context, Pen pen, List vertices) + { + // Drop duplicated and collinear vertices, so only real corners remain. + var points = new List(vertices.Count); + foreach (var v in vertices) + { + if (points.Count > 0 && Math.Abs(points[^1].X - v.X) < 0.01 && Math.Abs(points[^1].Y - v.Y) < 0.01) + continue; + + if (points.Count > 1) + { + var a = points[^2]; + var b = points[^1]; + var cross = (b.X - a.X) * (v.Y - b.Y) - (b.Y - a.Y) * (v.X - b.X); + if (Math.Abs(cross) < 0.01) + points.RemoveAt(points.Count - 1); + } + + points.Add(v); + } + + if (points.Count < 2) + return; + + var geo = new StreamGeometry(); + using (var ctx = geo.Open()) + { + ctx.BeginFigure(points[0], false); + + var lastIdx = points.Count - 1; + for (var i = 1; i < lastIdx; i++) + { + var prev = points[i - 1]; + var corner = points[i]; + var next = points[i + 1]; + + var inLen = Math.Sqrt((corner.X - prev.X) * (corner.X - prev.X) + (corner.Y - prev.Y) * (corner.Y - prev.Y)); + var outLen = Math.Sqrt((next.X - corner.X) * (next.X - corner.X) + (next.Y - corner.Y) * (next.Y - corner.Y)); + + // Segments between two corners are shared, so each corner may only use half of it. + var inMax = i == 1 ? inLen : inLen * 0.5; + var outMax = i + 1 == lastIdx ? outLen : outLen * 0.5; + var radius = Math.Min(CORNER_RADIUS, Math.Min(inMax, outMax)); + + var from = new Point(corner.X + (prev.X - corner.X) / inLen * radius, corner.Y + (prev.Y - corner.Y) / inLen * radius); + var to = new Point(corner.X + (next.X - corner.X) / outLen * radius, corner.Y + (next.Y - corner.Y) / outLen * radius); + ctx.LineTo(from); + ctx.QuadraticBezierTo(corner, to); + } + + ctx.LineTo(points[lastIdx]); + } + + context.DrawGeometry(null, pen, geo); + } + private void DrawAnchors(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight) { var dotFill = DotBrush; @@ -199,7 +451,103 @@ private void DrawAnchors(DrawingContext context, Models.CommitGraph graph, doubl } } + private void DrawRowDecorations(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight, double clipWidth) + { + var showConnectors = ShowRefConnectors; + var showTags = ShowTags; + var grayedPen = new Pen(new SolidColorBrush(Colors.Gray, 0.4), Models.CommitGraph.Pens[0].Thickness); + var bandHeight = Math.Max(rowHeight - 6, 4); + + foreach (var dot in graph.Dots) + { + var x = MapX(dot.Center.X, AVATARS_LANE_SCALE); + var y = dot.Center.Y * rowHeight; + + if (y < top) + continue; + if (y > bottom) + break; + + var pen = dot.IsHighlighted ? Models.CommitGraph.Pens[dot.Color] : grayedPen; + + // Translucent band from the node to the commit message column. + if (clipWidth > x) + { + using (context.PushOpacity(dot.IsHighlighted ? 0.12 : 0.05)) + context.FillRectangle(pen.Brush, new Rect(x, y - bandHeight * 0.5, clipWidth - x, bandHeight)); + } + + // Connector from the BRANCH / TAG column to the node. + if (showConnectors && HasVisibleRefs(dot.Commit, showTags)) + context.DrawLine(pen, new Point(0, y), new Point(x, y)); + } + } + + private void DrawAvatarAnchors(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight) + { + var dotFill = DotBrush; + var grayedBrush = Brushes.Gray; + + foreach (var dot in graph.Dots) + { + var center = new Point(MapX(dot.Center.X, AVATARS_LANE_SCALE), dot.Center.Y * rowHeight); + + if (center.Y < top) + continue; + if (center.Y > bottom) + break; + + var brush = dot.IsHighlighted ? Models.CommitGraph.Pens[dot.Color].Brush : grayedBrush; + + // Merge commits do not have an avatar. + if (dot.Type == Models.CommitGraph.DotType.Merge) + { + context.DrawEllipse(brush, null, center, MERGE_DOT_RADIUS, MERGE_DOT_RADIUS); + continue; + } + + if (dot.Type == Models.CommitGraph.DotType.Head) + context.DrawEllipse(dotFill, new Pen(brush, 1.5), center, AVATAR_RADIUS + 2.5, AVATAR_RADIUS + 2.5); + + context.DrawEllipse(brush, null, center, AVATAR_RADIUS, AVATAR_RADIUS); + + var inner = AVATAR_RADIUS - 2; + var rect = new Rect(center.X - inner, center.Y - inner, inner * 2, inner * 2); + var author = dot.Commit?.Author; + using (context.PushOpacity(dot.IsHighlighted ? 1.0 : 0.4)) + using (context.PushGeometryClip(new EllipseGeometry(rect))) + { + var img = author != null ? GetAvatar(author.Email) : null; + if (img != null) + context.DrawImage(img, rect); + else + Avatar.DrawFallback(context, author?.Name, rect, inner); + } + } + } + + private Bitmap GetAvatar(string email) + { + if (string.IsNullOrEmpty(email)) + return null; + + // Only ask `AvatarManager` once per email. Updates are pushed back via `OnAvatarResourceChanged`. + if (!_avatars.TryGetValue(email, out var img)) + { + img = Models.AvatarManager.Instance.Request(email, false); + _avatars.Add(email, img); + } + + return img; + } + + private const double AVATARS_LANE_SCALE = 2.0; + private const double AVATAR_RADIUS = 10.0; + private const double MERGE_DOT_RADIUS = 5.0; + private const double CORNER_RADIUS = 8.0; + private Models.CommitGraph _graph = null; private Models.CommitGraphLayout _layout = null; + private Dictionary _avatars = new Dictionary(); } } diff --git a/src/Views/CommitRefsPresenter.cs b/src/Views/CommitRefsPresenter.cs index 85e33962a1..06454ffeac 100644 --- a/src/Views/CommitRefsPresenter.cs +++ b/src/Views/CommitRefsPresenter.cs @@ -73,6 +73,8 @@ public class RenderItem public IBrush Brush { get; set; } = null; public bool IsHead { get; set; } = false; public double Width { get; set; } = 0.0; + public double LabelWidth { get; set; } = 0.0; + public bool IsLabelTrimmed { get; set; } = false; public List Remotes { get; set; } = []; } @@ -157,11 +159,47 @@ public bool ShowTags set => SetValue(ShowTagsProperty, value); } + public static readonly StyledProperty FitToWidthProperty = + AvaloniaProperty.Register(nameof(FitToWidth)); + + /// + /// Only shows the refs that fit in the available width, followed by a `+N` badge. + /// + public bool FitToWidth + { + get => GetValue(FitToWidthProperty); + set => SetValue(FitToWidthProperty, value); + } + + public static readonly StyledProperty ShowConnectorProperty = + AvaloniaProperty.Register(nameof(ShowConnector)); + + /// + /// Draws a line from the last ref to the right edge (towards the commit node in graph). + /// + public bool ShowConnector + { + get => GetValue(ShowConnectorProperty); + set => SetValue(ShowConnectorProperty, value); + } + + public Rect? OverflowBadgeBounds + { + get => _overflowRect; + } + + public bool IsOverflowBadgeAt(Point point) + { + return _overflowRect.HasValue && _overflowRect.Value.Inflate(2).Contains(point); + } + public Models.Decorator DecoratorAt(Point point) { var x = 0.0; - foreach (var item in _items) + var count = FitToWidth ? _visibleCount : _items.Count; + for (var i = 0; i < count; i++) { + var item = _items[i]; x += item.Width; if (point.X < x) return item.Decorator; @@ -182,12 +220,15 @@ public override void Render(DrawingContext context) var x = 1.5; var y = 0.5; var remoteIcon = CommitRefsIconCache.Instance.GetIcon(Models.DecoratorType.RemoteBranchHead); - var hasSingleRemote = HasSingleRemote; + var hasSingleRemote = HasSingleRemote || FitToWidth; - context.FillRectangle(Brushes.Transparent, Bounds); + var count = FitToWidth ? _visibleCount : _items.Count; - foreach (var item in _items) + context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size)); + + for (var i = 0; i < count; i++) { + var item = _items[i]; if (allowWrap && x > 1.5 && x + item.Width > Bounds.Width) { x = 1.5; @@ -250,6 +291,34 @@ public override void Render(DrawingContext context) x += item.Width + 4; } + + _overflowRect = null; + if (count < _items.Count && _overflowLabel != null) + { + var badgeRect = new RoundedRect(new Rect(x, y, _overflowLabel.Width + 8, 16), new CornerRadius(4)); + _overflowRect = badgeRect.Rect; + if (bg != null) + context.DrawRectangle(bg, null, badgeRect); + context.DrawRectangle(null, new Pen(Brushes.Gray), badgeRect); + context.DrawText(_overflowLabel, new Point(x + 4, y + 8.0 - _overflowLabel.Height * 0.5)); + x += badgeRect.Rect.Width + 4; + } + + if (ShowConnector && DataContext is Models.Commit commit && x < Bounds.Width) + { + var graphPen = Models.CommitGraph.Pens[commit.Color]; + var pen = useGraphColor ? graphPen : new Pen(new SolidColorBrush(Colors.Gray, 0.4), graphPen.Thickness); + var cy = y - 0.5 + 8.0; + context.DrawLine(pen, new Point(x - 4, cy), new Point(Bounds.Width, cy)); + } + } + + protected override Size ArrangeOverride(Size finalSize) + { + if (FitToWidth) + UpdateVisibleItems(finalSize.Width); + + return base.ArrangeOverride(finalSize); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) @@ -263,8 +332,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang change.Property == UseCompactBranchNamesProperty || change.Property == HasSingleRemoteProperty || change.Property == BackgroundProperty || - change.Property == ShowTagsProperty) + change.Property == ShowTagsProperty || + change.Property == FitToWidthProperty) InvalidateMeasure(); + else if (change.Property == ShowConnectorProperty) + InvalidateVisual(); } protected override void OnDataContextChanged(EventArgs e) @@ -289,7 +361,7 @@ protected override Size MeasureOverride(Size availableSize) } var useCompactBranchNames = UseCompactBranchNames; - var hasSingleRemote = HasSingleRemote; + var hasSingleRemote = HasSingleRemote || FitToWidth; var typeface = new Typeface(FontFamily); var typefaceHead = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold); var typefaceRemote = new Typeface(FontFamily, FontStyle.Italic, FontWeight.Bold); @@ -340,6 +412,7 @@ protected override Size MeasureOverride(Size availableSize) fg); } + item.LabelWidth = item.Label.Width; item.Width = item.Label.Width + 24; var findRemotes = useCompactBranchNames && (decorator.Type == Models.DecoratorType.CurrentBranchHead || decorator.Type == Models.DecoratorType.LocalBranchHead); @@ -368,8 +441,12 @@ protected override Size MeasureOverride(Size availableSize) item.Remotes.Add(remote); + // Only one remote icon is drawn in this mode, whatever the number of remotes. if (hasSingleRemote) - item.Width += 18; + { + if (item.Remotes.Count == 1) + item.Width += 18; + } else item.Width += remote.WidthIncludingTrailingWhitespace + 22; @@ -392,7 +469,9 @@ protected override Size MeasureOverride(Size availableSize) double requiredWidth = 0; if (_items.Count > 0) { - if (allowWrap && requiredHeight > 16.0) + if (FitToWidth && !double.IsInfinity(availableSize.Width)) + requiredWidth = Math.Min(x + 2, availableSize.Width); + else if (allowWrap && requiredHeight > 16.0) requiredWidth = double.IsInfinity(availableSize.Width) ? x + 2 : availableSize.Width; else requiredWidth = x + 2; @@ -402,6 +481,81 @@ protected override Size MeasureOverride(Size availableSize) return new Size(requiredWidth, requiredHeight); } + private void UpdateVisibleItems(double width) + { + var count = _items.Count; + var visible = count; + var x = 1.5; + + // Restore labels that may have been trimmed by previous arrange. + foreach (var item in _items) + { + if (item.IsLabelTrimmed) + { + var trimmedWidth = item.Label.Width; + item.Label.MaxTextWidth = item.LabelWidth + 1; + item.Label.Trimming = TextTrimming.None; + item.Width += item.LabelWidth - trimmedWidth; + item.IsLabelTrimmed = false; + } + } + + // Trim the label of the first ref when it does not fit, so the connector is still visible. + if (count > 0) + { + var first = _items[0]; + var reserved = count > 1 ? MeasureOverflowBadge(count - 1).Width + 12 : 0; + var overflow = 1.5 + first.Width + reserved + 12 - width; + if (overflow > 0) + { + var labelWidth = Math.Max(first.LabelWidth - overflow, 24); + if (labelWidth < first.LabelWidth) + { + first.Label.MaxTextWidth = labelWidth; + first.Label.MaxLineCount = 1; + first.Label.Trimming = TextTrimming.CharacterEllipsis; + first.Width -= first.LabelWidth - first.Label.Width; + first.IsLabelTrimmed = true; + } + } + } + + for (var i = 0; i < count; i++) + { + var remains = count - i - 1; + var reserved = remains > 0 ? MeasureOverflowBadge(remains).Width + 12 : 0; + if (i > 0 && x + _items[i].Width + reserved > width) + { + visible = i; + break; + } + + x += _items[i].Width + 4; + } + + _visibleCount = visible; + _overflowLabel = visible < count ? MeasureOverflowBadge(count - visible) : null; + + // Show the full name of a trimmed ref on hover. + var trimmed = count > 0 && _items[0].IsLabelTrimmed ? _items[0].Decorator.Name : null; + if (!Equals(ToolTip.GetTip(this), trimmed)) + ToolTip.SetTip(this, trimmed); + } + + private FormattedText MeasureOverflowBadge(int hidden) + { + return new FormattedText( + $"+{hidden}", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(FontFamily), + FontSize, + Foreground); + } + private List _items = new List(); + private int _visibleCount = 0; + private FormattedText _overflowLabel = null; + private Rect? _overflowRect = null; } } diff --git a/src/Views/CreatePullRequest.axaml b/src/Views/CreatePullRequest.axaml new file mode 100644 index 0000000000..4b62778471 --- /dev/null +++ b/src/Views/CreatePullRequest.axaml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CreatePullRequest.axaml.cs b/src/Views/CreatePullRequest.axaml.cs new file mode 100644 index 0000000000..13ec296151 --- /dev/null +++ b/src/Views/CreatePullRequest.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class CreatePullRequest : UserControl + { + public CreatePullRequest() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index a3d24529fd..342507d4a0 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -26,13 +26,14 @@ @@ -55,6 +56,14 @@ + + + @@ -103,16 +112,111 @@ - + + + + + + + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HasSingleRemote="{Binding $parent[v:Histories].HasSingleRemote}"> + + + + + + + - + @@ -185,7 +296,7 @@ - + @@ -202,7 +313,7 @@ - + @@ -222,7 +333,7 @@ - + @@ -247,7 +358,8 @@ HorizontalAlignment="Stretch" VerticalAlignment="Top" PointerMoved="OnCommitListHeaderPointerMoved" PointerPressed="OnCommitListHeaderPointerPressed" - PointerReleased="OnCommitListHeaderPointerReleased"> + PointerReleased="OnCommitListHeaderPointerReleased" + ContextRequested="OnCommitListHeaderContextRequested"> -4) + var resizable = GetResizableColumnAt(pos.X) >= 0; + if (resizable) { if (border.Cursor != _resizingCursor) border.Cursor = _resizingCursor; @@ -506,21 +528,144 @@ private void OnCommitListHeaderPointerPressed(object sender, PointerPressedEvent if (sender is not Border border) return; - var pos = e.GetPosition(border); - var dis = CommitListContainer.Columns[0].ActualWidth - 4 - pos.X; - if (dis > 4 || dis < -4) + var col = GetResizableColumnAt(e.GetPosition(border).X); + if (col < 0) return; if (e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) { - _resizingAuthorColumn = true; + _resizingColumn = col; e.Handled = true; } } private void OnCommitListHeaderPointerReleased(object sender, PointerReleasedEventArgs e) { - _resizingAuthorColumn = false; + _resizingColumn = -1; + } + + private void OnCommitListHeaderContextRequested(object sender, ContextRequestedEventArgs e) + { + e.Handled = true; + + if (sender is not Border border || DataContext is not ViewModels.Histories histories) + return; + + // Same options as the settings button in the top-left corner of the list. + var menu = CreateAdvancedOptionsMenu(histories); + menu.Placement = PlacementMode.Pointer; + menu.Open(border); + } + + private double GetColumnRightEdge(int idx) + { + var columns = CommitListContainer.Columns; + var edge = 0.0; + for (var i = 0; i <= idx; i++) + { + if (columns[i].IsVisible) + edge += columns[i].ActualWidth; + } + return edge; + } + + private int GetResizableColumnAt(double x) + { + if (DataContext is not ViewModels.Histories vm) + return -1; + + var columns = CommitListContainer.Columns; + if (columns[COL_REFS].IsVisible) + { + var dis = GetColumnRightEdge(COL_REFS) - 4 - x; + if (dis < 4 && dis > -4) + return COL_REFS; + } + + if (columns[COL_GRAPH].IsVisible) + { + var dis = GetColumnRightEdge(COL_GRAPH) - 4 - x; + if (dis < 4 && dis > -4) + return COL_GRAPH; + } + + if (vm.IsAuthorColumnVisible) + { + var dis = GetColumnRightEdge(COL_SUBJECT) - 4 - x; + if (dis < 4 && dis > -4) + return COL_AUTHOR; + } + + return -1; + } + + private List CreateColumnsMenuItems(ViewModels.Histories histories) + { + var columnsHeader = new MenuItem(); + columnsHeader.Header = new TextBlock() { Text = App.Text("Histories.ShowColumns"), FontWeight = FontWeight.Bold }; + columnsHeader.IsEnabled = false; + + var items = new List() { columnsHeader }; + + if (ViewModels.Preferences.Instance.CommitGraphStyle == Models.CommitGraphStyle.Avatars) + { + var refsColumn = new MenuItem(); + refsColumn.Header = App.Text("Histories.Header.BranchesAndTags"); + if (histories.IsRefsColumnVisible) + refsColumn.Icon = this.CreateMenuIcon("Icons.Check"); + refsColumn.Click += (_, ev) => + { + histories.IsRefsColumnVisible = !histories.IsRefsColumnVisible; + ev.Handled = true; + }; + items.Add(refsColumn); + } + + var authorColumn = new MenuItem(); + authorColumn.Header = App.Text("Histories.Header.Author"); + if (histories.IsAuthorColumnVisible) + authorColumn.Icon = this.CreateMenuIcon("Icons.Check"); + authorColumn.Click += (_, ev) => + { + histories.IsAuthorColumnVisible = !histories.IsAuthorColumnVisible; + ev.Handled = true; + }; + items.Add(authorColumn); + + var shaColumn = new MenuItem(); + shaColumn.Header = App.Text("Histories.Header.SHA"); + if (histories.IsSHAColumnVisible) + shaColumn.Icon = this.CreateMenuIcon("Icons.Check"); + shaColumn.Click += (_, ev) => + { + histories.IsSHAColumnVisible = !histories.IsSHAColumnVisible; + ev.Handled = true; + }; + items.Add(shaColumn); + + var authorTimeColumn = new MenuItem(); + authorTimeColumn.Header = App.Text("Histories.Header.AuthorTime"); + if (histories.IsAuthorTimeColumnVisible) + authorTimeColumn.Icon = this.CreateMenuIcon("Icons.Check"); + authorTimeColumn.Click += (_, ev) => + { + histories.IsAuthorTimeColumnVisible = !histories.IsAuthorTimeColumnVisible; + ev.Handled = true; + }; + items.Add(authorTimeColumn); + + var commitTimeColumn = new MenuItem(); + commitTimeColumn.Header = App.Text("Histories.Header.CommitTime"); + if (histories.IsCommitTimeColumnVisible) + commitTimeColumn.Icon = this.CreateMenuIcon("Icons.Check"); + commitTimeColumn.Click += (_, ev) => + { + histories.IsCommitTimeColumnVisible = !histories.IsCommitTimeColumnVisible; + ev.Handled = true; + }; + items.Add(commitTimeColumn); + + return items; } private void OnOpenAdvancedOptions(object sender, RoutedEventArgs e) @@ -531,6 +676,13 @@ private void OnOpenAdvancedOptions(object sender, RoutedEventArgs e) if (DataContext is not ViewModels.Histories histories) return; + var menu = CreateAdvancedOptionsMenu(histories); + menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + menu.Open(button); + } + + private ContextMenu CreateAdvancedOptionsMenu(ViewModels.Histories histories) + { var pref = ViewModels.Preferences.Instance; var layout = new MenuItem(); @@ -675,52 +827,7 @@ private void OnOpenAdvancedOptions(object sender, RoutedEventArgs e) ev.Handled = true; }; - var columnsHeader = new MenuItem(); - columnsHeader.Header = new TextBlock() { Text = App.Text("Histories.ShowColumns"), FontWeight = FontWeight.Bold }; - columnsHeader.IsEnabled = false; - - var authorColumn = new MenuItem(); - authorColumn.Header = App.Text("Histories.Header.Author"); - if (histories.IsAuthorColumnVisible) - authorColumn.Icon = this.CreateMenuIcon("Icons.Check"); - authorColumn.Click += (_, ev) => - { - histories.IsAuthorColumnVisible = !histories.IsAuthorColumnVisible; - ev.Handled = true; - }; - - var shaColumn = new MenuItem(); - shaColumn.Header = App.Text("Histories.Header.SHA"); - if (histories.IsSHAColumnVisible) - shaColumn.Icon = this.CreateMenuIcon("Icons.Check"); - shaColumn.Click += (_, ev) => - { - histories.IsSHAColumnVisible = !histories.IsSHAColumnVisible; - ev.Handled = true; - }; - - var authorTimeColumn = new MenuItem(); - authorTimeColumn.Header = App.Text("Histories.Header.AuthorTime"); - if (histories.IsAuthorTimeColumnVisible) - authorTimeColumn.Icon = this.CreateMenuIcon("Icons.Check"); - authorTimeColumn.Click += (_, ev) => - { - histories.IsAuthorTimeColumnVisible = !histories.IsAuthorTimeColumnVisible; - ev.Handled = true; - }; - - var commitTimeColumn = new MenuItem(); - commitTimeColumn.Header = App.Text("Histories.Header.CommitTime"); - if (histories.IsCommitTimeColumnVisible) - commitTimeColumn.Icon = this.CreateMenuIcon("Icons.Check"); - commitTimeColumn.Click += (_, ev) => - { - histories.IsCommitTimeColumnVisible = !histories.IsCommitTimeColumnVisible; - ev.Handled = true; - }; - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; menu.Items.Add(layout); menu.Items.Add(horizontal); menu.Items.Add(vertical); @@ -741,12 +848,9 @@ private void OnOpenAdvancedOptions(object sender, RoutedEventArgs e) menu.Items.Add(selectedCommitsOnlyFirstParent); menu.Items.Add(currentBranchAndSelectedCommits); menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(columnsHeader); - menu.Items.Add(authorColumn); - menu.Items.Add(shaColumn); - menu.Items.Add(authorTimeColumn); - menu.Items.Add(commitTimeColumn); - menu.Open(button); + foreach (var item in CreateColumnsMenuItems(histories)) + menu.Items.Add(item); + return menu; } private void OnCommitListLayoutUpdated(object _1, EventArgs _2) @@ -778,13 +882,39 @@ private void OnCommitListLayoutUpdated(object _1, EventArgs _2) IsScrollToTopVisible = startY >= rowHeight; - var clipWidth = dataGrid.Columns[0].ActualWidth - 4; + // Rows do not start exactly at the top of the graph (there's a separator below the column headers). + var rowsTop = rowsPresenter.TranslatePoint(new Point(0, 0), CommitGraph); + if (rowsTop.HasValue) + startY -= rowsTop.Value.Y; + + var offsetX = 0.0; + var clipWidth = dataGrid.Columns[COL_SUBJECT].ActualWidth - 4; + if (ViewModels.Preferences.Instance.CommitGraphStyle == Models.CommitGraphStyle.Avatars && + DataContext is ViewModels.Histories vm) + { + // The graph has its own column. Unless resized by user, size it to fit the graph (at most 30% of the list). + var graphColumn = dataGrid.Columns[COL_GRAPH]; + var graphWidth = vm.GraphColumnWidth; + if (graphWidth <= 0) + { + var maxGraphWidth = Math.Max(dataGrid.Bounds.Width * 0.3, 64); + graphWidth = Math.Round(Math.Clamp(CommitGraph.GetAvatarsStyleWidth(CommitGraph.Graph), 32, maxGraphWidth)); + } + + if (_resizingColumn != COL_GRAPH && (!graphColumn.Width.IsAbsolute || Math.Abs(graphColumn.Width.Value - graphWidth) > 0.5)) + graphColumn.Width = new(graphWidth, DataGridLengthUnitType.Pixel); + + offsetX = GetColumnRightEdge(COL_REFS); + clipWidth = graphColumn.ActualWidth; + } + var lastLayout = CommitGraph.Layout; if (lastLayout == null || Math.Abs(lastLayout.StartY - startY) > 0.01 || + Math.Abs(lastLayout.OffsetX - offsetX) > 0.01 || Math.Abs(lastLayout.ClipWidth - clipWidth) > 0.01 || Math.Abs(lastLayout.RowHeight - rowHeight) > 0.01) - CommitGraph.Layout = new(startY, clipWidth, rowHeight); + CommitGraph.Layout = new(startY, offsetX, clipWidth, rowHeight); } private void OnScrollToTopPointerPressed(object sender, PointerPressedEventArgs e) @@ -850,6 +980,139 @@ private async void OnCommitListDoubleTapped(object sender, TappedEventArgs e) } } + private void OnRefsColumnPointerPressed(object sender, PointerPressedEventArgs e) + { + if (sender is not CommitRefsPresenter { DataContext: Models.Commit commit } presenter) + return; + + if (!e.GetCurrentPoint(presenter).Properties.IsLeftButtonPressed || !presenter.IsOverflowBadgeAt(e.GetPosition(presenter))) + return; + + ShowRefsDropdown(presenter, commit); + e.Handled = true; + } + + private void ShowRefsDropdown(CommitRefsPresenter anchor, Models.Commit commit) + { + if (_refsDropdown is { IsOpen: true }) + { + if (_refsDropdown.Target == anchor) + return; + + _refsDropdown.Hide(); + } + + var repoView = this.FindAncestorOfType(); + if (repoView is not { DataContext: ViewModels.Repository repo } || DataContext is not ViewModels.Histories histories) + return; + + // Open right below the `+N` badge. + var showTags = ViewModels.Preferences.Instance.ShowTagsInGraph; + var flyout = new Flyout() + { + Placement = PlacementMode.BottomEdgeAlignedLeft, + HorizontalOffset = anchor.OverflowBadgeBounds?.X ?? 0, + }; + var list = new ListBox() + { + MaxHeight = 320, + MinWidth = 160, + Background = Brushes.Transparent, + SelectionMode = SelectionMode.Single, + }; + + foreach (var decorator in commit.Decorators) + { + if (!showTags && decorator.Type == Models.DecoratorType.Tag) + continue; + + var iconKey = decorator.Type switch + { + Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.CurrentCommitHead => "Icons.Head", + Models.DecoratorType.RemoteBranchHead => "Icons.Remote", + Models.DecoratorType.Tag => "Icons.Tag", + _ => "Icons.Branch", + }; + + var row = new StackPanel() { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 6 }; + row.Children.Add(this.CreateMenuIcon(iconKey)); + row.Children.Add(new TextBlock() + { + Text = decorator.Name, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + FontWeight = decorator.Type is Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.CurrentCommitHead ? FontWeight.Bold : FontWeight.Normal, + }); + + var item = new ListBoxItem() { Content = row, Padding = new Thickness(8, 4) }; + item.DoubleTapped += async (_, ev) => + { + ev.Handled = true; + flyout.Hide(); + await histories.CheckoutBranchByDecoratorAsync(decorator); + }; + item.ContextRequested += (_, ev) => + { + ev.Handled = true; + list.SelectedItem = item; + + var menu = CreateContextMenuForDecorator(repo, commit, decorator); + if (menu == null) + return; + + menu.AddHandler(MenuItem.ClickEvent, (_, _) => flyout.Hide()); + menu.Open(item); + }; + list.Items.Add(item); + } + + flyout.Content = list; + flyout.ShowAt(anchor); + _refsDropdown = flyout; + } + + private ContextMenu CreateContextMenuForDecorator(ViewModels.Repository repo, Models.Commit commit, Models.Decorator decorator) + { + var current = repo.CurrentBranch; + if (current == null) + return null; + + // Reuse the per-ref submenu of the commit's context menu, but show its items directly. + var holder = new ContextMenu(); + switch (decorator.Type) + { + case Models.DecoratorType.CurrentBranchHead: + FillCurrentBranchMenu(holder, repo, current); + break; + case Models.DecoratorType.LocalBranchHead: + var lb = repo.Branches.Find(x => x.IsLocal && decorator.Name.Equals(x.Name, StringComparison.Ordinal)); + if (lb != null) + FillOtherLocalBranchMenu(holder, repo, lb, current, commit.IsMerged); + break; + case Models.DecoratorType.RemoteBranchHead: + var rb = repo.Branches.Find(x => !x.IsLocal && decorator.Name.Equals(x.FriendlyName, StringComparison.Ordinal)); + FillRemoteBranchMenu(holder, repo, rb, current, commit.IsMerged); + break; + case Models.DecoratorType.Tag: + var tag = repo.Tags.Find(x => decorator.Name.Equals(x.Name, StringComparison.Ordinal)); + if (tag != null) + FillTagMenu(holder, repo, tag, current); + break; + } + + if (holder.Items.Count == 0 || holder.Items[0] is not MenuItem submenu) + return null; + + var items = new List(); + foreach (var item in submenu.Items) + items.Add(item); + submenu.Items.Clear(); + + var menu = new ContextMenu(); + foreach (var item in items) + menu.Items.Add(item); + return menu; + } + private void OnCommitGraphLoaded(object sender, RoutedEventArgs e) { // Force-update the graph layout to ensure the graph is correctly rendered when it's loaded. @@ -1608,6 +1871,22 @@ private void FillCurrentBranchMenu(ContextMenu menu, ViewModels.Repository repo, submenu.Items.Add(rename); submenu.Items.Add(new MenuItem() { Header = "-" }); + var upstreamBranch = string.IsNullOrEmpty(current.Upstream) || current.IsUpstreamGone ? null : repo.Branches.Find(x => x.FullName.Equals(current.Upstream, StringComparison.Ordinal)); + if (ViewModels.CreatePullRequest.CanCreateForUpstream(repo, upstreamBranch)) + { + var createPR = new MenuItem(); + createPR.Header = App.Text("BranchCM.CreatePRForUpstream", upstreamBranch.FriendlyName); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, current, null)); + e.Handled = true; + }; + submenu.Items.Add(createPR); + submenu.Items.Add(new MenuItem() { Header = "-" }); + } + if (!repo.IsBare) { var type = repo.GetGitFlowType(current); @@ -1872,6 +2151,21 @@ private void FillRemoteBranchMenu(ContextMenu menu, ViewModels.Repository repo, submenu.Items.Add(compare); submenu.Items.Add(new MenuItem() { Header = "-" }); + if (ViewModels.CreatePullRequest.CanCreateInto(repo, current, branch, out var isUpstreamOfHead)) + { + var createPR = new MenuItem(); + createPR.Header = isUpstreamOfHead ? App.Text("BranchCM.CreatePRForUpstream", name) : App.Text("BranchCM.CreatePR", current.Name, name); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, current, isUpstreamOfHead ? null : branch)); + e.Handled = true; + }; + submenu.Items.Add(createPR); + submenu.Items.Add(new MenuItem() { Header = "-" }); + } + var copy = new MenuItem(); copy.Header = App.Text("BranchCM.CopyName"); copy.Icon = this.CreateMenuIcon("Icons.Copy"); @@ -1969,7 +2263,13 @@ private async Task InteractiveRebaseWithPrefillActionAsync(ViewModels.Repository private AvaloniaList _issueTrackers = null; private bool _isScrollToTopVisible = false; private bool _isDetailsPanelExpanded = true; - private bool _resizingAuthorColumn = false; + private const int COL_REFS = 0; + private const int COL_GRAPH = 1; + private const int COL_SUBJECT = 2; + private const int COL_AUTHOR = 3; + + private int _resizingColumn = -1; + private Flyout _refsDropdown = null; private Cursor _resizingCursor = new(StandardCursorType.SizeWestEast); } } diff --git a/src/Views/LauncherPage.axaml b/src/Views/LauncherPage.axaml index 08107bfa90..359eadda3a 100644 --- a/src/Views/LauncherPage.axaml +++ b/src/Views/LauncherPage.axaml @@ -102,33 +102,60 @@ - - + + + + + + + + - + + + - + + + -