From b1f8c93c81a4cb55c6869475612e7680b546f9c8 Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 11 Oct 2023 10:34:54 +0800 Subject: [PATCH 0001/2741] optimize: remove unused resources --- src/Resources/Themes/Dark.xaml | 1 - src/Resources/Themes/Light.xaml | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Resources/Themes/Dark.xaml b/src/Resources/Themes/Dark.xaml index 0e26e7dc..657a3845 100644 --- a/src/Resources/Themes/Dark.xaml +++ b/src/Resources/Themes/Dark.xaml @@ -18,7 +18,6 @@ - Transparent diff --git a/src/Resources/Themes/Light.xaml b/src/Resources/Themes/Light.xaml index 9982c328..eb80a516 100644 --- a/src/Resources/Themes/Light.xaml +++ b/src/Resources/Themes/Light.xaml @@ -18,7 +18,6 @@ - #FFCFCFCF From e17fbab4cf9230f05a72ae05e1cb45a7b8238560 Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 11 Oct 2023 11:13:57 +0800 Subject: [PATCH 0002/2741] style: new style for FileHistories --- src/Models/Commit.cs | 2 ++ src/Views/FileHistories.xaml | 69 +++++++++++++++--------------------- 2 files changed, 30 insertions(+), 41 deletions(-) diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index 5467f3ec..ed60faae 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -27,6 +27,8 @@ namespace SourceGit.Models { public string AuthorTimeStr => UTC_START.AddSeconds(AuthorTime).ToString("yyyy-MM-dd HH:mm:ss"); public string CommitterTimeStr => UTC_START.AddSeconds(CommitterTime).ToString("yyyy-MM-dd HH:mm:ss"); + public string AuthorTimeShortStr => UTC_START.AddSeconds(AuthorTime).ToString("yyyy/MM/dd"); + public string CommitterTimeShortStr => UTC_START.AddSeconds(CommitterTime).ToString("yyyy/MM/dd"); public static void ParseUserAndTime(string data, ref User user, ref ulong time) { var match = REG_USER_FORMAT.Match(data); diff --git a/src/Views/FileHistories.xaml b/src/Views/FileHistories.xaml index f205ee1e..0345f49c 100644 --- a/src/Views/FileHistories.xaml +++ b/src/Views/FileHistories.xaml @@ -60,7 +60,6 @@ - - - - - + + + + + - + + + + + + + - - - - - + - - - - - + + - - - - - - - - - - + + + + + + + From 2516f11231c7794781d3f509f9bb0c617e17a4eb Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 11 Oct 2023 15:37:23 +0800 Subject: [PATCH 0003/2741] refactor: show author rather than committer in RevisionCompare/Histories --- src/Views/Widgets/Histories.xaml | 6 +- src/Views/Widgets/RevisionCompare.xaml | 92 +++++++++++------------ src/Views/Widgets/RevisionCompare.xaml.cs | 10 ++- 3 files changed, 52 insertions(+), 56 deletions(-) diff --git a/src/Views/Widgets/Histories.xaml b/src/Views/Widgets/Histories.xaml index eb1190be..002c0b93 100644 --- a/src/Views/Widgets/Histories.xaml +++ b/src/Views/Widgets/Histories.xaml @@ -122,8 +122,8 @@ + Email="{Binding Author.Email}" + FallbackLabel="{Binding Author.Name}"/> @@ -131,7 +131,7 @@ - + diff --git a/src/Views/Widgets/RevisionCompare.xaml b/src/Views/Widgets/RevisionCompare.xaml index 4e66a372..d44fc37b 100644 --- a/src/Views/Widgets/RevisionCompare.xaml +++ b/src/Views/Widgets/RevisionCompare.xaml @@ -10,7 +10,7 @@ d:DesignHeight="450" d:DesignWidth="800"> - + @@ -28,33 +28,30 @@ BorderBrush="{DynamicResource Brush.Border2}" BorderThickness="1" Background="{DynamicResource Brush.Contents}" - CornerRadius="4"> - - - - - + CornerRadius="4" + Padding="4" + TextElement.FontFamily="{Binding Source={x:Static models:Preference.Instance}, Path=General.FontFamilyContent, Mode=OneWay}"> + + + + + - + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + @@ -74,33 +71,30 @@ BorderBrush="{DynamicResource Brush.Border2}" BorderThickness="1" Background="{DynamicResource Brush.Contents}" - CornerRadius="4"> - - - - - + Padding="4" + CornerRadius="4" + TextElement.FontFamily="{Binding Source={x:Static models:Preference.Instance}, Path=General.FontFamilyContent, Mode=OneWay}"> + + + + + - + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + diff --git a/src/Views/Widgets/RevisionCompare.xaml.cs b/src/Views/Widgets/RevisionCompare.xaml.cs index 75ce957b..5336d01a 100644 --- a/src/Views/Widgets/RevisionCompare.xaml.cs +++ b/src/Views/Widgets/RevisionCompare.xaml.cs @@ -13,16 +13,18 @@ namespace SourceGit.Views.Widgets { } public void SetData(string repo, Models.Commit start, Models.Commit end) { - avatarStart.Email = start.Committer.Email; - avatarStart.FallbackLabel = start.Committer.Name; + avatarStart.Email = start.Author.Email; + avatarStart.FallbackLabel = start.Author.Name; avatarStart.ToolTip = start.Committer.Name; + txtStartAuthor.Text = start.Author.Name; txtStartSHA.Text = start.ShortSHA; txtStartTime.Text = start.CommitterTimeStr; txtStartSubject.Text = start.Subject; - avatarEnd.Email = end.Committer.Email; - avatarEnd.FallbackLabel = end.Committer.Name; + avatarEnd.Email = end.Author.Email; + avatarEnd.FallbackLabel = end.Author.Name; avatarEnd.ToolTip = end.Committer.Name; + txtEndAuthor.Text = end.Author.Name; txtEndSHA.Text = end.ShortSHA; txtEndTime.Text = end.CommitterTimeStr; txtEndSubject.Text = end.Subject; From e4c350f189fdc91dc21c43d925a45057a4aeb6d6 Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 11 Oct 2023 17:29:17 +0800 Subject: [PATCH 0004/2741] update: copyright update --- src/Views/About.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Views/About.xaml b/src/Views/About.xaml index 9c4e14af..1b754a30 100644 --- a/src/Views/About.xaml +++ b/src/Views/About.xaml @@ -112,7 +112,7 @@ - + From 738daddbc7b561237016532a5826c04e6fab0a34 Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 11 Oct 2023 18:23:33 +0800 Subject: [PATCH 0005/2741] feature: add context menu to manually re-fetch avatar --- src/Views/Controls/Avatar.cs | 101 ++++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 42 deletions(-) diff --git a/src/Views/Controls/Avatar.cs b/src/Views/Controls/Avatar.cs index 9b5cfe6b..abbf0fa2 100644 --- a/src/Views/Controls/Avatar.cs +++ b/src/Views/Controls/Avatar.cs @@ -71,7 +71,9 @@ namespace SourceGit.Views.Controls { set { SetValue(FallbackLabelProperty, value); } } - private static Dictionary> requesting = new Dictionary>(); + private static event Action RefetchRequested; + private static event Action FetchCompleted; + private static Dictionary loaded = new Dictionary(); private static Task loader = null; @@ -79,23 +81,45 @@ namespace SourceGit.Views.Controls { private FormattedText label = null; public Avatar() { + RefetchRequested += email => { + if (email == Email) { + Source = null; + InvalidateVisual(); + } + }; + + FetchCompleted += email => { + if (email == Email) { + Source = loaded[Email]; + InvalidateVisual(); + } + }; + SetValue(RenderOptions.BitmapScalingModeProperty, BitmapScalingMode.HighQuality); SetValue(RenderOptions.ClearTypeHintProperty, ClearTypeHint.Auto); - Unloaded += (o, e) => Cancel(Email); + + var refetch = new MenuItem(); + refetch.Header = App.Text("Dashboard.Refresh"); + refetch.Click += (o, e) => Refetch(); + + ContextMenu = new ContextMenu(); + ContextMenu.Items.Add(refetch); } /// - /// 取消一个下载任务 + /// 手动刷新 /// - /// - private void Cancel(string email) { - if (!string.IsNullOrEmpty(email) && requesting.ContainsKey(email)) { - if (requesting[email].Count <= 1) { - requesting.Remove(email); - } else { - requesting[email].Remove(this); - } - } + private void Refetch() { + byte[] hash = MD5.Create().ComputeHash(Encoding.Default.GetBytes(Email.ToLower().Trim())); + string md5 = ""; + for (int i = 0; i < hash.Length; i++) md5 += hash[i].ToString("x2"); + md5 = md5.ToLower(); + string filePath = Path.Combine(CACHE_PATH, md5); + if (File.Exists(filePath)) File.Delete(filePath); + + RefetchRequested?.Invoke(Email); + if (loaded.ContainsKey(Email)) loaded.Remove(Email); + OnEmailChanged(this, new DependencyPropertyChangedEventArgs(EmailProperty, null, Email)); } /// @@ -146,6 +170,7 @@ namespace SourceGit.Views.Controls { var chars = placeholder.ToCharArray(); foreach (var ch in chars) a.colorIdx += Math.Abs(ch); a.colorIdx = a.colorIdx % BACKGROUND_BRUSHES.Length; + if (a.Source == null) a.InvalidateVisual(); } /// @@ -157,9 +182,10 @@ namespace SourceGit.Views.Controls { Avatar a = d as Avatar; if (a == null) return; - a.Cancel(e.OldValue as string); - a.Source = null; - a.InvalidateVisual(); + if (a.Source != null) { + a.Source = null; + a.InvalidateVisual(); + } var email = e.NewValue as string; if (string.IsNullOrEmpty(email)) return; @@ -169,11 +195,6 @@ namespace SourceGit.Views.Controls { return; } - if (requesting.ContainsKey(email)) { - requesting[email].Add(a); - return; - } - byte[] hash = MD5.Create().ComputeHash(Encoding.Default.GetBytes(email.ToLower().Trim())); string md5 = ""; for (int i = 0; i < hash.Length; i++) md5 += hash[i].ToString("x2"); @@ -181,18 +202,15 @@ namespace SourceGit.Views.Controls { string filePath = Path.Combine(CACHE_PATH, md5); if (File.Exists(filePath)) { - var img = new BitmapImage(new Uri(filePath)); + var img = LoadFromFile(filePath); loaded.Add(email, img); a.Source = img; return; } - requesting.Add(email, new List()); - requesting[email].Add(a); + loaded.Add(email, null); Action job = () => { - if (!requesting.ContainsKey(email)) return; - try { var req = WebRequest.CreateHttp($"https://cravatar.cn/avatar/{md5}?d=404"); req.Timeout = 2000; @@ -200,27 +218,18 @@ namespace SourceGit.Views.Controls { var rsp = req.GetResponse() as HttpWebResponse; if (rsp != null && rsp.StatusCode == HttpStatusCode.OK) { - using (var reader = rsp.GetResponseStream()) - using (var writer = File.OpenWrite(filePath)) { - reader.CopyTo(writer); - } + using (var reader = rsp.GetResponseStream()) { + using (var writer = File.OpenWrite(filePath)) { + reader.CopyTo(writer); + } + } a.Dispatcher.Invoke(() => { - var img = new BitmapImage(new Uri(filePath)); - loaded.Add(email, img); - - if (requesting.ContainsKey(email)) { - foreach (var one in requesting[email]) one.Source = img; - } + loaded[email] = LoadFromFile(filePath); + FetchCompleted?.Invoke(email); }); - } else { - if (!loaded.ContainsKey(email)) loaded.Add(email, null); } - } catch { - if (!loaded.ContainsKey(email)) loaded.Add(email, null); - } - - requesting.Remove(email); + } catch {} }; if (loader != null && !loader.IsCompleted) { @@ -229,5 +238,13 @@ namespace SourceGit.Views.Controls { loader = Task.Run(job); } } + + private static BitmapImage LoadFromFile(string file) { + var img = new BitmapImage(); + img.BeginInit(); + img.StreamSource = new MemoryStream(File.ReadAllBytes(file)); + img.EndInit(); + return img; + } } } From 918eb486636513cc7a0d0b1b31a98c13f551b726 Mon Sep 17 00:00:00 2001 From: leo Date: Thu, 12 Oct 2023 12:02:41 +0800 Subject: [PATCH 0006/2741] optimize<*>: reduce repository loading time --- src/Commands/Branches.cs | 18 ++++++++---------- src/Commands/Command.cs | 8 ++++++-- src/Commands/Commits.cs | 4 ++-- src/Commands/Stashes.cs | 2 +- src/Commands/Tags.cs | 24 ++++++++---------------- src/Models/Branch.cs | 1 - src/Models/Commit.cs | 11 +++++------ src/Models/User.cs | 13 ++++++++----- 8 files changed, 38 insertions(+), 43 deletions(-) diff --git a/src/Commands/Branches.cs b/src/Commands/Branches.cs index 88e767b3..8dfdfae1 100644 --- a/src/Commands/Branches.cs +++ b/src/Commands/Branches.cs @@ -9,8 +9,7 @@ namespace SourceGit.Commands { public class Branches : Command { private static readonly string PREFIX_LOCAL = "refs/heads/"; private static readonly string PREFIX_REMOTE = "refs/remotes/"; - private static readonly string CMD = "branch -l --all -v --format=\"$%(refname)$%(objectname)$%(HEAD)$%(upstream)$%(upstream:track)$%(contents:subject)\""; - private static readonly Regex REG_FORMAT = new Regex(@"\$(.*)\$(.*)\$([\* ])\$(.*)\$(.*?)\$(.*)"); + private static readonly string CMD = "branch -l --all -v --format=\"%(refname)$%(objectname)$%(HEAD)$%(upstream)$%(upstream:track)\""; private static readonly Regex REG_AHEAD = new Regex(@"ahead (\d+)"); private static readonly Regex REG_BEHIND = new Regex(@"behind (\d+)"); @@ -27,11 +26,11 @@ namespace SourceGit.Commands { } public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; + var parts = line.Split('$'); + if (parts.Length != 5) return; var branch = new Models.Branch(); - var refName = match.Groups[1].Value; + var refName = parts[0]; if (refName.EndsWith("/HEAD")) return; if (refName.StartsWith(PREFIX_LOCAL, StringComparison.Ordinal)) { @@ -51,11 +50,10 @@ namespace SourceGit.Commands { } branch.FullName = refName; - branch.Head = match.Groups[2].Value; - branch.IsCurrent = match.Groups[3].Value == "*"; - branch.Upstream = match.Groups[4].Value; - branch.UpstreamTrackStatus = ParseTrackStatus(match.Groups[5].Value); - branch.HeadSubject = match.Groups[6].Value; + branch.Head = parts[1]; + branch.IsCurrent = parts[2] == "*"; + branch.Upstream = parts[3]; + branch.UpstreamTrackStatus = ParseTrackStatus(parts[4]); loaded.Add(branch); } diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs index 3c22903b..96f18c3f 100644 --- a/src/Commands/Command.cs +++ b/src/Commands/Command.cs @@ -17,6 +17,7 @@ namespace SourceGit.Commands { /// 命令接口 /// public class Command { + private static readonly Regex PROGRESS_REG = new Regex(@"\d+%"); /// /// 读取全部输出时的结果 @@ -68,7 +69,6 @@ namespace SourceGit.Commands { if (!string.IsNullOrEmpty(Cwd)) start.WorkingDirectory = Cwd; - var progressFilter = new Regex(@"\s\d+%\s"); var errs = new List(); var proc = new Process() { StartInfo = start }; var isCancelled = false; @@ -97,8 +97,12 @@ namespace SourceGit.Commands { if (string.IsNullOrEmpty(e.Data)) return; if (TraitErrorAsOutput) OnReadline(e.Data); - if (progressFilter.IsMatch(e.Data)) return; + // 错误信息中忽略进度相关的输出 + if (e.Data.StartsWith("remote: Enumerating objects:", StringComparison.Ordinal)) return; if (e.Data.StartsWith("remote: Counting objects:", StringComparison.Ordinal)) return; + if (e.Data.StartsWith("remote: Compressing objects:", StringComparison.Ordinal)) return; + if (e.Data.StartsWith("Filtering content:", StringComparison.Ordinal)) return; + if (PROGRESS_REG.IsMatch(e.Data)) return; errs.Add(e.Data); }; diff --git a/src/Commands/Commits.cs b/src/Commands/Commits.cs index b1b4c28f..f70528db 100644 --- a/src/Commands/Commits.cs +++ b/src/Commands/Commits.cs @@ -77,13 +77,13 @@ namespace SourceGit.Commands { } else if (line.StartsWith("author ", StringComparison.Ordinal)) { Models.User user = Models.User.Invalid; ulong time = 0; - Models.Commit.ParseUserAndTime(line, ref user, ref time); + Models.Commit.ParseUserAndTime(line.Substring(7), ref user, ref time); current.Author = user; current.AuthorTime = time; } else if (line.StartsWith("committer ", StringComparison.Ordinal)) { Models.User user = Models.User.Invalid; ulong time = 0; - Models.Commit.ParseUserAndTime(line, ref user, ref time); + Models.Commit.ParseUserAndTime(line.Substring(10), ref user, ref time); current.Committer = user; current.CommitterTime = time; } else if (string.IsNullOrEmpty(current.Subject)) { diff --git a/src/Commands/Stashes.cs b/src/Commands/Stashes.cs index 3e7d9713..5668ff64 100644 --- a/src/Commands/Stashes.cs +++ b/src/Commands/Stashes.cs @@ -39,7 +39,7 @@ namespace SourceGit.Commands { } else if (line.StartsWith("author ", StringComparison.Ordinal)) { Models.User user = Models.User.Invalid; ulong time = 0; - Models.Commit.ParseUserAndTime(line, ref user, ref time); + Models.Commit.ParseUserAndTime(line.Substring(7), ref user, ref time); current.Author = user; current.Time = time; } diff --git a/src/Commands/Tags.cs b/src/Commands/Tags.cs index f534c7b2..738c86db 100644 --- a/src/Commands/Tags.cs +++ b/src/Commands/Tags.cs @@ -1,5 +1,5 @@ +using System; using System.Collections.Generic; -using System.Text.RegularExpressions; namespace SourceGit.Commands { /// @@ -7,8 +7,6 @@ namespace SourceGit.Commands { /// public class Tags : Command { public static readonly string CMD = "for-each-ref --sort=-creatordate --format=\"$%(refname:short)$%(objectname)$%(*objectname)\" refs/tags"; - public static readonly Regex REG_FORMAT = new Regex(@"\$(.*)\$(.*)\$(.*)"); - private List loaded = new List(); public Tags(string path) { @@ -22,22 +20,16 @@ namespace SourceGit.Commands { } public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var name = match.Groups[1].Value; - var commit = match.Groups[2].Value; - var dereference = match.Groups[3].Value; - - if (string.IsNullOrEmpty(dereference)) { + var subs = line.Split(new char[] { '$' }, StringSplitOptions.RemoveEmptyEntries); + if (subs.Length == 2) { loaded.Add(new Models.Tag() { - Name = name, - SHA = commit, + Name = subs[0], + SHA = subs[1], }); - } else { + } else if (subs.Length == 3) { loaded.Add(new Models.Tag() { - Name = name, - SHA = dereference, + Name = subs[0], + SHA = subs[2], }); } } diff --git a/src/Models/Branch.cs b/src/Models/Branch.cs index f6d63b50..9f88c4c8 100644 --- a/src/Models/Branch.cs +++ b/src/Models/Branch.cs @@ -6,7 +6,6 @@ namespace SourceGit.Models { public string Name { get; set; } public string FullName { get; set; } public string Head { get; set; } - public string HeadSubject { get; set; } public bool IsLocal { get; set; } public bool IsCurrent { get; set; } public string Upstream { get; set; } diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index ed60faae..141f6b9e 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Text.RegularExpressions; using System.Windows; namespace SourceGit.Models { @@ -8,7 +7,6 @@ namespace SourceGit.Models { /// 提交记录 /// public class Commit { - private static readonly Regex REG_USER_FORMAT = new Regex(@"\w+ (.*) <(.*)> (\d{10}) [\+\-]\d+"); private static readonly DateTime UTC_START = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).ToLocalTime(); public string SHA { get; set; } = string.Empty; @@ -31,11 +29,12 @@ namespace SourceGit.Models { public string CommitterTimeShortStr => UTC_START.AddSeconds(CommitterTime).ToString("yyyy/MM/dd"); public static void ParseUserAndTime(string data, ref User user, ref ulong time) { - var match = REG_USER_FORMAT.Match(data); - if (!match.Success) return; + var userEndIdx = data.IndexOf('>'); + if (userEndIdx < 0) return; - user = User.FindOrAdd(match.Groups[1].Value, match.Groups[2].Value); - time = ulong.Parse(match.Groups[3].Value); + var timeEndIdx = data.IndexOf(' ', userEndIdx + 2); + user = User.FindOrAdd(data.Substring(0, userEndIdx)); + time = timeEndIdx < 0 ? 0 : ulong.Parse(data.Substring(userEndIdx + 2, timeEndIdx - userEndIdx - 2)); } } } diff --git a/src/Models/User.cs b/src/Models/User.cs index bc3aeb3d..9af6bdcf 100644 --- a/src/Models/User.cs +++ b/src/Models/User.cs @@ -22,13 +22,16 @@ namespace SourceGit.Models { return base.GetHashCode(); } - public static User FindOrAdd(string name, string email) { - string key = $"{name}#&#{email}"; - if (Caches.ContainsKey(key)) { - return Caches[key]; + public static User FindOrAdd(string data) { + if (Caches.ContainsKey(data)) { + return Caches[data]; } else { + var nameEndIdx = data.IndexOf('<'); + var name = nameEndIdx >= 2 ? data.Substring(0, nameEndIdx - 1) : string.Empty; + var email = data.Substring(nameEndIdx + 1); + User user = new User() { Name = name, Email = email }; - Caches.Add(key, user); + Caches.Add(data, user); return user; } } From c85052bbccbc39a43d53e4900ba631a6ca5a3288 Mon Sep 17 00:00:00 2001 From: leo Date: Fri, 13 Oct 2023 10:26:11 +0800 Subject: [PATCH 0007/2741] fix: TaskbarItemInfo.ProgressState should disappears only when all tasks are complete --- src/Views/Launcher.xaml.cs | 19 +++++++++++++++++++ src/Views/Widgets/Dashboard.xaml.cs | 8 +++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Views/Launcher.xaml.cs b/src/Views/Launcher.xaml.cs index 0e3cee7e..7d67f71e 100644 --- a/src/Views/Launcher.xaml.cs +++ b/src/Views/Launcher.xaml.cs @@ -7,6 +7,7 @@ using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Shapes; +using System.Windows.Shell; namespace SourceGit.Views { @@ -14,6 +15,7 @@ namespace SourceGit.Views { /// 主窗体 /// public partial class Launcher : Controls.Window { + private int taskBarProgressRequest = 0; public Launcher() { Models.Watcher.Opened += OpenRepository; @@ -21,6 +23,22 @@ namespace SourceGit.Views { tabs.Add(); } + #region TASKBAR_PROGRESS + public void IncreaseProgressBar() { + if (taskBarProgressRequest == 0) TaskbarItemInfo.ProgressState = TaskbarItemProgressState.Indeterminate; + taskBarProgressRequest++; + } + + public void DecreaseProgressBar() { + taskBarProgressRequest--; + if (taskBarProgressRequest <= 0) { + taskBarProgressRequest = 0; + TaskbarItemInfo.ProgressState = TaskbarItemProgressState.None; + } + } + #endregion + + #region WINDOW_EVENTS private void OnClosing(object sender, CancelEventArgs e) { var restore = Models.Preference.Instance.Restore; if (!restore.IsEnabled) return; @@ -46,6 +64,7 @@ namespace SourceGit.Views { Models.Preference.Save(); } + #endregion #region OPEN_REPO private void OpenRepository(Models.Repository repo) { diff --git a/src/Views/Widgets/Dashboard.xaml.cs b/src/Views/Widgets/Dashboard.xaml.cs index 06c4535d..9bb7764a 100644 --- a/src/Views/Widgets/Dashboard.xaml.cs +++ b/src/Views/Widgets/Dashboard.xaml.cs @@ -155,18 +155,20 @@ namespace SourceGit.Views.Widgets { isPopupLocked = true; popupProgressMask.Visibility = Visibility.Visible; processing.IsAnimating = true; - App.Current.MainWindow.TaskbarItemInfo.ProgressState = TaskbarItemProgressState.Indeterminate; + + var launcher = App.Current.MainWindow as Launcher; + launcher.IncreaseProgressBar(); var task = curPopup.Start(); if (task != null) { var close = await task; - App.Current.MainWindow.TaskbarItemInfo.ProgressState = TaskbarItemProgressState.None; + launcher.DecreaseProgressBar(); if (close) { ClosePopups(true); return; } } else { - App.Current.MainWindow.TaskbarItemInfo.ProgressState = TaskbarItemProgressState.None; + launcher.DecreaseProgressBar(); } isPopupLocked = false; From 838e688a0caecf0c248b80683ddae348ccbd92a6 Mon Sep 17 00:00:00 2001 From: leo Date: Fri, 13 Oct 2023 11:16:03 +0800 Subject: [PATCH 0008/2741] refactor: add context to exception to filter exceptions; each page has it's own error display control --- src/App.xaml.cs | 10 ++++++++ src/Commands/Command.cs | 2 +- src/Models/Exception.cs | 15 ------------ src/Views/Clone.xaml.cs | 2 +- src/Views/Launcher.xaml | 6 ----- src/Views/Popups/AddSubTree.xaml.cs | 2 +- .../Popups/FastForwardWithoutCheckout.xaml.cs | 2 +- src/Views/Widgets/Dashboard.xaml | 8 +++++++ src/Views/Widgets/Dashboard.xaml.cs | 12 ++++++---- src/Views/Widgets/DiffViewer.xaml.cs | 2 +- src/Views/Widgets/Exceptions.xaml.cs | 24 ++++++++++++++++++- src/Views/Widgets/Histories.xaml.cs | 2 +- src/Views/Widgets/Welcome.xaml | 10 +++++++- src/Views/Widgets/Welcome.xaml.cs | 14 +++++++---- src/Views/Widgets/WorkingCopy.xaml.cs | 12 +++++----- 15 files changed, 79 insertions(+), 44 deletions(-) delete mode 100644 src/Models/Exception.cs diff --git a/src/App.xaml.cs b/src/App.xaml.cs index 1528ae9b..845bef46 100644 --- a/src/App.xaml.cs +++ b/src/App.xaml.cs @@ -8,6 +8,7 @@ namespace SourceGit { /// 程序入口. /// public partial class App : Application { + public static event Action ExceptionRaised; /// /// 读取本地化字串 @@ -21,6 +22,15 @@ namespace SourceGit { return string.Format(data, args); } + /// + /// 触发错误 + /// + /// 错误上下文 + /// 错误内容 + public static void Exception(string ctx, string detail) { + ExceptionRaised?.Invoke(ctx, detail); + } + /// /// 启动. /// diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs index 96f18c3f..aac9bdff 100644 --- a/src/Commands/Command.cs +++ b/src/Commands/Command.cs @@ -178,7 +178,7 @@ namespace SourceGit.Commands { /// /// public virtual void OnException(string message) { - Models.Exception.Raise(message); + App.Exception(Cwd, message); } } } diff --git a/src/Models/Exception.cs b/src/Models/Exception.cs deleted file mode 100644 index 99d5b92b..00000000 --- a/src/Models/Exception.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace SourceGit.Models { - - /// - /// 错误通知 - /// - public static class Exception { - public static Action Handler { get; set; } - - public static void Raise(string error) { - Handler?.Invoke(error); - } - } -} diff --git a/src/Views/Clone.xaml.cs b/src/Views/Clone.xaml.cs index afce1aed..f5d6b81d 100644 --- a/src/Views/Clone.xaml.cs +++ b/src/Views/Clone.xaml.cs @@ -63,7 +63,7 @@ namespace SourceGit.Views { } if (!Directory.Exists(path)) { - Models.Exception.Raise($"Folder {path} not found!"); + Dispatcher.Invoke(() => txtError.Text = $"Folder {path} not found!"); return false; } diff --git a/src/Views/Launcher.xaml b/src/Views/Launcher.xaml index d3fef695..801e8fc2 100644 --- a/src/Views/Launcher.xaml +++ b/src/Views/Launcher.xaml @@ -70,11 +70,5 @@ - - - diff --git a/src/Views/Popups/AddSubTree.xaml.cs b/src/Views/Popups/AddSubTree.xaml.cs index 0086a0a2..c68f114b 100644 --- a/src/Views/Popups/AddSubTree.xaml.cs +++ b/src/Views/Popups/AddSubTree.xaml.cs @@ -34,7 +34,7 @@ namespace SourceGit.Views.Popups { var squash = chkSquash.IsChecked == true; if (repo.SubTrees.FindIndex(x => x.Prefix == Prefix) >= 0) { - Models.Exception.Raise($"Subtree add failed. Prefix({Prefix}) already exists!"); + App.Exception(repo.Path, $"Subtree add failed. Prefix({Prefix}) already exists!"); return null; } diff --git a/src/Views/Popups/FastForwardWithoutCheckout.xaml.cs b/src/Views/Popups/FastForwardWithoutCheckout.xaml.cs index 37efa5b5..b561b1a9 100644 --- a/src/Views/Popups/FastForwardWithoutCheckout.xaml.cs +++ b/src/Views/Popups/FastForwardWithoutCheckout.xaml.cs @@ -14,7 +14,7 @@ namespace SourceGit.Views.Popups { public FastForwardWithoutCheckout(string repo, string branch, string upstream) { int idx = upstream.IndexOf('/'); if (idx < 0 || idx == upstream.Length - 1) { - Models.Exception.Raise($"Invalid upstream: {upstream}"); + App.Exception(repo, $"Invalid upstream: {upstream}"); return; } diff --git a/src/Views/Widgets/Dashboard.xaml b/src/Views/Widgets/Dashboard.xaml index 6b44bd6f..03dbfb61 100644 --- a/src/Views/Widgets/Dashboard.xaml +++ b/src/Views/Widgets/Dashboard.xaml @@ -1,4 +1,5 @@ + + + diff --git a/src/Views/Widgets/Dashboard.xaml.cs b/src/Views/Widgets/Dashboard.xaml.cs index 9bb7764a..30ff38ca 100644 --- a/src/Views/Widgets/Dashboard.xaml.cs +++ b/src/Views/Widgets/Dashboard.xaml.cs @@ -54,6 +54,8 @@ namespace SourceGit.Views.Widgets { } } + public string ExceptionContext => repo.Path; + public Dashboard(Models.Repository repo) { this.repo = repo; @@ -402,7 +404,7 @@ namespace SourceGit.Views.Widgets { private void OpenInTerminal(object sender, RoutedEventArgs e) { var bash = Path.Combine(Models.Preference.Instance.Git.Path, "..", "bash.exe"); if (!File.Exists(bash)) { - Models.Exception.Raise(App.Text("MissingBash")); + App.Exception(repo.Path, App.Text("MissingBash")); return; } @@ -428,7 +430,7 @@ namespace SourceGit.Views.Widgets { private void OpenFetch(object sender, RoutedEventArgs e) { if (repo.Remotes.Count == 0) { - Models.Exception.Raise("No remotes added to this repository!!!"); + App.Exception(repo.Path, "No remotes added to this repository!!!"); return; } @@ -438,7 +440,7 @@ namespace SourceGit.Views.Widgets { private void OpenPull(object sender, RoutedEventArgs e) { if (repo.Remotes.Count == 0) { - Models.Exception.Raise("No remotes added to this repository!!!"); + App.Exception(repo.Path, "No remotes added to this repository!!!"); return; } @@ -448,7 +450,7 @@ namespace SourceGit.Views.Widgets { private void OpenPush(object sender, RoutedEventArgs e) { if (repo.Remotes.Count == 0) { - Models.Exception.Raise("No remotes added to this repository!!!"); + App.Exception(repo.Path, "No remotes added to this repository!!!"); return; } @@ -576,7 +578,7 @@ namespace SourceGit.Views.Widgets { if (current != null) { new Popups.CreateBranch(repo, current).Show(); } else { - Models.Exception.Raise(App.Text("CreateBranch.Idle")); + App.Exception(repo.Path, App.Text("CreateBranch.Idle")); } e.Handled = true; } diff --git a/src/Views/Widgets/DiffViewer.xaml.cs b/src/Views/Widgets/DiffViewer.xaml.cs index 755509e4..4a856eea 100644 --- a/src/Views/Widgets/DiffViewer.xaml.cs +++ b/src/Views/Widgets/DiffViewer.xaml.cs @@ -604,7 +604,7 @@ namespace SourceGit.Views.Widgets { var merger = Models.MergeTool.Supported.Find(x => x.Type == mergeType); if (merger == null || merger.Type == 0 || !System.IO.File.Exists(mergeExe)) { - Models.Exception.Raise("Invalid merge tool in preference setting!"); + App.Exception(repo, "Invalid merge tool in preference setting!"); return; } diff --git a/src/Views/Widgets/Exceptions.xaml.cs b/src/Views/Widgets/Exceptions.xaml.cs index ac4a8fd9..f68583e5 100644 --- a/src/Views/Widgets/Exceptions.xaml.cs +++ b/src/Views/Widgets/Exceptions.xaml.cs @@ -10,9 +10,31 @@ namespace SourceGit.Views.Widgets { public partial class Exceptions : UserControl { public ObservableCollection Messages { get; set; } + /// + /// 用于判断异常是否属于自己的上下文属性 + /// + public static readonly DependencyProperty ContextProperty = DependencyProperty.Register( + "Context", + typeof(string), + typeof(Exceptions), + new PropertyMetadata(null)); + + /// + /// 上下文 + /// + public string Context { + get { return (string)GetValue(ContextProperty); } + set { SetValue(ContextProperty, value); } + } + public Exceptions() { + App.ExceptionRaised += (ctx, detail) => { + Dispatcher.Invoke(() => { + if (ctx == Context) Messages.Add(detail); + }); + }; + Messages = new ObservableCollection(); - Models.Exception.Handler = e => Dispatcher.Invoke(() => Messages.Add(e)); InitializeComponent(); } diff --git a/src/Views/Widgets/Histories.xaml.cs b/src/Views/Widgets/Histories.xaml.cs index 0eb0b8c7..853460bd 100644 --- a/src/Views/Widgets/Histories.xaml.cs +++ b/src/Views/Widgets/Histories.xaml.cs @@ -303,7 +303,7 @@ namespace SourceGit.Views.Widgets { } } - Models.Exception.Raise("Can NOT found parent of HEAD!"); + App.Exception(repo.Path, "Can NOT found parent of HEAD!"); e.Handled = true; }; menu.Items.Add(squash); diff --git a/src/Views/Widgets/Welcome.xaml b/src/Views/Widgets/Welcome.xaml index 9c0b3949..24c916de 100644 --- a/src/Views/Widgets/Welcome.xaml +++ b/src/Views/Widgets/Welcome.xaml @@ -1,4 +1,5 @@ - + + + + diff --git a/src/Views/Widgets/Welcome.xaml.cs b/src/Views/Widgets/Welcome.xaml.cs index fcc3de42..63062883 100644 --- a/src/Views/Widgets/Welcome.xaml.cs +++ b/src/Views/Widgets/Welcome.xaml.cs @@ -14,8 +14,14 @@ namespace SourceGit.Views.Widgets { /// 新标签页 /// public partial class Welcome : UserControl { + public string ExceptionContext { + get; + set; + } public Welcome() { + ExceptionContext = Guid.NewGuid().ToString(); + InitializeComponent(); UpdateVisibles(); @@ -34,7 +40,7 @@ namespace SourceGit.Views.Widgets { if (MakeSureReady()) { var bash = Path.Combine(Models.Preference.Instance.Git.Path, "..", "bash.exe"); if (!File.Exists(bash)) { - Models.Exception.Raise(App.Text("MissingBash")); + App.Exception(ExceptionContext, App.Text("MissingBash")); return; } @@ -175,7 +181,7 @@ namespace SourceGit.Views.Widgets { var bash = Path.Combine(Models.Preference.Instance.Git.Path, "..", "bash.exe"); if (!File.Exists(bash)) { - Models.Exception.Raise(App.Text("MissingBash")); + App.Exception(ExceptionContext, App.Text("MissingBash")); return; } @@ -245,7 +251,7 @@ namespace SourceGit.Views.Widgets { private bool MakeSureReady() { if (!Models.Preference.Instance.IsReady) { - Models.Exception.Raise(App.Text("NotConfigured")); + App.Exception(ExceptionContext, App.Text("NotConfigured")); return false; } return true; @@ -255,7 +261,7 @@ namespace SourceGit.Views.Widgets { if (!MakeSureReady()) return; if (!Directory.Exists(path)) { - Models.Exception.Raise(App.Text("PathNotFound", path)); + App.Exception(ExceptionContext, App.Text("PathNotFound", path)); return; } diff --git a/src/Views/Widgets/WorkingCopy.xaml.cs b/src/Views/Widgets/WorkingCopy.xaml.cs index b95f210b..95406fd4 100644 --- a/src/Views/Widgets/WorkingCopy.xaml.cs +++ b/src/Views/Widgets/WorkingCopy.xaml.cs @@ -220,7 +220,7 @@ namespace SourceGit.Views.Widgets { var merger = Models.MergeTool.Supported.Find(x => x.Type == mergeType); if (merger == null || merger.Type == 0 || !File.Exists(mergeExe)) { - Models.Exception.Raise("Invalid merge tool in preference setting!"); + App.Exception(repo.Path, "Invalid merge tool in preference setting!"); return; } @@ -289,7 +289,7 @@ namespace SourceGit.Views.Widgets { private void StartAmend(object sender, RoutedEventArgs e) { var commits = new Commands.Commits(repo.Path, "-n 1", false).Result(); if (commits.Count == 0) { - Models.Exception.Raise("No commits to amend!"); + App.Exception(repo.Path, "No commits to amend!"); chkAmend.IsChecked = false; return; } @@ -316,12 +316,12 @@ namespace SourceGit.Views.Widgets { var changes = await Task.Run(() => new Commands.LocalChanges(repo.Path).Result()); var conflict = changes.Find(x => x.IsConflit); if (conflict != null) { - Models.Exception.Raise("You have unsolved conflicts in your working copy!"); + App.Exception(repo.Path, "You have unsolved conflicts in your working copy!"); return; } if (stagedContainer.Changes.Count == 0) { - Models.Exception.Raise("No files added to commit!"); + App.Exception(repo.Path, "No files added to commit!"); return; } @@ -351,12 +351,12 @@ namespace SourceGit.Views.Widgets { var changes = await Task.Run(() => new Commands.LocalChanges(repo.Path).Result()); var conflict = changes.Find(x => x.IsConflit); if (conflict != null) { - Models.Exception.Raise("You have unsolved conflicts in your working copy!"); + App.Exception(repo.Path, "You have unsolved conflicts in your working copy!"); return; } if (stagedContainer.Changes.Count == 0) { - Models.Exception.Raise("No files added to commit!"); + App.Exception(repo.Path, "No files added to commit!"); return; } From 0966baa1d8fa981bbbf5ba4e8f56bf138f6153c1 Mon Sep 17 00:00:00 2001 From: leo Date: Fri, 13 Oct 2023 11:21:45 +0800 Subject: [PATCH 0009/2741] rename: rename Models.Issue to Models.CrashInfo --- src/App.xaml.cs | 4 ++-- src/Models/{Issue.cs => CrashInfo.cs} | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) rename src/Models/{Issue.cs => CrashInfo.cs} (83%) diff --git a/src/App.xaml.cs b/src/App.xaml.cs index 845bef46..16637cd1 100644 --- a/src/App.xaml.cs +++ b/src/App.xaml.cs @@ -39,8 +39,8 @@ namespace SourceGit { protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); - // 崩溃上报 - AppDomain.CurrentDomain.UnhandledException += (_, ev) => Models.Issue.Create(ev.ExceptionObject as Exception); + // 崩溃文件生成 + AppDomain.CurrentDomain.UnhandledException += (_, ev) => Models.CrashInfo.Create(ev.ExceptionObject as Exception); // 创建必要目录 if (!Directory.Exists(Views.Controls.Avatar.CACHE_PATH)) { diff --git a/src/Models/Issue.cs b/src/Models/CrashInfo.cs similarity index 83% rename from src/Models/Issue.cs rename to src/Models/CrashInfo.cs index 52e531d6..02318bac 100644 --- a/src/Models/Issue.cs +++ b/src/Models/CrashInfo.cs @@ -8,17 +8,17 @@ namespace SourceGit.Models { /// /// 崩溃日志生成 /// - public class Issue { - public static void Create(System.Exception e) { + public class CrashInfo { + public static void Create(Exception e) { var builder = new StringBuilder(); builder.Append("Crash: "); builder.Append(e.Message); builder.Append("\n\n"); builder.Append("----------------------------\n"); builder.Append($"Windows OS: {Environment.OSVersion}\n"); - builder.Append($"Version: {Assembly.GetExecutingAssembly().GetName().Version}"); - builder.Append($"Platform: {AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}"); - builder.Append($"Source: {e.Source}"); + builder.Append($"Version: {Assembly.GetExecutingAssembly().GetName().Version}\n"); + builder.Append($"Platform: {AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}\n"); + builder.Append($"Source: {e.Source}\n"); builder.Append($"---------------------------\n\n"); builder.Append(e.StackTrace); From ed26256c9059ab92148e025e71be89da4db8180b Mon Sep 17 00:00:00 2001 From: leo Date: Tue, 17 Oct 2023 19:50:09 +0800 Subject: [PATCH 0010/2741] fix: fix that discard all unstaged changes will drop changes staged --- src/Views/Widgets/Dashboard.xaml.cs | 1 - src/Views/Widgets/WorkingCopy.xaml.cs | 8 ++++++++ src/Views/Widgets/WorkingCopyChanges.xaml.cs | 13 +++++++++---- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/Views/Widgets/Dashboard.xaml.cs b/src/Views/Widgets/Dashboard.xaml.cs index 30ff38ca..3fc4cec3 100644 --- a/src/Views/Widgets/Dashboard.xaml.cs +++ b/src/Views/Widgets/Dashboard.xaml.cs @@ -10,7 +10,6 @@ using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; -using System.Windows.Shell; namespace SourceGit.Views.Widgets { diff --git a/src/Views/Widgets/WorkingCopy.xaml.cs b/src/Views/Widgets/WorkingCopy.xaml.cs index 95406fd4..5d7f583a 100644 --- a/src/Views/Widgets/WorkingCopy.xaml.cs +++ b/src/Views/Widgets/WorkingCopy.xaml.cs @@ -97,6 +97,14 @@ namespace SourceGit.Views.Widgets { if (watcher != null) watcher.RefreshWC(); } + public void Discard(List changes) { + if (changes.Count >= unstagedContainer.Changes.Count && stagedContainer.Changes.Count == 0) { + new Popups.Discard(repo.Path, null).Show(); + } else { + new Popups.Discard(repo.Path, changes).Show(); + } + } + #region STAGE_UNSTAGE private void ViewAssumeUnchanged(object sender, RoutedEventArgs e) { var dialog = new AssumeUnchanged(repo.Path); diff --git a/src/Views/Widgets/WorkingCopyChanges.xaml.cs b/src/Views/Widgets/WorkingCopyChanges.xaml.cs index 79b49c1a..36e8d126 100644 --- a/src/Views/Widgets/WorkingCopyChanges.xaml.cs +++ b/src/Views/Widgets/WorkingCopyChanges.xaml.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Input; +using System.Windows.Media; namespace SourceGit.Views.Widgets { /// @@ -385,10 +386,14 @@ namespace SourceGit.Views.Widgets { } private void Disard(List changes) { - if (changes.Count >= Changes.Count) { - new Popups.Discard(repo, null).Show(); - } else { - new Popups.Discard(repo, changes).Show(); + DependencyObject parent = VisualTreeHelper.GetParent(this); + while (parent != null) { + if (parent is WorkingCopy wc) { + wc.Discard(changes); + return; + } + + parent = VisualTreeHelper.GetParent(parent); } } From 12c33545bfa89cf5cbf0df75f8f82a71958f11a0 Mon Sep 17 00:00:00 2001 From: leo Date: Tue, 17 Oct 2023 20:32:24 +0800 Subject: [PATCH 0011/2741] upgrade: Release 6.9 --- src/SourceGit.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SourceGit.csproj b/src/SourceGit.csproj index f0808ae0..2835684a 100644 --- a/src/SourceGit.csproj +++ b/src/SourceGit.csproj @@ -9,7 +9,7 @@ OpenSource GIT client for Windows Copyright © sourcegit 2023. All rights reserved. App.manifest - 6.8 + 6.9 MIT SourceGit.App https://github.com/sourcegit-scm/sourcegit.git From 8fa3a558a074a265d3ad9ccab129d79adc0148cf Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 18 Oct 2023 11:05:50 +0800 Subject: [PATCH 0012/2741] style: add scrollbar for commit messages --- src/Views/Controls/ChangeStatusIcon.cs | 55 ++++++++++++-------------- src/Views/Widgets/CommitDetail.xaml | 5 ++- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/src/Views/Controls/ChangeStatusIcon.cs b/src/Views/Controls/ChangeStatusIcon.cs index 8b306098..793f4c2e 100644 --- a/src/Views/Controls/ChangeStatusIcon.cs +++ b/src/Views/Controls/ChangeStatusIcon.cs @@ -8,6 +8,27 @@ namespace SourceGit.Views.Controls { /// 变更状态图标 /// class ChangeStatusIcon : FrameworkElement { + public static readonly Brush[] Backgrounds = new Brush[] { + Brushes.Transparent, + new LinearGradientBrush(Color.FromRgb(238, 160, 14), Color.FromRgb(228, 172, 67), 90), + new LinearGradientBrush(Color.FromRgb(47, 185, 47), Color.FromRgb(75, 189, 75), 90), + new LinearGradientBrush(Colors.Tomato, Color.FromRgb(252, 165, 150), 90), + new LinearGradientBrush(Colors.Orchid, Color.FromRgb(248, 161, 245), 90), + new LinearGradientBrush(Color.FromRgb(238, 160, 14), Color.FromRgb(228, 172, 67), 90), + new LinearGradientBrush(Color.FromRgb(238, 160, 14), Color.FromRgb(228, 172, 67), 90), + new LinearGradientBrush(Color.FromRgb(47, 185, 47), Color.FromRgb(75, 189, 75), 90), + }; + + public static readonly string[] Labels = new string[] { + "?", + "±", + "+", + "−", + "➜", + "❏", + "U", + "★", + }; public static readonly DependencyProperty ChangeProperty = DependencyProperty.Register( "Change", @@ -62,12 +83,12 @@ namespace SourceGit.Views.Controls { icon.background = Brushes.OrangeRed; txt = "!"; } else { - icon.background = GetBackground(icon.Change.WorkTree); - txt = GetLabel(icon.Change.WorkTree); + icon.background = Backgrounds[(int)icon.Change.WorkTree]; + txt = Labels[(int)icon.Change.WorkTree]; } } else { - icon.background = GetBackground(icon.Change.Index); - txt = GetLabel(icon.Change.Index); + icon.background = Backgrounds[(int)icon.Change.Index]; + txt = Labels[(int)icon.Change.Index]; } icon.label = new FormattedText( @@ -81,31 +102,5 @@ namespace SourceGit.Views.Controls { icon.InvalidateVisual(); } - - private static Brush GetBackground(Models.Change.Status status) { - switch (status) { - case Models.Change.Status.Modified: return new LinearGradientBrush(Color.FromRgb(238, 160, 14), Color.FromRgb(228, 172, 67), 90); - case Models.Change.Status.Added: return new LinearGradientBrush(Color.FromRgb(47, 185, 47), Color.FromRgb(75, 189, 75), 90); - case Models.Change.Status.Deleted: return new LinearGradientBrush(Colors.Tomato, Color.FromRgb(252, 165, 150), 90); - case Models.Change.Status.Renamed: return new LinearGradientBrush(Colors.Orchid, Color.FromRgb(248, 161, 245), 90); - case Models.Change.Status.Copied: return new LinearGradientBrush(Color.FromRgb(238, 160, 14), Color.FromRgb(228, 172, 67), 90); - case Models.Change.Status.Unmerged: return new LinearGradientBrush(Color.FromRgb(238, 160, 14), Color.FromRgb(228, 172, 67), 90); - case Models.Change.Status.Untracked: return new LinearGradientBrush(Color.FromRgb(47, 185, 47), Color.FromRgb(75, 189, 75), 90); - default: return Brushes.Transparent; - } - } - - private static string GetLabel(Models.Change.Status status) { - switch (status) { - case Models.Change.Status.Modified: return "±"; - case Models.Change.Status.Added: return "+"; - case Models.Change.Status.Deleted: return "−"; - case Models.Change.Status.Renamed: return "➜"; - case Models.Change.Status.Copied: return "❏"; - case Models.Change.Status.Unmerged: return "U"; - case Models.Change.Status.Untracked: return "★"; - default: return "?"; - } - } } } diff --git a/src/Views/Widgets/CommitDetail.xaml b/src/Views/Widgets/CommitDetail.xaml index c3d4fb8c..4f046c6b 100644 --- a/src/Views/Widgets/CommitDetail.xaml +++ b/src/Views/Widgets/CommitDetail.xaml @@ -170,6 +170,9 @@ BorderThickness="0" TextWrapping="Wrap" Margin="11,5,16,0" + MaxHeight="80" + HorizontalScrollBarVisibility="Disabled" + VerticalScrollBarVisibility="Auto" VerticalAlignment="Top"/> @@ -193,7 +196,7 @@ Grid.Column="1" x:Name="changeList" RowHeight="24" - Margin="11,0,0,2"> + Margin="11,0,16,2"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/Styles/Button.xaml b/src/Resources/Styles/Button.xaml deleted file mode 100644 index 76aabb6e..00000000 --- a/src/Resources/Styles/Button.xaml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/ComboBox.xaml b/src/Resources/Styles/ComboBox.xaml deleted file mode 100644 index b2f36a3f..00000000 --- a/src/Resources/Styles/ComboBox.xaml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/ContextMenu.xaml b/src/Resources/Styles/ContextMenu.xaml deleted file mode 100644 index 2c5be7b9..00000000 --- a/src/Resources/Styles/ContextMenu.xaml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/DataGrid.xaml b/src/Resources/Styles/DataGrid.xaml deleted file mode 100644 index 1ca58ae3..00000000 --- a/src/Resources/Styles/DataGrid.xaml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/HyperLink.xaml b/src/Resources/Styles/HyperLink.xaml deleted file mode 100644 index fcba9d56..00000000 --- a/src/Resources/Styles/HyperLink.xaml +++ /dev/null @@ -1,12 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/IconButton.xaml b/src/Resources/Styles/IconButton.xaml deleted file mode 100644 index f530df8f..00000000 --- a/src/Resources/Styles/IconButton.xaml +++ /dev/null @@ -1,46 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/ListBox.xaml b/src/Resources/Styles/ListBox.xaml deleted file mode 100644 index 2fda7c79..00000000 --- a/src/Resources/Styles/ListBox.xaml +++ /dev/null @@ -1,26 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/ListView.xaml b/src/Resources/Styles/ListView.xaml deleted file mode 100644 index 3c81d5af..00000000 --- a/src/Resources/Styles/ListView.xaml +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/Path.xaml b/src/Resources/Styles/Path.xaml deleted file mode 100644 index a91dee33..00000000 --- a/src/Resources/Styles/Path.xaml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/RadioButton.xaml b/src/Resources/Styles/RadioButton.xaml deleted file mode 100644 index f935b69f..00000000 --- a/src/Resources/Styles/RadioButton.xaml +++ /dev/null @@ -1,52 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/ScrollBar.xaml b/src/Resources/Styles/ScrollBar.xaml deleted file mode 100644 index 464ac563..00000000 --- a/src/Resources/Styles/ScrollBar.xaml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/ScrollViewer.xaml b/src/Resources/Styles/ScrollViewer.xaml deleted file mode 100644 index b1d1aecb..00000000 --- a/src/Resources/Styles/ScrollViewer.xaml +++ /dev/null @@ -1,52 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/Slider.xaml b/src/Resources/Styles/Slider.xaml deleted file mode 100644 index c54077e5..00000000 --- a/src/Resources/Styles/Slider.xaml +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/TabControl.xaml b/src/Resources/Styles/TabControl.xaml deleted file mode 100644 index 69cc26a8..00000000 --- a/src/Resources/Styles/TabControl.xaml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/TextBlock.xaml b/src/Resources/Styles/TextBlock.xaml deleted file mode 100644 index b2540c91..00000000 --- a/src/Resources/Styles/TextBlock.xaml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/TextBox.xaml b/src/Resources/Styles/TextBox.xaml deleted file mode 100644 index 3d95136d..00000000 --- a/src/Resources/Styles/TextBox.xaml +++ /dev/null @@ -1,107 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/ToggleButton.xaml b/src/Resources/Styles/ToggleButton.xaml deleted file mode 100644 index f284536a..00000000 --- a/src/Resources/Styles/ToggleButton.xaml +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/Tooltip.xaml b/src/Resources/Styles/Tooltip.xaml deleted file mode 100644 index 4ce7cf0a..00000000 --- a/src/Resources/Styles/Tooltip.xaml +++ /dev/null @@ -1,22 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/Tree.xaml b/src/Resources/Styles/Tree.xaml deleted file mode 100644 index d0980c64..00000000 --- a/src/Resources/Styles/Tree.xaml +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/Window.xaml b/src/Resources/Styles/Window.xaml deleted file mode 100644 index 710e1f58..00000000 --- a/src/Resources/Styles/Window.xaml +++ /dev/null @@ -1,40 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/Resources/Themes.axaml b/src/Resources/Themes.axaml new file mode 100644 index 00000000..88f38d8c --- /dev/null +++ b/src/Resources/Themes.axaml @@ -0,0 +1,95 @@ + + + + #FFFF6059 + #FFFFBE2F + #FF29c941 + #FFF0F5F9 + #FFCFDEEA + #FFF0F5F9 + #FFF8F8F8 + #FFFAFAFA + #FFB0CEE8 + #FF6F6F6F + #FFF8F8F8 + #FF836C2E + #FFDFDFDF + #FFCFCFCF + #FFCFCFCF + #FF898989 + #FFCFCFCF + #FFEFEFEF + #FFF8F8F8 + White + #FF4295FF + #FF529DFB + #FF1F1F1F + #FF6F6F6F + #FFFFFFFF + #FF4295FF + #FF529DFB + #4C007ACC + + + + #FFFF5E56 + #FFFCBB2D + #FF25C53C + #FF252525 + #FF1F1F1F + #FF2C2C2C + #FF2B2B2B + #FF181818 + #FF8F8F8F + #FF505050 + #FFF8F8F8 + #FFFAFAD2 + #FF323232 + #FF3F3F3F + #FF181818 + #FF7C7C7C + #FF404040 + #FF252525 + #FF303030 + #FF333333 + #FF3A3A3A + #FF404040 + #FFF1F1F1 + #40F1F1F1 + #FF252525 + #FF007ACC + #FF006BBE + #8C007ACC + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/Themes/Dark.xaml b/src/Resources/Themes/Dark.xaml deleted file mode 100644 index 657a3845..00000000 --- a/src/Resources/Themes/Dark.xaml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Themes/Light.xaml b/src/Resources/Themes/Light.xaml deleted file mode 100644 index eb80a516..00000000 --- a/src/Resources/Themes/Light.xaml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/SourceGit.csproj b/src/SourceGit.csproj index a5e7e0bd..e32977e5 100644 --- a/src/SourceGit.csproj +++ b/src/SourceGit.csproj @@ -1,30 +1,34 @@ - - - net48 - WinExe - true - true - App.ico - sourcegit - OpenSource GIT client for Windows - Copyright © sourcegit 2023. All rights reserved. - App.manifest - 7.0 - MIT - SourceGit.App - https://github.com/sourcegit-scm/sourcegit.git - https://github.com/sourcegit-scm/sourcegit.git - Public - true - none - false - false - - - - - - - - - \ No newline at end of file + + + WinExe + net8.0 + true + App.manifest + App.ico + 8.0 + false + true + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SourceGit.sln b/src/SourceGit.sln new file mode 100644 index 00000000..13ea93f0 --- /dev/null +++ b/src/SourceGit.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34408.163 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGit", "SourceGit.csproj", "{89AD3F88-E72C-4399-AD61-6A87FC424E7B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {89AD3F88-E72C-4399-AD61-6A87FC424E7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {89AD3F88-E72C-4399-AD61-6A87FC424E7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89AD3F88-E72C-4399-AD61-6A87FC424E7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {89AD3F88-E72C-4399-AD61-6A87FC424E7B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DA70C1D9-A8D2-4C89-98F3-B263CCBC5F28} + EndGlobalSection +EndGlobal diff --git a/src/ViewModels/AddRemote.cs b/src/ViewModels/AddRemote.cs new file mode 100644 index 00000000..8bb2652a --- /dev/null +++ b/src/ViewModels/AddRemote.cs @@ -0,0 +1,82 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class AddRemote : Popup { + [Required(ErrorMessage = "Remote name is required!!!")] + [RegularExpression(@"^[\w\-\.]+$", ErrorMessage = "Bad remote name format!!!")] + [CustomValidation(typeof(AddRemote), nameof(ValidateRemoteName))] + public string Name { + get => _name; + set => SetProperty(ref _name, value, true); + } + + [Required(ErrorMessage = "Remote URL is required!!!")] + [CustomValidation(typeof(AddRemote), nameof(ValidateRemoteURL))] + public string Url { + get => _url; + set { + if (SetProperty(ref _url, value, true)) UseSSH = Models.Remote.IsSSH(value); + } + } + + public bool UseSSH { + get => _useSSH; + set => SetProperty(ref _useSSH, value); + } + + public string SSHKey { + get; + set; + } + + public AddRemote(Repository repo) { + _repo = repo; + View = new Views.AddRemote() { DataContext = this }; + } + + public static ValidationResult ValidateRemoteName(string name, ValidationContext ctx) { + if (ctx.ObjectInstance is AddRemote add) { + var exists = add._repo.Remotes.Find(x => x.Name == name); + if (exists != null) return new ValidationResult("A remote with given name already exists!!!"); + } + + return ValidationResult.Success; + } + + public static ValidationResult ValidateRemoteURL(string url, ValidationContext ctx) { + if (ctx.ObjectInstance is AddRemote add) { + if (!Models.Remote.IsValidURL(url)) return new ValidationResult("Bad remote URL format!!!"); + + var exists = add._repo.Remotes.Find(x => x.URL == url); + if (exists != null) return new ValidationResult("A remote with the same url already exists!!!"); + } + + return ValidationResult.Success; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription("Adding remote ..."); + var succ = new Commands.Remote(_repo.FullPath).Add(_name, _url); + if (succ) { + SetProgressDescription("Fetching from added remote ..."); + new Commands.Fetch(_repo.FullPath, _name, true, SetProgressDescription).Exec(); + + if (_useSSH) { + SetProgressDescription("Post processing ..."); + new Commands.Config(_repo.FullPath).Set($"remote.{_name}.sshkey", SSHKey); + } + } + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private string _name = string.Empty; + private string _url = string.Empty; + private bool _useSSH = false; + } +} diff --git a/src/ViewModels/AddSubmodule.cs b/src/ViewModels/AddSubmodule.cs new file mode 100644 index 00000000..1e2e1c02 --- /dev/null +++ b/src/ViewModels/AddSubmodule.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class AddSubmodule : Popup { + [Required(ErrorMessage = "Url is required!!!")] + [CustomValidation(typeof(AddSubmodule), nameof(ValidateURL))] + public string Url { + get => _url; + set => SetProperty(ref _url, value, true); + } + + [Required(ErrorMessage = "Reletive path is required!!!")] + [CustomValidation(typeof(AddSubmodule), nameof(ValidateRelativePath))] + public string RelativePath { + get => _relativePath; + set => SetProperty(ref _relativePath, value, true); + } + + public bool Recursive { + get; + set; + } + + public AddSubmodule(Repository repo) { + _repo = repo; + View = new Views.AddSubmodule() { DataContext = this }; + } + + public static ValidationResult ValidateURL(string url, ValidationContext ctx) { + if (!Models.Remote.IsValidURL(url)) return new ValidationResult("Invalid repository URL format"); + return ValidationResult.Success; + } + + public static ValidationResult ValidateRelativePath(string path, ValidationContext ctx) { + if (Path.Exists(path)) { + return new ValidationResult("Give path is exists already!"); + } + + if (Path.IsPathRooted(path)) { + return new ValidationResult("Path must be relative to this repository!"); + } + + return ValidationResult.Success; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + var succ = new Commands.Submodule(_repo.FullPath).Add(_url, _relativePath, Recursive, SetProgressDescription); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private string _url = string.Empty; + private string _relativePath = string.Empty; + } +} diff --git a/src/ViewModels/Apply.cs b/src/ViewModels/Apply.cs new file mode 100644 index 00000000..c3685426 --- /dev/null +++ b/src/ViewModels/Apply.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Apply : Popup { + [Required(ErrorMessage = "Patch file is required!!!")] + [CustomValidation(typeof(Apply), nameof(ValidatePatchFile))] + public string PatchFile { + get => _patchFile; + set => SetProperty(ref _patchFile, value, true); + } + + public bool IgnoreWhiteSpace { + get => _ignoreWhiteSpace; + set => SetProperty(ref _ignoreWhiteSpace, value); + } + + public List WhiteSpaceModes { + get; + private set; + } + + public Models.ApplyWhiteSpaceMode SelectedWhiteSpaceMode { + get; + set; + } + + public Apply(Repository repo) { + _repo = repo; + + WhiteSpaceModes = new List { + new Models.ApplyWhiteSpaceMode("Apply.NoWarn", "Apply.NoWarn.Desc", "nowarn"), + new Models.ApplyWhiteSpaceMode("Apply.Warn", "Apply.Warn.Desc", "warn"), + new Models.ApplyWhiteSpaceMode("Apply.Error", "Apply.Error.Desc", "error"), + new Models.ApplyWhiteSpaceMode("Apply.ErrorAll", "Apply.ErrorAll.Desc", "error-all") + }; + SelectedWhiteSpaceMode = WhiteSpaceModes[0]; + + View = new Views.Apply() { DataContext = this }; + } + + public static ValidationResult ValidatePatchFile(string file, ValidationContext _) { + if (File.Exists(file)) { + return ValidationResult.Success; + } + + return new ValidationResult($"File '{file}' can NOT be found!!!"); + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + var succ = new Commands.Apply(_repo.FullPath, _patchFile, _ignoreWhiteSpace, SelectedWhiteSpaceMode.Arg).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private string _patchFile = string.Empty; + private bool _ignoreWhiteSpace = true; + } +} diff --git a/src/ViewModels/Archive.cs b/src/ViewModels/Archive.cs new file mode 100644 index 00000000..3661a978 --- /dev/null +++ b/src/ViewModels/Archive.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Archive : Popup { + + [Required(ErrorMessage = "Output file name is required")] + public string SaveFile { + get => _saveFile; + set => SetProperty(ref _saveFile, value, true); + } + + public object BasedOn { + get; + private set; + } + + public Archive(Repository repo, Models.Branch branch) { + _repo = repo; + _revision = branch.Head; + _saveFile = $"archive-{Path.GetFileNameWithoutExtension(branch.Name)}.zip"; + BasedOn = branch; + View = new Views.Archive() { DataContext = this }; + } + + public Archive(Repository repo, Models.Commit commit) { + _repo = repo; + _revision = commit.SHA; + _saveFile = $"archive-{commit.SHA.Substring(0,10)}.zip"; + BasedOn = commit; + View = new Views.Archive() { DataContext = this }; + } + + public Archive(Repository repo, Models.Tag tag) { + _repo = repo; + _revision = tag.SHA; + _saveFile = $"archive-{tag.Name}.zip"; + BasedOn = tag; + View = new Views.Archive() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + var succ = new Commands.Archive(_repo.FullPath, _revision, _saveFile, SetProgressDescription).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private string _saveFile = string.Empty; + private string _revision = string.Empty; + } +} diff --git a/src/ViewModels/AssumeUnchangedManager.cs b/src/ViewModels/AssumeUnchangedManager.cs new file mode 100644 index 00000000..f633de75 --- /dev/null +++ b/src/ViewModels/AssumeUnchangedManager.cs @@ -0,0 +1,30 @@ +using Avalonia.Collections; +using Avalonia.Threading; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class AssumeUnchangedManager { + public AvaloniaList Files { get; private set; } + + public AssumeUnchangedManager(string repo) { + _repo = repo; + Files = new AvaloniaList(); + + Task.Run(() => { + var collect = new Commands.AssumeUnchanged(_repo).View(); + Dispatcher.UIThread.Invoke(() => { + Files.AddRange(collect); + }); + }); + } + + public void Remove(object param) { + if (param is string file) { + new Commands.AssumeUnchanged(_repo).Remove(file); + Files.Remove(file); + } + } + + private string _repo; + } +} diff --git a/src/ViewModels/Blame.cs b/src/ViewModels/Blame.cs new file mode 100644 index 00000000..3aa1e66e --- /dev/null +++ b/src/ViewModels/Blame.cs @@ -0,0 +1,48 @@ +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Blame : ObservableObject { + public string Title { + get; + private set; + } + + public string SelectedSHA { + get => _selectedSHA; + private set => SetProperty(ref _selectedSHA, value); + } + + public bool IsBinary { + get => _data != null && _data.IsBinary; + } + + public Models.BlameData Data { + get => _data; + private set => SetProperty(ref _data, value); + } + + public Blame(string repo, string file, string revision) { + _repo = repo; + + Title = $"{file}@{revision.Substring(0, 10)}"; + Task.Run(() => { + var result = new Commands.Blame(repo, file, revision).Result(); + Dispatcher.UIThread.Invoke(() => { + Data = result; + OnPropertyChanged(nameof(IsBinary)); + }); + }); + } + + public void NavigateToCommit(string commitSHA) { + var repo = Preference.FindRepository(_repo); + if (repo != null) repo.NavigateToCommit(commitSHA); + } + + private string _repo = string.Empty; + private string _selectedSHA = string.Empty; + private Models.BlameData _data = null; + } +} diff --git a/src/ViewModels/Checkout.cs b/src/ViewModels/Checkout.cs new file mode 100644 index 00000000..33c70d6b --- /dev/null +++ b/src/ViewModels/Checkout.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Checkout : Popup { + public string Branch { + get; + private set; + } + + public Checkout(Repository repo, string branch) { + _repo = repo; + Branch = branch; + View = new Views.Checkout() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription($"Checkout '{Branch}' ..."); + var succ = new Commands.Checkout(_repo.FullPath).Branch(Branch, SetProgressDescription); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo; + } +} diff --git a/src/ViewModels/CherryPick.cs b/src/ViewModels/CherryPick.cs new file mode 100644 index 00000000..658c1e0e --- /dev/null +++ b/src/ViewModels/CherryPick.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class CherryPick : Popup { + public Models.Commit Target { + get; + private set; + } + + public bool AutoCommit { + get; + set; + } + + public CherryPick(Repository repo, Models.Commit target) { + _repo = repo; + Target = target; + AutoCommit = true; + View = new Views.CherryPick() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription($"Cherry-Pick commit '{Target.SHA}' ..."); + var succ = new Commands.CherryPick(_repo.FullPath, Target.SHA, !AutoCommit).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/Cleanup.cs b/src/ViewModels/Cleanup.cs new file mode 100644 index 00000000..07561d5e --- /dev/null +++ b/src/ViewModels/Cleanup.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Cleanup : Popup { + public Cleanup(Repository repo) { + _repo = repo; + View = new Views.Cleanup() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Cleanup (GC & prune) ..."; + return Task.Run(() => { + SetProgressDescription("Run GC ..."); + new Commands.GC(_repo.FullPath, SetProgressDescription).Exec(); + + var lfs = new Commands.LFS(_repo.FullPath); + if (lfs.IsEnabled()) { + SetProgressDescription("Run LFS prune ..."); + lfs.Prune(SetProgressDescription); + } + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/ClearStashes.cs b/src/ViewModels/ClearStashes.cs new file mode 100644 index 00000000..ba10adbf --- /dev/null +++ b/src/ViewModels/ClearStashes.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class ClearStashes : Popup { + public ClearStashes(Repository repo) { + _repo = repo; + View = new Views.ClearStashes() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + new Commands.Stash(_repo.FullPath).Clear(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/Clone.cs b/src/ViewModels/Clone.cs new file mode 100644 index 00000000..3a7de344 --- /dev/null +++ b/src/ViewModels/Clone.cs @@ -0,0 +1,111 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Clone : Popup { + [Required(ErrorMessage = "Remote URL is required")] + [CustomValidation(typeof(Clone), nameof(ValidateRemote))] + public string Remote { + get => _remote; + set { + if (SetProperty(ref _remote, value, true)) UseSSH = Models.Remote.IsSSH(value); + } + } + + public bool UseSSH { + get => _useSSH; + set => SetProperty(ref _useSSH, value); + } + + public string SSHKey { + get => _sshKey; + set => SetProperty(ref _sshKey, value); + } + + [Required(ErrorMessage = "Parent folder is required")] + [CustomValidation(typeof(Clone), nameof(ValidateParentFolder))] + public string ParentFolder { + get => _parentFolder; + set => SetProperty(ref _parentFolder, value, true); + } + + public string Local { + get => _local; + set => SetProperty(ref _local, value); + } + + public string ExtraArgs { + get => _extraArgs; + set => SetProperty(ref _extraArgs, value); + } + + public Clone(LauncherPage page) { + View = new Views.Clone() { DataContext = this }; + _page = page; + } + + public static ValidationResult ValidateRemote(string remote, ValidationContext _) { + if (!Models.Remote.IsValidURL(remote)) return new ValidationResult("Invalid remote repository URL format"); + return ValidationResult.Success; + } + + public static ValidationResult ValidateParentFolder(string folder, ValidationContext _) { + if (!Directory.Exists(folder)) return new ValidationResult("Given path can NOT be found"); + return ValidationResult.Success; + } + + public override Task Sure() { + return Task.Run(() => { + var cmd = new Commands.Clone(HostPageId, _parentFolder, _remote, _local, _useSSH ? _sshKey : "", _extraArgs, SetProgressDescription); + if (!cmd.Exec()) return false; + + var path = _parentFolder; + if (!string.IsNullOrEmpty(_local)) { + path = Path.GetFullPath(Path.Combine(path, _local)); + } else { + var name = Path.GetFileName(_remote); + if (name.EndsWith(".git")) name = name.Substring(0, name.Length - 4); + path = Path.GetFullPath(Path.Combine(path, name)); + } + + if (!Directory.Exists(path)) { + CallUIThread(() => { + App.RaiseException(HostPageId, $"Folder '{path}' can NOT be found"); + }); + return false; + } + + if (_useSSH && !string.IsNullOrEmpty(_sshKey)) { + var config = new Commands.Config(path); + config.Set("remote.origin.sshkey", _sshKey); + } + + CallUIThread(() => { + var repo = Preference.AddRepository(path, Path.Combine(path, ".git")); + var node = new RepositoryNode() { + Id = path, + Name = Path.GetFileName(path), + Bookmark = 0, + IsRepository = true, + }; + Preference.AddNode(node); + + _page.View = new Views.Repository() { DataContext = repo }; + _page.Node = node; + }); + + return true; + }); + } + + private LauncherPage _page = null; + private string _remote = string.Empty; + private bool _useSSH = false; + private string _sshKey = string.Empty; + private string _parentFolder = Preference.Instance.GitDefaultCloneDir; + private string _local = string.Empty; + private string _extraArgs = string.Empty; + } +} diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs new file mode 100644 index 00000000..602f1db3 --- /dev/null +++ b/src/ViewModels/CommitDetail.cs @@ -0,0 +1,408 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class CommitDetail : ObservableObject { + public DiffContext DiffContext { + get => _diffContext; + private set => SetProperty(ref _diffContext, value); + } + + public int ActivePageIndex { + get => _activePageIndex; + set => SetProperty(ref _activePageIndex, value); + } + + public Models.Commit Commit { + get => _commit; + set { + if (SetProperty(ref _commit, value)) Refresh(); + } + } + + public List Changes { + get => _changes; + set => SetProperty(ref _changes, value); + } + + public List VisibleChanges { + get => _visibleChanges; + set => SetProperty(ref _visibleChanges, value); + } + + public List ChangeTree { + get => _changeTree; + set => SetProperty(ref _changeTree, value); + } + + public Models.Change SelectedChange { + get => _selectedChange; + set { + if (SetProperty(ref _selectedChange, value)) { + if (value == null) { + SelectedChangeNode = null; + DiffContext = null; + } else { + SelectedChangeNode = FileTreeNode.SelectByPath(_changeTree, value.Path); + DiffContext = new DiffContext(_repo, new Models.DiffOption(_commit, value)); + } + } + } + } + + public FileTreeNode SelectedChangeNode { + get => _selectedChangeNode; + set { + if (SetProperty(ref _selectedChangeNode, value)) { + if (value == null) { + SelectedChange = null; + } else { + SelectedChange = value.Backend as Models.Change; + } + } + } + } + + public string SearchChangeFilter { + get => _searchChangeFilter; + set { + if (SetProperty(ref _searchChangeFilter, value)) { + RefreshVisibleChanges(); + } + } + } + + public List RevisionFilesTree { + get => _revisionFilesTree; + set => SetProperty(ref _revisionFilesTree, value); + } + + public FileTreeNode SelectedRevisionFileNode { + get => _selectedRevisionFileNode; + set { + if (SetProperty(ref _selectedRevisionFileNode, value) && value != null && !value.IsFolder) { + RefreshViewRevisionFile(value.Backend as Models.Object); + } else { + ViewRevisionFileContent = null; + } + } + } + + public string SearchFileFilter { + get => _searchFileFilter; + set { + if (SetProperty(ref _searchFileFilter, value)) { + RefreshVisibleFiles(); + } + } + } + + public object ViewRevisionFileContent { + get => _viewRevisionFileContent; + set => SetProperty(ref _viewRevisionFileContent, value); + } + + public CommitDetail(string repo) { + _repo = repo; + } + + public void NavigateTo(string commitSHA) { + var repo = Preference.FindRepository(_repo); + if (repo != null) repo.NavigateToCommit(commitSHA); + } + + public void ClearSearchChangeFilter() { + SearchChangeFilter = string.Empty; + } + + public void ClearSearchFileFilter() { + SearchFileFilter = string.Empty; + } + + public ContextMenu CreateChangeContextMenu(Models.Change change) { + var menu = new ContextMenu(); + + if (change.Index != Models.ChangeState.Deleted) { + var history = new MenuItem(); + history.Header = App.Text("FileHistory"); + history.Icon = CreateMenuIcon("Icons.Histories"); + history.Click += (_, ev) => { + var window = new Views.FileHistories() { DataContext = new FileHistories(_repo, change.Path) }; + window.Show(); + ev.Handled = true; + }; + + var blame = new MenuItem(); + blame.Header = App.Text("Blame"); + blame.Icon = CreateMenuIcon("Icons.Blame"); + blame.Click += (o, ev) => { + var window = new Views.Blame() { DataContext = new Blame(_repo, change.Path, _commit.SHA) }; + window.Show(); + ev.Handled = true; + }; + + var full = Path.GetFullPath(Path.Combine(_repo, change.Path)); + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = CreateMenuIcon("Icons.Folder.Open"); + explore.IsEnabled = File.Exists(full); + explore.Click += (_, ev) => { + Native.OS.OpenInFileManager(full, true); + ev.Handled = true; + }; + + menu.Items.Add(history); + menu.Items.Add(blame); + menu.Items.Add(explore); + } + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = CreateMenuIcon("Icons.Copy"); + copyPath.Click += (_, ev) => { + App.CopyText(change.Path); + ev.Handled = true; + }; + + menu.Items.Add(copyPath); + return menu; + } + + public ContextMenu CreateRevisionFileContextMenu(Models.Object file) { + var history = new MenuItem(); + history.Header = App.Text("FileHistory"); + history.Icon = CreateMenuIcon("Icons.Histories"); + history.Click += (_, ev) => { + var window = new Views.FileHistories() { DataContext = new FileHistories(_repo, file.Path) }; + window.Show(); + ev.Handled = true; + }; + + var blame = new MenuItem(); + blame.Header = App.Text("Blame"); + blame.Icon = CreateMenuIcon("Icons.Blame"); + blame.Click += (o, ev) => { + var window = new Views.Blame() { DataContext = new Blame(_repo, file.Path, _commit.SHA) }; + window.Show(); + ev.Handled = true; + }; + + var full = Path.GetFullPath(Path.Combine(_repo, file.Path)); + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = CreateMenuIcon("Icons.Folder.Open"); + explore.Click += (_, ev) => { + Native.OS.OpenInFileManager(full, file.Type == Models.ObjectType.Blob); + ev.Handled = true; + }; + + var saveAs = new MenuItem(); + saveAs.Header = App.Text("SaveAs"); + saveAs.Icon = CreateMenuIcon("Icons.Save"); + saveAs.IsEnabled = file.Type == Models.ObjectType.Blob; + saveAs.Click += async (_, ev) => { + var topLevel = App.GetTopLevel(); + if (topLevel == null) return; + + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + var selected = await topLevel.StorageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) { + var saveTo = Path.Combine(selected[0].Path.LocalPath, Path.GetFileName(file.Path)); + Commands.SaveRevisionFile.Run(_repo, _commit.SHA, file.Path, saveTo); + } + + ev.Handled = true; + }; + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = CreateMenuIcon("Icons.Copy"); + copyPath.Click += (_, ev) => { + App.CopyText(file.Path); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(history); + menu.Items.Add(blame); + menu.Items.Add(explore); + menu.Items.Add(saveAs); + menu.Items.Add(copyPath); + return menu; + } + + private void Refresh() { + _changes = null; + VisibleChanges = null; + SelectedChange = null; + RevisionFilesTree = null; + SelectedRevisionFileNode = null; + if (_commit == null) return; + if (_cancelToken != null) _cancelToken.Requested = true; + + _cancelToken = new Commands.Command.CancelToken(); + var cmdChanges = new Commands.QueryCommitChanges(_repo, _commit.SHA) { Cancel = _cancelToken }; + var cmdRevisionFiles = new Commands.QueryRevisionObjects(_repo, _commit.SHA) { Cancel = _cancelToken }; + + Task.Run(() => { + var changes = cmdChanges.Result(); + if (cmdChanges.Cancel.Requested) return; + + var visible = changes; + if (!string.IsNullOrWhiteSpace(_searchChangeFilter)) { + visible = new List(); + foreach (var c in changes) { + if (c.Path.Contains(_searchChangeFilter, StringComparison.OrdinalIgnoreCase)) { + visible.Add(c); + } + } + } + + var tree = FileTreeNode.Build(visible); + Dispatcher.UIThread.Invoke(() => { + Changes = changes; + VisibleChanges = visible; + ChangeTree = tree; + }); + }); + + Task.Run(() => { + var files = cmdRevisionFiles.Result(); + if (cmdRevisionFiles.Cancel.Requested) return; + + var visible = files; + if (!string.IsNullOrWhiteSpace(_searchFileFilter)) { + visible = new List(); + foreach (var f in files) { + if (f.Path.Contains(_searchFileFilter, StringComparison.OrdinalIgnoreCase)) { + visible.Add(f); + } + } + } + + var tree = FileTreeNode.Build(visible); + Dispatcher.UIThread.Invoke(() => { + _revisionFiles = files; + RevisionFilesTree = tree; + }); + }); + } + + private void RefreshVisibleChanges() { + if (_changes == null) return; + + if (string.IsNullOrEmpty(_searchChangeFilter)) { + VisibleChanges = _changes; + } else { + var visible = new List(); + foreach (var c in _changes) { + if (c.Path.Contains(_searchChangeFilter, StringComparison.OrdinalIgnoreCase)) { + visible.Add(c); + } + } + + VisibleChanges = visible; + } + + ChangeTree = FileTreeNode.Build(_visibleChanges); + } + + private void RefreshVisibleFiles() { + if (_revisionFiles == null) return; + + var visible = _revisionFiles; + if (!string.IsNullOrWhiteSpace(_searchFileFilter)) { + visible = new List(); + foreach (var f in _revisionFiles) { + if (f.Path.Contains(_searchFileFilter, StringComparison.OrdinalIgnoreCase)) { + visible.Add(f); + } + } + } + + RevisionFilesTree = FileTreeNode.Build(visible); + } + + private void RefreshViewRevisionFile(Models.Object file) { + switch (file.Type) { + case Models.ObjectType.Blob: + Task.Run(() => { + var isBinary = new Commands.IsBinary(_repo, _commit.SHA, file.Path).Result(); + if (isBinary) { + Dispatcher.UIThread.Invoke(() => { + ViewRevisionFileContent = new Models.RevisionBinaryFile(); + }); + return; + } + + var content = new Commands.QueryFileContent(_repo, _commit.SHA, file.Path).Result(); + if (content.StartsWith("version https://git-lfs.github.com/spec/", StringComparison.OrdinalIgnoreCase)) { + var obj = new Models.RevisionLFSObject() { Object = new Models.LFSObject() }; + var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (lines.Length == 3) { + foreach (var line in lines) { + if (line.StartsWith("oid sha256:")) { + obj.Object.Oid = line.Substring(11); + } else if (line.StartsWith("size ")) { + obj.Object.Size = long.Parse(line.Substring(5)); + } + } + Dispatcher.UIThread.Invoke(() => { + ViewRevisionFileContent = obj; + }); + return; + } + } + + Dispatcher.UIThread.Invoke(() => { + ViewRevisionFileContent = new Models.RevisionTextFile() { + FileName = file.Path, + Content = content + }; + }); + }); + break; + case Models.ObjectType.Commit: + ViewRevisionFileContent = new Models.RevisionSubmodule() { SHA = file.SHA }; + break; + default: + ViewRevisionFileContent = null; + break; + } + } + + private Avalonia.Controls.Shapes.Path CreateMenuIcon(string key) { + var icon = new Avalonia.Controls.Shapes.Path(); + icon.Width = 12; + icon.Height = 12; + icon.Stretch = Stretch.Uniform; + icon.Data = App.Current?.FindResource(key) as StreamGeometry; + return icon; + } + + private string _repo = string.Empty; + private int _activePageIndex = 0; + private Models.Commit _commit = null; + private List _changes = null; + private List _visibleChanges = null; + private List _changeTree = null; + private Models.Change _selectedChange = null; + private FileTreeNode _selectedChangeNode = null; + private string _searchChangeFilter = string.Empty; + private DiffContext _diffContext = null; + private List _revisionFiles = null; + private List _revisionFilesTree = null; + private FileTreeNode _selectedRevisionFileNode = null; + private string _searchFileFilter = string.Empty; + private object _viewRevisionFileContent = null; + private Commands.Command.CancelToken _cancelToken = null; + } +} diff --git a/src/ViewModels/CreateBranch.cs b/src/ViewModels/CreateBranch.cs new file mode 100644 index 00000000..73d19ca4 --- /dev/null +++ b/src/ViewModels/CreateBranch.cs @@ -0,0 +1,111 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class CreateBranch : Popup { + [Required(ErrorMessage = "Branch name is required!")] + [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] + [CustomValidation(typeof(CreateBranch), nameof(ValidateBranchName))] + public string Name { + get => _name; + set => SetProperty(ref _name, value, true); + } + + public object BasedOn { + get; + private set; + } + + public bool CheckoutAfterCreated { + get; + set; + } = true; + + public bool AutoStash { + get; + set; + } = true; + + public CreateBranch(Repository repo, Models.Branch branch) { + _repo = repo; + _baseOnRevision = branch.Head; + + BasedOn = branch; + View = new Views.CreateBranch() { DataContext = this }; + } + + public CreateBranch(Repository repo, Models.Commit commit) { + _repo = repo; + _baseOnRevision = commit.SHA; + + BasedOn = commit; + View = new Views.CreateBranch() { DataContext = this }; + } + + public CreateBranch(Repository repo, Models.Tag tag) { + _repo = repo; + _baseOnRevision = tag.SHA; + + BasedOn = tag; + View = new Views.CreateBranch() { DataContext = this }; + } + + public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) { + var creator = ctx.ObjectInstance as CreateBranch; + if (creator == null) return new ValidationResult("Missing runtime context to create branch!"); + + foreach (var b in creator._repo.Branches) { + var test = b.IsLocal ? b.Name : $"{b.Remote}/{b.Name}"; + if (test == name) return new ValidationResult("A branch with same name already exists!"); + } + + return ValidationResult.Success; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + if (CheckoutAfterCreated) { + bool needPopStash = false; + if (_repo.WorkingCopyChangesCount > 0) { + if (AutoStash) { + SetProgressDescription("Adding untracked changes..."); + var succ = new Commands.Add(_repo.FullPath).Exec(); + if (succ) { + SetProgressDescription("Stash local changes"); + succ = new Commands.Stash(_repo.FullPath).Push("CREATE_BRANCH_AUTO_STASH"); + } + + if (!succ) { + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return false; + } + + needPopStash = true; + } else { + SetProgressDescription("Discard local changes..."); + Commands.Discard.All(_repo.FullPath); + } + } + + SetProgressDescription($"Create new branch '{_name}'"); + new Commands.Checkout(_repo.FullPath).Branch(_name, _baseOnRevision, SetProgressDescription); + + if (needPopStash) { + SetProgressDescription("Re-apply local changes..."); + new Commands.Stash(_repo.FullPath).Pop("stash@{0}"); + } + } else { + Commands.Branch.Create(_repo.FullPath, _name, _baseOnRevision); + } + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + private string _name = null; + private string _baseOnRevision = null; + } +} diff --git a/src/ViewModels/CreateGroup.cs b/src/ViewModels/CreateGroup.cs new file mode 100644 index 00000000..a398f610 --- /dev/null +++ b/src/ViewModels/CreateGroup.cs @@ -0,0 +1,32 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class CreateGroup : Popup { + [Required(ErrorMessage = "Group name is required!")] + public string Name { + get => _name; + set => SetProperty(ref _name, value, true); + } + + public CreateGroup(RepositoryNode parent) { + _parent = parent; + View = new Views.CreateGroup() { DataContext = this }; + } + + public override Task Sure() { + Preference.AddNode(new RepositoryNode() { + Id = Guid.NewGuid().ToString(), + Name = _name, + IsRepository = false, + IsExpanded = false, + }, _parent); + + return null; + } + + private RepositoryNode _parent = null; + private string _name = string.Empty; + } +} diff --git a/src/ViewModels/CreateTag.cs b/src/ViewModels/CreateTag.cs new file mode 100644 index 00000000..ef33d0b3 --- /dev/null +++ b/src/ViewModels/CreateTag.cs @@ -0,0 +1,64 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class CreateTag : Popup { + [Required(ErrorMessage = "Tag name is required!")] + [RegularExpression(@"^[\w\-\.]+$", ErrorMessage = "Bad tag name format!")] + [CustomValidation(typeof(CreateTag), nameof(ValidateTagName))] + public string TagName { + get => _tagName; + set => SetProperty(ref _tagName, value, true); + } + + public string Message { + get; + set; + } + + public object BasedOn { + get; + private set; + } + + public CreateTag(Repository repo, Models.Branch branch) { + _repo = repo; + _basedOn = branch.Head; + + BasedOn = branch; + View = new Views.CreateTag() { DataContext = this }; + } + + public CreateTag(Repository repo, Models.Commit commit) { + _repo = repo; + _basedOn = commit.SHA; + + BasedOn = commit; + View = new Views.CreateTag() { DataContext = this }; + } + + public static ValidationResult ValidateTagName(string name, ValidationContext ctx) { + var creator = ctx.ObjectInstance as CreateTag; + if (creator != null) { + var found = creator._repo.Tags.Find(x => x.Name == name); + if (found != null) return new ValidationResult("A tag with same name already exists!"); + } + return ValidationResult.Success; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + Commands.Tag.Add(_repo.FullPath, TagName, _basedOn, Message); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + private string _tagName = string.Empty; + private string _basedOn = string.Empty; + } +} diff --git a/src/ViewModels/DeleteBranch.cs b/src/ViewModels/DeleteBranch.cs new file mode 100644 index 00000000..0421cf04 --- /dev/null +++ b/src/ViewModels/DeleteBranch.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class DeleteBranch : Popup { + public Models.Branch Target { + get; + private set; + } + + public DeleteBranch(Repository repo, Models.Branch branch) { + _repo = repo; + Target = branch; + View = new Views.DeleteBranch() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + if (Target.IsLocal) { + Commands.Branch.Delete(_repo.FullPath, Target.Name); + } else { + new Commands.Push(_repo.FullPath, Target.Remote, Target.Name).Exec(); + } + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/DeleteRemote.cs b/src/ViewModels/DeleteRemote.cs new file mode 100644 index 00000000..2ede9853 --- /dev/null +++ b/src/ViewModels/DeleteRemote.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class DeleteRemote : Popup { + public Models.Remote Remote { + get; + private set; + } + + public DeleteRemote(Repository repo, Models.Remote remote) { + _repo = repo; + Remote = remote; + View = new Views.DeleteRemote() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription("Deleting remote ..."); + var succ = new Commands.Remote(_repo.FullPath).Delete(Remote.Name); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/DeleteRepositoryNode.cs b/src/ViewModels/DeleteRepositoryNode.cs new file mode 100644 index 00000000..03e4e9ef --- /dev/null +++ b/src/ViewModels/DeleteRepositoryNode.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class DeleteRepositoryNode : Popup { + public RepositoryNode Node { + get => _node; + set => SetProperty(ref _node, value); + } + + public DeleteRepositoryNode(RepositoryNode node) { + _node = node; + View = new Views.DeleteRepositoryNode() { DataContext = this }; + } + + public override Task Sure() { + Preference.RemoveNode(_node); + return null; + } + + private RepositoryNode _node = null; + } +} diff --git a/src/ViewModels/DeleteSubmodule.cs b/src/ViewModels/DeleteSubmodule.cs new file mode 100644 index 00000000..2d7f1d5c --- /dev/null +++ b/src/ViewModels/DeleteSubmodule.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class DeleteSubmodule : Popup { + + public string Submodule { + get; + private set; + } + + public DeleteSubmodule(Repository repo, string submodule) { + _repo = repo; + Submodule = submodule; + View = new Views.DeleteSubmodule() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + var succ = new Commands.Submodule(_repo.FullPath).Delete(Submodule); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/DeleteTag.cs b/src/ViewModels/DeleteTag.cs new file mode 100644 index 00000000..3b33e000 --- /dev/null +++ b/src/ViewModels/DeleteTag.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class DeleteTag : Popup { + public Models.Tag Target { + get; + private set; + } + + public bool ShouldPushToRemote { + get; + set; + } + + public DeleteTag(Repository repo, Models.Tag tag) { + _repo = repo; + Target = tag; + ShouldPushToRemote = true; + View = new Views.DeleteTag() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription($"Deleting tag '{Target.Name}' ..."); + var remotes = ShouldPushToRemote ? _repo.Remotes : null; + var succ = Commands.Tag.Delete(_repo.FullPath, Target.Name, remotes); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/DiffContext.cs b/src/ViewModels/DiffContext.cs new file mode 100644 index 00000000..da8fa957 --- /dev/null +++ b/src/ViewModels/DiffContext.cs @@ -0,0 +1,105 @@ +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class DiffContext : ObservableObject { + public string FilePath { + get => _option.Path; + } + + public bool IsOrgFilePathVisible { + get => !string.IsNullOrWhiteSpace(_option.OrgPath) && _option.OrgPath != "/dev/null"; + } + + public string OrgFilePath { + get => _option.OrgPath; + } + + public bool IsLoading { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public bool IsNoChange { + get => _isNoChange; + private set => SetProperty(ref _isNoChange, value); + } + + public bool IsTextDiff { + get => _isTextDiff; + private set => SetProperty(ref _isTextDiff, value); + } + + public object Content { + get => _content; + private set => SetProperty(ref _content, value); + } + + public DiffContext(string repo, Models.DiffOption option) { + _repo = repo; + _option = option; + + OnPropertyChanged(nameof(FilePath)); + OnPropertyChanged(nameof(IsOrgFilePathVisible)); + OnPropertyChanged(nameof(OrgFilePath)); + + Task.Run(() => { + var latest = new Commands.Diff(repo, option).Result(); + var binaryDiff = null as Models.BinaryDiff; + + if (latest.IsBinary) { + binaryDiff = new Models.BinaryDiff(); + + var oldPath = string.IsNullOrEmpty(_option.OrgPath) ? _option.Path : _option.OrgPath; + if (option.Revisions.Count == 2) { + binaryDiff.OldSize = new Commands.QueryFileSize(repo, oldPath, option.Revisions[0]).Result(); + binaryDiff.NewSize = new Commands.QueryFileSize(repo, _option.Path, option.Revisions[1]).Result(); + } else { + binaryDiff.OldSize = new Commands.QueryFileSize(repo, oldPath, "HEAD").Result(); + binaryDiff.NewSize = new FileInfo(Path.Combine(repo, _option.Path)).Length; + } + } + + Dispatcher.UIThread.InvokeAsync(() => { + if (latest.IsBinary) { + Content = binaryDiff; + } else if (latest.IsLFS) { + Content = latest.LFSDiff; + } else if (latest.TextDiff != null) { + latest.TextDiff.File = _option.Path; + Content = latest.TextDiff; + IsTextDiff = true; + } else { + IsTextDiff = false; + IsNoChange = true; + } + + IsLoading = false; + }); + }); + } + + public async void OpenExternalMergeTool() { + var type = Preference.Instance.ExternalMergeToolType; + var exec = Preference.Instance.ExternalMergeToolPath; + + var tool = Models.ExternalMergeTools.Supported.Find(x => x.Type == type); + if (tool == null || !File.Exists(exec)) { + App.RaiseException(_repo, "Invalid merge tool in preference setting!"); + return; + } + + var args = tool.Type != 0 ? tool.DiffCmd : Preference.Instance.ExternalMergeToolDiffCmd; + await Task.Run(() => Commands.MergeTool.OpenForDiff(_repo, exec, args, _option)); + } + + private string _repo = string.Empty; + private Models.DiffOption _option = null; + private bool _isLoading = true; + private bool _isNoChange = false; + private bool _isTextDiff = false; + private object _content = null; + } +} diff --git a/src/ViewModels/Discard.cs b/src/ViewModels/Discard.cs new file mode 100644 index 00000000..4f091448 --- /dev/null +++ b/src/ViewModels/Discard.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class DiscardModeAll { } + public class DiscardModeSingle { public string File { get; set; } } + public class DiscardModeMulti { public int Count { get; set; } } + + public class Discard : Popup { + + public object Mode { + get; + private set; + } + + public Discard(Repository repo, List changes = null) { + _repo = repo; + _changes = changes; + + if (_changes == null) { + Mode = new DiscardModeAll(); + } else if (_changes.Count == 1) { + Mode = new DiscardModeSingle() { File = _changes[0].Path }; + } else { + Mode = new DiscardModeMulti() { Count = _changes.Count }; + } + + View = new Views.Discard() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + if (_changes == null) { + SetProgressDescription("Discard all local changes ..."); + Commands.Discard.All(_repo.FullPath); + } else { + SetProgressDescription($"Discard total {_changes.Count} changes ..."); + Commands.Discard.Changes(_repo.FullPath, _changes); + } + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + private List _changes = null; + } +} diff --git a/src/ViewModels/DropStash.cs b/src/ViewModels/DropStash.cs new file mode 100644 index 00000000..767bdd9f --- /dev/null +++ b/src/ViewModels/DropStash.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class DropStash : Popup { + public Models.Stash Stash { get; private set; } + + public DropStash(string repo, Models.Stash stash) { + _repo = repo; + Stash = stash; + View = new Views.DropStash() { DataContext = this }; + } + + public override Task Sure() { + return Task.Run(() => { + new Commands.Stash(_repo).Drop(Stash.Name); + return true; + }); + } + + private string _repo; + } +} diff --git a/src/ViewModels/EditRemote.cs b/src/ViewModels/EditRemote.cs new file mode 100644 index 00000000..48082cc2 --- /dev/null +++ b/src/ViewModels/EditRemote.cs @@ -0,0 +1,100 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class EditRemote : Popup { + [Required(ErrorMessage = "Remote name is required!!!")] + [RegularExpression(@"^[\w\-\.]+$", ErrorMessage = "Bad remote name format!!!")] + [CustomValidation(typeof(EditRemote), nameof(ValidateRemoteName))] + public string Name { + get => _name; + set => SetProperty(ref _name, value, true); + } + + [Required(ErrorMessage = "Remote URL is required!!!")] + [CustomValidation(typeof(EditRemote), nameof(ValidateRemoteURL))] + public string Url { + get => _url; + set { + if (SetProperty(ref _url, value, true)) UseSSH = Models.Remote.IsSSH(value); + } + } + + public bool UseSSH { + get => _useSSH; + set => SetProperty(ref _useSSH, value); + } + + public string SSHKey { + get; + set; + } + + public EditRemote(Repository repo, Models.Remote remote) { + _repo = repo; + _remote = remote; + _name = remote.Name; + _url = remote.URL; + _useSSH = Models.Remote.IsSSH(remote.URL); + + if (_useSSH) { + SSHKey = new Commands.Config(repo.FullPath).Get($"remote.{remote.Name}.sshkey"); + } + + View = new Views.EditRemote() { DataContext = this }; + } + + public static ValidationResult ValidateRemoteName(string name, ValidationContext ctx) { + if (ctx.ObjectInstance is EditRemote edit) { + foreach (var remote in edit._repo.Remotes) { + if (remote != edit._remote && name == remote.Name) new ValidationResult("A remote with given name already exists!!!"); + } + } + + return ValidationResult.Success; + } + + public static ValidationResult ValidateRemoteURL(string url, ValidationContext ctx) { + if (ctx.ObjectInstance is EditRemote edit) { + if (!Models.Remote.IsValidURL(url)) return new ValidationResult("Bad remote URL format!!!"); + + foreach (var remote in edit._repo.Remotes) { + if (remote != edit._remote && url == remote.URL) new ValidationResult("A remote with the same url already exists!!!"); + } + } + + return ValidationResult.Success; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription($"Editing remote '{_remote.Name}' ..."); + + if (_remote.Name != _name) { + var succ = new Commands.Remote(_repo.FullPath).Rename(_remote.Name, _name); + if (succ) _remote.Name = _name; + } + + if (_remote.URL != _url) { + var succ = new Commands.Remote(_repo.FullPath).SetURL(_name, _url); + if (succ) _remote.URL = _url; + } + + if (_useSSH) { + SetProgressDescription("Post processing ..."); + new Commands.Config(_repo.FullPath).Set($"remote.{_name}.sshkey", SSHKey); + } + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + private Models.Remote _remote = null; + private string _name = string.Empty; + private string _url = string.Empty; + private bool _useSSH = false; + } +} diff --git a/src/ViewModels/EditRepositoryNode.cs b/src/ViewModels/EditRepositoryNode.cs new file mode 100644 index 00000000..6111b72e --- /dev/null +++ b/src/ViewModels/EditRepositoryNode.cs @@ -0,0 +1,55 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class EditRepositoryNode : Popup { + public RepositoryNode Node { + get => _node; + set => SetProperty(ref _node, value); + } + + public string Id { + get => _id; + set => SetProperty(ref _id, value); + } + + [Required(ErrorMessage = "Name is required!")] + public string Name { + get => _name; + set => SetProperty(ref _name, value, true); + } + + public int Bookmark { + get => _bookmark; + set => SetProperty(ref _bookmark, value); + } + + public bool IsRepository { + get => _isRepository; + set => SetProperty(ref _isRepository, value); + } + + public EditRepositoryNode(RepositoryNode node) { + _node = node; + _id = node.Id; + _name = node.Name; + _isRepository = node.IsRepository; + _bookmark = node.Bookmark; + + View = new Views.EditRepositoryNode() { DataContext = this }; + } + + public override Task Sure() { + _node.Name = _name; + _node.Bookmark = _bookmark; + return null; + } + + private RepositoryNode _node = null; + private string _id = string.Empty; + private string _name = string.Empty; + private bool _isRepository = false; + private int _bookmark = 0; + } +} diff --git a/src/ViewModels/FastForwardWithoutCheckout.cs b/src/ViewModels/FastForwardWithoutCheckout.cs new file mode 100644 index 00000000..d9eb5462 --- /dev/null +++ b/src/ViewModels/FastForwardWithoutCheckout.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class FastForwardWithoutCheckout : Popup { + public Models.Branch Local { + get; + private set; + } + + public Models.Branch To { + get; + private set; + } + + public FastForwardWithoutCheckout(Repository repo, Models.Branch local, Models.Branch upstream) { + _repo = repo; + Local = local; + To = upstream; + View = new Views.FastForwardWithoutCheckout() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription("Fast-Forward ..."); + new Commands.Fetch(_repo.FullPath, To.Remote, Local.Name, To.Name, SetProgressDescription).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/Fetch.cs b/src/ViewModels/Fetch.cs new file mode 100644 index 00000000..e5d94c77 --- /dev/null +++ b/src/ViewModels/Fetch.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Fetch : Popup { + public List Remotes { + get => _repo.Remotes; + } + + public bool FetchAllRemotes { + get => _fetchAllRemotes; + set => SetProperty(ref _fetchAllRemotes, value); + } + + public Models.Remote SelectedRemote { + get; + set; + } + + public bool Prune { + get; + set; + } + + public Fetch(Repository repo, Models.Remote preferedRemote = null) { + _repo = repo; + _fetchAllRemotes = true; + SelectedRemote = preferedRemote != null ? preferedRemote : _repo.Remotes[0]; + Prune = true; + View = new Views.Fetch() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + if (FetchAllRemotes) { + foreach (var remote in _repo.Remotes) { + SetProgressDescription($"Fetching remote: {remote.Name}"); + new Commands.Fetch(_repo.FullPath, remote.Name, Prune, SetProgressDescription).Exec(); + } + } else { + SetProgressDescription($"Fetching remote: {SelectedRemote.Name}"); + new Commands.Fetch(_repo.FullPath, SelectedRemote.Name, Prune, SetProgressDescription).Exec(); + } + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + private bool _fetchAllRemotes = true; + } +} diff --git a/src/ViewModels/FileHistories.cs b/src/ViewModels/FileHistories.cs new file mode 100644 index 00000000..90356484 --- /dev/null +++ b/src/ViewModels/FileHistories.cs @@ -0,0 +1,66 @@ +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class FileHistories : ObservableObject { + public string File { + get => _file; + } + + public bool IsLoading { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public List Commits { + get => _commits; + set => SetProperty(ref _commits, value); + } + + public Models.Commit SelectedCommit { + get => _selectedCommit; + set { + if (SetProperty(ref _selectedCommit, value)) { + if (value == null) { + DiffContext = null; + } else { + DiffContext = new DiffContext(_repo, new Models.DiffOption(value, _file)); + } + } + } + } + + public DiffContext DiffContext { + get => _diffContext; + set => SetProperty(ref _diffContext, value); + } + + public FileHistories(string repo, string file) { + _repo = repo; + _file = file; + + Task.Run(() => { + var commits = new Commands.QueryCommits(_repo, $"-n 10000 -- \"{file}\"").Result(); + Dispatcher.UIThread.Invoke(() => { + IsLoading = false; + Commits = commits; + if (commits.Count > 0) SelectedCommit = commits[0]; + }); + }); + } + + public void NavigateToCommit(string commitSHA) { + var repo = Preference.FindRepository(_repo); + if (repo != null) repo.NavigateToCommit(commitSHA); + } + + private string _repo = string.Empty; + private string _file = string.Empty; + private bool _isLoading = true; + private List _commits = null; + private Models.Commit _selectedCommit = null; + private DiffContext _diffContext = null; + } +} diff --git a/src/ViewModels/FileTreeNode.cs b/src/ViewModels/FileTreeNode.cs new file mode 100644 index 00000000..94f30561 --- /dev/null +++ b/src/ViewModels/FileTreeNode.cs @@ -0,0 +1,170 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using System.Collections.Generic; + +namespace SourceGit.ViewModels { + public class FileTreeNode : ObservableObject { + public string FullPath { get; set; } = string.Empty; + public bool IsFolder { get; set; } = false; + public object Backend { get; set; } = null; + public List Children { get; set; } = new List(); + + public bool IsExpanded { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + public static List Build(List changes) { + var nodes = new List(); + var folders = new Dictionary(); + var expanded = changes.Count <= 50; + + foreach (var c in changes) { + var sepIdx = c.Path.IndexOf('/'); + if (sepIdx == -1) { + nodes.Add(new FileTreeNode() { + FullPath = c.Path, + Backend = c, + IsFolder = false, + IsExpanded = false + }); + } else { + FileTreeNode lastFolder = null; + var start = 0; + + while (sepIdx != -1) { + var folder = c.Path.Substring(0, sepIdx); + if (folders.ContainsKey(folder)) { + lastFolder = folders[folder]; + } else if (lastFolder == null) { + lastFolder = new FileTreeNode() { + FullPath = folder, + Backend = null, + IsFolder = true, + IsExpanded = expanded + }; + nodes.Add(lastFolder); + folders.Add(folder, lastFolder); + } else { + var cur = new FileTreeNode() { + FullPath = folder, + Backend = null, + IsFolder = true, + IsExpanded = expanded + }; + folders.Add(folder, cur); + lastFolder.Children.Add(cur); + lastFolder = cur; + } + + start = sepIdx + 1; + sepIdx = c.Path.IndexOf('/', start); + } + + lastFolder.Children.Add(new FileTreeNode() { + FullPath = c.Path, + Backend = c, + IsFolder = false, + IsExpanded = false + }); + } + } + + folders.Clear(); + Sort(nodes); + return nodes; + } + + public static List Build(List files) { + var nodes = new List(); + var folders = new Dictionary(); + var expanded = files.Count <= 50; + + foreach (var f in files) { + var sepIdx = f.Path.IndexOf('/'); + if (sepIdx == -1) { + nodes.Add(new FileTreeNode() { + FullPath = f.Path, + Backend = f, + IsFolder = false, + IsExpanded = false + }); + } else { + FileTreeNode lastFolder = null; + var start = 0; + + while (sepIdx != -1) { + var folder = f.Path.Substring(0, sepIdx); + if (folders.ContainsKey(folder)) { + lastFolder = folders[folder]; + } else if (lastFolder == null) { + lastFolder = new FileTreeNode() { + FullPath = folder, + Backend = null, + IsFolder = true, + IsExpanded = expanded + }; + nodes.Add(lastFolder); + folders.Add(folder, lastFolder); + } else { + var cur = new FileTreeNode() { + FullPath = folder, + Backend = null, + IsFolder = true, + IsExpanded = expanded + }; + folders.Add(folder, cur); + lastFolder.Children.Add(cur); + lastFolder = cur; + } + + start = sepIdx + 1; + sepIdx = f.Path.IndexOf('/', start); + } + + lastFolder.Children.Add(new FileTreeNode() { + FullPath = f.Path, + Backend = f, + IsFolder = false, + IsExpanded = false + }); + } + } + + folders.Clear(); + Sort(nodes); + return nodes; + } + + public static FileTreeNode SelectByPath(List nodes, string path) { + foreach (var node in nodes) { + if (node.FullPath == path) return node; + + if (node.IsFolder && path.StartsWith(node.FullPath + "/")) { + var foundInChildren = SelectByPath(node.Children, path); + if (foundInChildren != null) { + node.IsExpanded = true; + } + return foundInChildren; + } + } + + return null; + } + + private static void Sort(List nodes) { + nodes.Sort((l, r) => { + if (l.IsFolder == r.IsFolder) { + return l.FullPath.CompareTo(r.FullPath); + } else { + return l.IsFolder ? -1 : 1; + } + }); + + foreach (var node in nodes) { + if (node.Children.Count > 1) Sort(node.Children); + } + } + + private bool _isExpanded = true; + } +} diff --git a/src/ViewModels/GitFlowFinish.cs b/src/ViewModels/GitFlowFinish.cs new file mode 100644 index 00000000..a50251ce --- /dev/null +++ b/src/ViewModels/GitFlowFinish.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class GitFlowFinish : Popup { + public Models.Branch Branch => _branch; + public bool IsFeature => _type == Models.GitFlowBranchType.Feature; + public bool IsRelease => _type == Models.GitFlowBranchType.Release; + public bool IsHotfix => _type == Models.GitFlowBranchType.Hotfix; + + public bool KeepBranch { + get; + set; + } = false; + + public GitFlowFinish(Repository repo, Models.Branch branch, Models.GitFlowBranchType type) { + _repo = repo; + _branch = branch; + _type = type; + View = new Views.GitFlowFinish() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + var branch = _branch.Name; + switch (_type) { + case Models.GitFlowBranchType.Feature: + branch = branch.Substring(_repo.GitFlow.Feature.Length); + break; + case Models.GitFlowBranchType.Release: + branch = branch.Substring(_repo.GitFlow.Release.Length); + break; + default: + branch = branch.Substring(_repo.GitFlow.Hotfix.Length); + break; + } + + var succ = new Commands.GitFlow(_repo.FullPath).Finish(_type, branch, KeepBranch); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private Models.Branch _branch = null; + private Models.GitFlowBranchType _type = Models.GitFlowBranchType.None; + } +} diff --git a/src/ViewModels/GitFlowStart.cs b/src/ViewModels/GitFlowStart.cs new file mode 100644 index 00000000..d1e4cf5c --- /dev/null +++ b/src/ViewModels/GitFlowStart.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class GitFlowStart : Popup { + [Required(ErrorMessage = "Name is required!!!")] + [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] + [CustomValidation(typeof(GitFlowStart), nameof(ValidateBranchName))] + public string Name { + get => _name; + set => SetProperty(ref _name, value, true); + } + + public string Prefix { + get => _prefix; + } + + public bool IsFeature => _type == Models.GitFlowBranchType.Feature; + public bool IsRelease => _type == Models.GitFlowBranchType.Release; + public bool IsHotfix => _type == Models.GitFlowBranchType.Hotfix; + + public GitFlowStart(Repository repo, Models.GitFlowBranchType type) { + _repo = repo; + _type = type; + + switch (type) { + case Models.GitFlowBranchType.Feature: + _prefix = repo.GitFlow.Feature; + break; + case Models.GitFlowBranchType.Release: + _prefix = repo.GitFlow.Release; + break; + default: + _prefix = repo.GitFlow.Hotfix; + break; + } + + View = new Views.GitFlowStart() { DataContext = this }; + } + + public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) { + if (ctx.ObjectInstance is GitFlowStart starter) { + var check = $"{starter._prefix}{name}"; + foreach (var b in starter._repo.Branches) { + var test = b.IsLocal ? b.Name : $"{b.Remote}/{b.Name}"; + if (test == check) return new ValidationResult("A branch with same name already exists!"); + } + } + + return ValidationResult.Success; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + var succ = new Commands.GitFlow(_repo.FullPath).Start(_type, _name); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private Models.GitFlowBranchType _type = Models.GitFlowBranchType.Feature; + private string _prefix = string.Empty; + private string _name = null; + } +} diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs new file mode 100644 index 00000000..3a9222e9 --- /dev/null +++ b/src/ViewModels/Histories.cs @@ -0,0 +1,473 @@ +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class CountSelectedCommits { + public int Count { get; set; } + } + + public class Histories : ObservableObject { + public bool IsLoading { + get => _isLoading; + set => SetProperty(ref _isLoading, value); + } + + public double DataGridRowHeight { + get => _dataGridRowHeight; + } + + public List Commits { + get => _commits; + set { + if (SetProperty(ref _commits, value)) { + Graph = null; + Task.Run(() => { + var graph = Models.CommitGraph.Parse(value, DataGridRowHeight, 8); + Dispatcher.UIThread.Invoke(() => { + Graph = graph; + }); + }); + } + } + } + + public Models.CommitGraph Graph { + get => _graph; + set => SetProperty(ref _graph, value); + } + + public Models.Commit AutoSelectedCommit { + get => _autoSelectedCommit; + private set => SetProperty(ref _autoSelectedCommit, value); + } + + public object DetailContext { + get => _detailContext; + private set => SetProperty(ref _detailContext, value); + } + + public Histories(Repository repo) { + _repo = repo; + } + + public void NavigateTo(string commitSHA) { + var commit = _commits.Find(x => x.SHA.StartsWith(commitSHA)); + if (commit != null) { + AutoSelectedCommit = commit; + + if (_detailContext is CommitDetail detail) { + detail.Commit = commit; + } else { + var commitDetail = new CommitDetail(_repo.FullPath); + commitDetail.Commit = commit; + DetailContext = commitDetail; + } + } + } + + public void Select(IList commits) { + if (commits.Count == 0) { + DetailContext = null; + } else if (commits.Count == 1) { + var commit = commits[0] as Models.Commit; + AutoSelectedCommit = commit; + + if (_detailContext is CommitDetail detail) { + detail.Commit = commit; + } else { + var commitDetail = new CommitDetail(_repo.FullPath); + commitDetail.Commit = commit; + DetailContext = commitDetail; + } + } else if (commits.Count == 2) { + var end = commits[0] as Models.Commit; + var start = commits[1] as Models.Commit; + DetailContext = new RevisionCompare(_repo.FullPath, start, end); + } else { + DetailContext = new CountSelectedCommits() { Count = commits.Count }; + } + } + + public ContextMenu MakeContextMenu() { + var detail = _detailContext as CommitDetail; + if (detail == null) return null; + + var current = _repo.Branches.Find(x => x.IsCurrent); + if (current == null) return null; + + var commit = detail.Commit; + var menu = new ContextMenu(); + var tags = new List(); + + if (commit.HasDecorators) { + foreach (var d in commit.Decorators) { + if (d.Type == Models.DecoratorType.CurrentBranchHead) { + FillCurrentBranchMenu(menu, current); + } else if (d.Type == Models.DecoratorType.LocalBranchHead) { + var b = _repo.Branches.Find(x => x.IsLocal && d.Name == x.Name); + FillOtherLocalBranchMenu(menu, b, current, commit.IsMerged); + } else if (d.Type == Models.DecoratorType.RemoteBranchHead) { + var b = _repo.Branches.Find(x => !x.IsLocal && d.Name == $"{x.Remote}/{x.Name}"); + FillRemoteBranchMenu(menu, b, current, commit.IsMerged); + } else if (d.Type == Models.DecoratorType.Tag) { + var t = _repo.Tags.Find(x => x.Name == d.Name); + if (t != null) tags.Add(t); + } + } + + if (menu.Items.Count > 0) menu.Items.Add(new MenuItem() { Header = "-" }); + } + + if (tags.Count > 0) { + foreach (var tag in tags) FillTagMenu(menu, tag); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + if (current.Head != commit.SHA) { + var reset = new MenuItem(); + reset.Header = App.Text("CommitCM.Reset", current.Name); + reset.Icon = CreateMenuIcon("Icons.Reset"); + reset.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Reset(_repo, current, commit)); + e.Handled = true; + }; + menu.Items.Add(reset); + } else { + var reword = new MenuItem(); + reword.Header = App.Text("CommitCM.Reword"); + reword.Icon = CreateMenuIcon("Icons.Edit"); + reword.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Reword(_repo, commit)); + e.Handled = true; + }; + menu.Items.Add(reword); + + var squash = new MenuItem(); + squash.Header = App.Text("CommitCM.Squash"); + squash.Icon = CreateMenuIcon("Icons.SquashIntoParent"); + squash.IsEnabled = commit.Parents.Count == 1; + squash.Click += (o, e) => { + if (commit.Parents.Count == 1) { + var parent = _commits.Find(x => x.SHA == commit.Parents[0]); + if (parent != null && PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Squash(_repo, commit, parent)); + } + + e.Handled = true; + }; + menu.Items.Add(squash); + } + + if (!commit.IsMerged) { + var rebase = new MenuItem(); + rebase.Header = App.Text("CommitCM.Rebase", current.Name); + rebase.Icon = CreateMenuIcon("Icons.Rebase"); + rebase.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Rebase(_repo, current, commit)); + e.Handled = true; + }; + menu.Items.Add(rebase); + + var cherryPick = new MenuItem(); + cherryPick.Header = App.Text("CommitCM.CherryPick"); + cherryPick.Icon = CreateMenuIcon("Icons.CherryPick"); + cherryPick.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CherryPick(_repo, commit)); + e.Handled = true; + }; + menu.Items.Add(cherryPick); + } else { + var revert = new MenuItem(); + revert.Header = App.Text("CommitCM.Revert"); + revert.Icon = CreateMenuIcon("Icons.Undo"); + revert.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Revert(_repo, commit)); + e.Handled = true; + }; + menu.Items.Add(revert); + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + + var createBranch = new MenuItem(); + createBranch.Icon = CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(_repo, commit)); + e.Handled = true; + }; + menu.Items.Add(createBranch); + + var createTag = new MenuItem(); + createTag.Icon = CreateMenuIcon("Icons.Tag.Add"); + createTag.Header = App.Text("CreateTag"); + createTag.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateTag(_repo, commit)); + e.Handled = true; + }; + menu.Items.Add(createTag); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var saveToPatch = new MenuItem(); + saveToPatch.Icon = CreateMenuIcon("Icons.Diff"); + saveToPatch.Header = App.Text("CommitCM.SaveAsPatch"); + saveToPatch.Click += async (_, e) => { + var topLevel = App.GetTopLevel(); + if (topLevel == null) return; + + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + var selected = await topLevel.StorageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) { + var succ = new Commands.FormatPatch(_repo.FullPath, commit.SHA, selected[0].Path.LocalPath).Exec(); + if (succ) App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + e.Handled = true; + }; + menu.Items.Add(saveToPatch); + + var archive = new MenuItem(); + archive.Icon = CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Archive(_repo, commit)); + e.Handled = true; + }; + menu.Items.Add(archive); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var copySHA = new MenuItem(); + copySHA.Header = App.Text("CommitCM.CopySHA"); + copySHA.Icon = CreateMenuIcon("Icons.Copy"); + copySHA.Click += (o, e) => { + App.CopyText(commit.SHA); + e.Handled = true; + }; + menu.Items.Add(copySHA); + return menu; + } + + private void FillCurrentBranchMenu(ContextMenu menu, Models.Branch current) { + var submenu = new MenuItem(); + submenu.Icon = CreateMenuIcon("Icons.Branch"); + submenu.Header = current.Name; + + var dirty = !string.IsNullOrEmpty(current.UpstreamTrackStatus); + if (!string.IsNullOrEmpty(current.Upstream)) { + var upstream = current.Upstream.Substring(13); + + var fastForward = new MenuItem(); + fastForward.Header = App.Text("BranchCM.FastForward", upstream); + fastForward.Icon = CreateMenuIcon("Icons.FastForward"); + fastForward.IsEnabled = dirty; + fastForward.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Merge(_repo, upstream, current.Name)); + e.Handled = true; + }; + submenu.Items.Add(fastForward); + + var pull = new MenuItem(); + pull.Header = App.Text("BranchCM.Pull", upstream); + pull.Icon = CreateMenuIcon("Icons.Pull"); + pull.IsEnabled = dirty; + pull.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Pull(_repo, null)); + e.Handled = true; + }; + submenu.Items.Add(pull); + } + + var push = new MenuItem(); + push.Header = App.Text("BranchCM.Push", current.Name); + push.Icon = CreateMenuIcon("Icons.Push"); + push.IsEnabled = _repo.Remotes.Count > 0 && dirty; + push.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Push(_repo, current)); + e.Handled = true; + }; + submenu.Items.Add(push); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var type = _repo.GitFlow.GetBranchType(current.Name); + if (type != Models.GitFlowBranchType.None) { + var finish = new MenuItem(); + finish.Header = App.Text("BranchCM.Finish", current.Name); + finish.Icon = CreateMenuIcon("Icons.Flow"); + finish.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new GitFlowFinish(_repo, current, type)); + e.Handled = true; + }; + submenu.Items.Add(finish); + submenu.Items.Add(new MenuItem() { Header = "-" }); + } + + var rename = new MenuItem(); + rename.Header = App.Text("BranchCM.Rename", current.Name); + rename.Icon = CreateMenuIcon("Icons.Rename"); + rename.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new RenameBranch(_repo, current)); + e.Handled = true; + }; + submenu.Items.Add(rename); + + menu.Items.Add(submenu); + } + + private void FillOtherLocalBranchMenu(ContextMenu menu, Models.Branch branch, Models.Branch current, bool merged) { + var submenu = new MenuItem(); + submenu.Icon = CreateMenuIcon("Icons.Branch"); + submenu.Header = branch.Name; + + var checkout = new MenuItem(); + checkout.Header = App.Text("BranchCM.Checkout", branch.Name); + checkout.Icon = CreateMenuIcon("Icons.Check"); + checkout.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Checkout(_repo, branch.Name)); + e.Handled = true; + }; + submenu.Items.Add(checkout); + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", branch.Name, current.Name); + merge.Icon = CreateMenuIcon("Icons.Merge"); + merge.IsEnabled = !merged; + merge.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Merge(_repo, branch.Name, current.Name)); + e.Handled = true; + }; + submenu.Items.Add(merge); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var type = _repo.GitFlow.GetBranchType(branch.Name); + if (type != Models.GitFlowBranchType.None) { + var finish = new MenuItem(); + finish.Header = App.Text("BranchCM.Finish", branch.Name); + finish.Icon = CreateMenuIcon("Icons.Flow"); + finish.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new GitFlowFinish(_repo, branch, type)); + e.Handled = true; + }; + submenu.Items.Add(finish); + submenu.Items.Add(new MenuItem() { Header = "-" }); + } + + var rename = new MenuItem(); + rename.Header = App.Text("BranchCM.Rename", branch.Name); + rename.Icon = CreateMenuIcon("Icons.Rename"); + rename.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new RenameBranch(_repo, branch)); + e.Handled = true; + }; + submenu.Items.Add(rename); + + var delete = new MenuItem(); + delete.Header = App.Text("BranchCM.Delete", branch.Name); + delete.Icon = CreateMenuIcon("Icons.Clear"); + delete.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteBranch(_repo, branch)); + e.Handled = true; + }; + submenu.Items.Add(delete); + + menu.Items.Add(submenu); + } + + private void FillRemoteBranchMenu(ContextMenu menu, Models.Branch branch, Models.Branch current, bool merged) { + var name = $"{branch.Remote}/{branch.Name}"; + + var submenu = new MenuItem(); + submenu.Icon = CreateMenuIcon("Icons.Branch"); + submenu.Header = name; + + var checkout = new MenuItem(); + checkout.Header = App.Text("BranchCM.Checkout", name); + checkout.Icon = CreateMenuIcon("Icons.Check"); + checkout.Click += (o, e) => { + foreach (var b in _repo.Branches) { + if (b.IsLocal && b.Upstream == branch.FullName) { + if (b.IsCurrent) return; + if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Checkout(_repo, b.Name)); + return; + } + } + + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(_repo, branch)); + e.Handled = true; + }; + submenu.Items.Add(checkout); + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", name, current.Name); + merge.Icon = CreateMenuIcon("Icons.Merge"); + merge.IsEnabled = !merged; + merge.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Merge(_repo, name, current.Name)); + e.Handled = true; + }; + + submenu.Items.Add(merge); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var delete = new MenuItem(); + delete.Header = App.Text("BranchCM.Delete", name); + delete.Icon = CreateMenuIcon("Icons.Clear"); + delete.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteBranch(_repo, branch)); + e.Handled = true; + }; + submenu.Items.Add(delete); + + menu.Items.Add(submenu); + } + + private void FillTagMenu(ContextMenu menu, Models.Tag tag) { + var submenu = new MenuItem(); + submenu.Header = tag.Name; + submenu.Icon = CreateMenuIcon("Icons.Tag"); + submenu.MinWidth = 200; + + var push = new MenuItem(); + push.Header = App.Text("TagCM.Push", tag.Name); + push.Icon = CreateMenuIcon("Icons.Push"); + push.IsEnabled = _repo.Remotes.Count > 0; + push.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new PushTag(_repo, tag)); + e.Handled = true; + }; + submenu.Items.Add(push); + + var delete = new MenuItem(); + delete.Header = App.Text("TagCM.Delete", tag.Name); + delete.Icon = CreateMenuIcon("Icons.Clear"); + delete.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteTag(_repo, tag)); + e.Handled = true; + }; + submenu.Items.Add(delete); + + menu.Items.Add(submenu); + } + + private Avalonia.Controls.Shapes.Path CreateMenuIcon(string key) { + var icon = new Avalonia.Controls.Shapes.Path(); + icon.Width = 12; + icon.Height = 12; + icon.Stretch = Stretch.Uniform; + icon.Data = App.Current?.FindResource(key) as StreamGeometry; + return icon; + } + + private Repository _repo = null; + private double _dataGridRowHeight = 28; + private bool _isLoading = true; + private List _commits = new List(); + private Models.CommitGraph _graph = null; + private Models.Commit _autoSelectedCommit = null; + private object _detailContext = null; + } +} diff --git a/src/ViewModels/Init.cs b/src/ViewModels/Init.cs new file mode 100644 index 00000000..f95ed80d --- /dev/null +++ b/src/ViewModels/Init.cs @@ -0,0 +1,41 @@ +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Init : Popup { + public string TargetPath { + get => _targetPath; + set => SetProperty(ref _targetPath, value); + } + + public Init(string path) { + TargetPath = path; + View = new Views.Init() { DataContext = this }; + } + + public override Task Sure() { + return Task.Run(() => { + SetProgressDescription($"Initialize git repository at: '{_targetPath}'"); + var succ = new Commands.Init(HostPageId, _targetPath).Exec(); + if (!succ) return false; + + var gitDir = Path.GetFullPath(Path.Combine(_targetPath, ".git")); + + CallUIThread(() => { + var repo = Preference.AddRepository(_targetPath, gitDir); + var node = new RepositoryNode() { + Id = _targetPath, + Name = Path.GetFileName(_targetPath), + Bookmark = 0, + IsRepository = true, + }; + Preference.AddNode(node); + }); + + return true; + }); + } + + private string _targetPath; + } +} diff --git a/src/ViewModels/InitGitFlow.cs b/src/ViewModels/InitGitFlow.cs new file mode 100644 index 00000000..724310ee --- /dev/null +++ b/src/ViewModels/InitGitFlow.cs @@ -0,0 +1,96 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class InitGitFlow : Popup { + private static readonly Regex TAG_PREFIX = new Regex(@"^[\w\-/\.]+$"); + + [Required(ErrorMessage = "Master branch name is required!!!")] + [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] + [CustomValidation(typeof(InitGitFlow), nameof(ValidateBaseBranch))] + public string Master { + get => _master; + set => SetProperty(ref _master, value, true); + } + + [Required(ErrorMessage = "Develop branch name is required!!!")] + [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] + [CustomValidation(typeof(InitGitFlow), nameof(ValidateBaseBranch))] + public string Develop { + get => _develop; + set => SetProperty(ref _develop, value, true); + } + + [Required(ErrorMessage = "Feature prefix is required!!!")] + [RegularExpression(@"^[\w\-\.]+/$", ErrorMessage = "Bad feature prefix format!")] + public string FeturePrefix { + get => _featurePrefix; + set => SetProperty(ref _featurePrefix, value, true); + } + + [Required(ErrorMessage = "Release prefix is required!!!")] + [RegularExpression(@"^[\w\-\.]+/$", ErrorMessage = "Bad release prefix format!")] + public string ReleasePrefix { + get => _releasePrefix; + set => SetProperty(ref _releasePrefix, value, true); + } + + [Required(ErrorMessage = "Hotfix prefix is required!!!")] + [RegularExpression(@"^[\w\-\.]+/$", ErrorMessage = "Bad hotfix prefix format!")] + public string HotfixPrefix { + get => _hotfixPrefix; + set => SetProperty(ref _hotfixPrefix, value, true); + } + + [CustomValidation(typeof(InitGitFlow), nameof(ValidateTagPrefix))] + public string TagPrefix { + get => _tagPrefix; + set => SetProperty(ref _tagPrefix, value, true); + } + + public InitGitFlow(Repository repo) { + _repo = repo; + View = new Views.InitGitFlow() { DataContext = this }; + } + + public static ValidationResult ValidateBaseBranch(string _, ValidationContext ctx) { + if (ctx.ObjectInstance is InitGitFlow initializer) { + if (initializer._master == initializer._develop) return new ValidationResult("Develop branch has the same name with master branch!"); + } + + return ValidationResult.Success; + } + + public static ValidationResult ValidateTagPrefix(string tagPrefix, ValidationContext ctx) { + if (!string.IsNullOrWhiteSpace(tagPrefix) && !TAG_PREFIX.IsMatch(tagPrefix)) { + return new ValidationResult("Bad tag prefix format!"); + } + + return ValidationResult.Success; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + var succ = new Commands.GitFlow(_repo.FullPath).Init(_repo.Branches, _master, _develop, _featurePrefix, _releasePrefix, _hotfixPrefix, _tagPrefix); + if (succ) { + _repo.GitFlow.Feature = _featurePrefix; + _repo.GitFlow.Release = _releasePrefix; + _repo.GitFlow.Hotfix = _hotfixPrefix; + } + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private string _master = "master"; + private string _develop = "develop"; + private string _featurePrefix = "feature/"; + private string _releasePrefix = "release/"; + private string _hotfixPrefix = "hotfix/"; + private string _tagPrefix = string.Empty; + } +} diff --git a/src/ViewModels/Launcher.cs b/src/ViewModels/Launcher.cs new file mode 100644 index 00000000..d8754f73 --- /dev/null +++ b/src/ViewModels/Launcher.cs @@ -0,0 +1,154 @@ +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using System.IO; + +namespace SourceGit.ViewModels { + public class Launcher : ObservableObject { + public AvaloniaList Pages { + get; + private set; + } + + public LauncherPage ActivePage { + get => _activePage; + set { + if (SetProperty(ref _activePage, value)) { + PopupHost.Active = value; + } + } + } + + public Launcher() { + Pages = new AvaloniaList(); + AddNewTab(); + } + + public void AddNewTab() { + var page = new LauncherPage(); + Pages.Add(page); + ActivePage = page; + } + + public void MoveTab(LauncherPage from, LauncherPage to) { + var fromIdx = Pages.IndexOf(from); + var toIdx = Pages.IndexOf(to); + Pages.Move(fromIdx, toIdx); + ActivePage = from; + } + + public void GotoNextTab() { + if (Pages.Count == 1) return; + + var activeIdx = Pages.IndexOf(_activePage); + var nextIdx = (activeIdx + 1) % Pages.Count; + ActivePage = Pages[nextIdx]; + } + + public void CloseTab(object param) { + if (Pages.Count == 1) { + App.Quit(); + return; + } + + LauncherPage page = param as LauncherPage; + if (page == null) page = _activePage; + + CloseRepositoryInTab(page); + + var removeIdx = Pages.IndexOf(page); + var activeIdx = Pages.IndexOf(_activePage); + if (removeIdx == activeIdx) { + if (removeIdx == Pages.Count - 1) { + ActivePage = Pages[removeIdx - 1]; + } else { + ActivePage = Pages[removeIdx + 1]; + } + + Pages.RemoveAt(removeIdx); + OnPropertyChanged(nameof(Pages)); + } else if (removeIdx + 1 == activeIdx) { + Pages.RemoveAt(removeIdx); + OnPropertyChanged(nameof(Pages)); + } else { + Pages.RemoveAt(removeIdx); + } + } + + public void CloseOtherTabs(object param) { + if (Pages.Count == 1) return; + + LauncherPage page = param as LauncherPage; + if (page == null) page = _activePage; + + foreach (var one in Pages) { + if (one.Node.Id != page.Node.Id) { + CloseRepositoryInTab(one); + } + } + + ActivePage = page; + Pages = new AvaloniaList { page }; + OnPropertyChanged(nameof(Pages)); + } + + public void CloseRightTabs(object param) { + LauncherPage page = param as LauncherPage; + if (page == null) page = _activePage; + + var endIdx = Pages.IndexOf(page); + var activeIdx = Pages.IndexOf(_activePage); + if (endIdx < activeIdx) { + ActivePage = page; + } + + for (var i = Pages.Count - 1; i > endIdx; i--) { + CloseRepositoryInTab(Pages[i]); + Pages.Remove(Pages[i]); + } + } + + public void OpenRepositoryInTab(RepositoryNode node, LauncherPage page) { + foreach (var one in Pages) { + if (one.Node.Id == node.Id) { + ActivePage = one; + return; + } + } + + var repo = Preference.FindRepository(node.Id); + if (repo == null || !Path.Exists(repo.FullPath)) { + var ctx = page == null ? ActivePage.Node.Id : page.Node.Id; + App.RaiseException(ctx, "Repository does NOT exists any more. Please remove it."); + return; + } + + repo.Open(); + + if (page == null) { + if (ActivePage == null || ActivePage.Node.IsRepository) { + page = new LauncherPage(node, repo); + Pages.Add(page); + } else { + page.Node = node; + page.View = new Views.Repository() { DataContext = repo }; + } + } else { + page.Node = node; + page.View = new Views.Repository() { DataContext = repo }; + } + + ActivePage = page; + } + + private void CloseRepositoryInTab(LauncherPage page) { + if (!page.Node.IsRepository) return; + + var repo = Preference.FindRepository(page.Node.Id); + if (repo == null) return; + + repo.Close(); + } + + private LauncherPage _activePage = null; + } +} diff --git a/src/ViewModels/LauncherPage.cs b/src/ViewModels/LauncherPage.cs new file mode 100644 index 00000000..c147c270 --- /dev/null +++ b/src/ViewModels/LauncherPage.cs @@ -0,0 +1,53 @@ +using Avalonia.Collections; +using System; + +namespace SourceGit.ViewModels { + public class LauncherPage : PopupHost { + public RepositoryNode Node { + get => _node; + set => SetProperty(ref _node, value); + } + + public object View { + get => _view; + set => SetProperty(ref _view, value); + } + + public AvaloniaList Notifications { + get; + set; + } = new AvaloniaList(); + + public LauncherPage() { + _node = new RepositoryNode() { + Id = Guid.NewGuid().ToString(), + Name = "WelcomePage", + Bookmark = 0, + IsRepository = false, + }; + _view = new Views.Welcome() { DataContext = new Welcome() }; + } + + public LauncherPage(RepositoryNode node, Repository repo) { + _node = node; + _view = new Views.Repository() { DataContext = repo }; + } + + public override string GetId() { + return _node.Id; + } + + public void CopyPath() { + if (_node.IsRepository) App.CopyText(_node.Id); + } + + public void DismissNotification(object param) { + if (param is Models.Notification notice) { + Notifications.Remove(notice); + } + } + + private RepositoryNode _node = null; + private object _view = null; + } +} diff --git a/src/ViewModels/Merge.cs b/src/ViewModels/Merge.cs new file mode 100644 index 00000000..b7628739 --- /dev/null +++ b/src/ViewModels/Merge.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class MergeMode { + public string Name { get; set; } + public string Desc { get; set; } + public string Arg { get; set; } + + public MergeMode(string n, string d, string a) { + Name = n; + Desc = d; + Arg = a; + } + } + + public class Merge : Popup { + public string Source { + get; + private set; + } + + public string Into { + get; + private set; + } + + public List Modes { + get; + private set; + } + + public MergeMode SelectedMode { + get; + set; + } + + public Merge(Repository repo, string source, string into) { + _repo = repo; + Source = source; + Into = into; + Modes = new List() { + new MergeMode("Default", "Fast-forward if possible", ""), + new MergeMode("No Fast-forward", "Always create a merge commit", "--no-ff"), + new MergeMode("Squash", "Use '--squash'", "--squash"), + new MergeMode("Don't commit", "Merge without commit", "--no-commit"), + }; + SelectedMode = Modes[0]; + View = new Views.Merge() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription($"Merging '{Source}' into '{Into}' ..."); + var succ = new Commands.Merge(_repo.FullPath, Source, SelectedMode.Arg, SetProgressDescription).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/Popup.cs b/src/ViewModels/Popup.cs new file mode 100644 index 00000000..52e99c84 --- /dev/null +++ b/src/ViewModels/Popup.cs @@ -0,0 +1,51 @@ +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Popup : ObservableValidator { + public string HostPageId { + get; + set; + } + + public object View { + get; + set; + } + + public bool InProgress { + get => _inProgress; + set => SetProperty(ref _inProgress, value); + } + + public string ProgressDescription { + get => _progressDescription; + set => SetProperty(ref _progressDescription, value); + } + + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode")] + public bool Check() { + if (HasErrors) return false; + ValidateAllProperties(); + return !HasErrors; + } + + public virtual Task Sure() { + return null; + } + + protected void CallUIThread(Action action) { + Dispatcher.UIThread.Invoke(action); + } + + protected void SetProgressDescription(string description) { + CallUIThread(() => ProgressDescription = description); + } + + private bool _inProgress = false; + private string _progressDescription = string.Empty; + } +} diff --git a/src/ViewModels/PopupHost.cs b/src/ViewModels/PopupHost.cs new file mode 100644 index 00000000..1d1cec02 --- /dev/null +++ b/src/ViewModels/PopupHost.cs @@ -0,0 +1,75 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels { + public class PopupHost : ObservableObject { + public static PopupHost Active { + get; + set; + } = null; + + public Popup Popup { + get => _popup; + set => SetProperty(ref _popup, value); + } + + public static bool CanCreatePopup() { + return Active != null && (Active._popup == null || !Active._popup.InProgress); + } + + public static void ShowPopup(Popup popup) { + popup.HostPageId = Active.GetId(); + Active.Popup = popup; + } + + public static async void ShowAndStartPopup(Popup popup) { + popup.HostPageId = Active.GetId(); + Active.Popup = popup; + + if (!popup.Check()) return; + + popup.InProgress = true; + var task = popup.Sure(); + if (task != null) { + var finished = await task; + if (finished) { + Active.Popup = null; + } else { + popup.InProgress = false; + } + } else { + Active.Popup = null; + } + } + + public virtual string GetId() { + return string.Empty; + } + + public async void ProcessPopup() { + if (_popup != null) { + if (!_popup.Check()) return; + + _popup.InProgress = true; + var task = _popup.Sure(); + if (task != null) { + var finished = await task; + if (finished) { + Popup = null; + } else { + _popup.InProgress = false; + } + } else { + Popup = null; + } + } + } + + public void CancelPopup() { + if (_popup == null) return; + if (_popup.InProgress) return; + Popup = null; + } + + private Popup _popup = null; + } +} diff --git a/src/ViewModels/Preference.cs b/src/ViewModels/Preference.cs new file mode 100644 index 00000000..03951664 --- /dev/null +++ b/src/ViewModels/Preference.cs @@ -0,0 +1,258 @@ +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SourceGit.ViewModels { + public class Preference : ObservableObject { + [JsonIgnore] + public static Preference Instance { + get { + if (_instance == null) { + if (!File.Exists(_savePath)) { + _instance = new Preference(); + } else { + try { + _instance = JsonSerializer.Deserialize(File.ReadAllText(_savePath), JsonSerializationCodeGen.Default.Preference); + } catch { + _instance = new Preference(); + } + } + } + + _instance.Repositories.RemoveAll(x => !Directory.Exists(x.FullPath)); + + if (!_instance.IsGitConfigured) { + _instance.GitInstallDir = Native.OS.FindGitInstallDir(); + } + + return _instance; + } + } + + public string Locale { + get => _locale; + set { + if (SetProperty(ref _locale, value)) { + App.SetLocale(value); + } + } + } + + public string Theme { + get => _theme; + set { + if (SetProperty(ref _theme, value)) { + App.SetTheme(value); + } + } + } + + public int MaxHistoryCommits { + get => _maxHistoryCommits; + set => SetProperty(ref _maxHistoryCommits, value); + } + + public bool RestoreTabs { + get => _restoreTabs; + set => SetProperty(ref _restoreTabs, value); + } + + public bool UseMacOSStyle { + get => _useMacOSStyle; + set => SetProperty(ref _useMacOSStyle, value); + } + + public bool UseTwoColumnsLayoutInHistories { + get => _useTwoColumnsLayoutInHistories; + set => SetProperty(ref _useTwoColumnsLayoutInHistories, value); + } + + public bool UseCombinedTextDiff { + get => _useCombinedTextDiff; + set => SetProperty(ref _useCombinedTextDiff, value); + } + + public Models.ChangeViewMode UnstagedChangeViewMode { + get => _unstagedChangeViewMode; + set => SetProperty(ref _unstagedChangeViewMode, value); + } + + public Models.ChangeViewMode StagedChangeViewMode { + get => _stagedChangeViewMode; + set => SetProperty(ref _stagedChangeViewMode, value); + } + + public Models.ChangeViewMode CommitChangeViewMode { + get => _commitChangeViewMode; + set => SetProperty(ref _commitChangeViewMode, value); + } + + [JsonIgnore] + public bool IsGitConfigured { + get => !string.IsNullOrEmpty(GitInstallDir) && Directory.Exists(GitInstallDir); + } + + public string GitInstallDir { + get => Native.OS.GitInstallDir; + set { + if (Native.OS.GitInstallDir != value) { + Native.OS.GitInstallDir = value; + OnPropertyChanged(nameof(GitInstallDir)); + } + } + } + + public string GitDefaultCloneDir { + get => _gitDefaultCloneDir; + set => SetProperty(ref _gitDefaultCloneDir, value); + } + + public bool GitAutoFetch { + get => _gitAutoFetch; + set => SetProperty(ref _gitAutoFetch, value); + } + + public int ExternalMergeToolType { + get => _externalMergeToolType; + set => SetProperty(ref _externalMergeToolType, value); + } + + public string ExternalMergeToolPath { + get => _externalMergeToolPath; + set => SetProperty(ref _externalMergeToolPath, value); + } + + public string ExternalMergeToolCmd { + get => _externalMergeToolCmd; + set => SetProperty(ref _externalMergeToolCmd, value); + } + + public string ExternalMergeToolDiffCmd { + get => _externalMergeToolDiffCmd; + set => SetProperty(ref _externalMergeToolDiffCmd, value); + } + + public List Repositories { + get; + set; + } = new List(); + + public AvaloniaList RepositoryNodes { + get => _repositoryNodes; + set => SetProperty(ref _repositoryNodes, value); + } + + public static void AddNode(RepositoryNode node, RepositoryNode to = null) { + var collection = to == null ? _instance._repositoryNodes : to.SubNodes; + var list = new List(); + list.AddRange(collection); + list.Add(node); + list.Sort((l, r) => { + if (l.IsRepository != r.IsRepository) { + return l.IsRepository ? 1 : -1; + } else { + return l.Name.CompareTo(r.Name); + } + }); + + collection.Clear(); + foreach (var one in list) { + collection.Add(one); + } + } + + public static void MoveNode(RepositoryNode node, RepositoryNode to = null) { + if (to == null && _instance._repositoryNodes.Contains(node)) return; + if (to != null && to.SubNodes.Contains(node)) return; + + RemoveNode(node); + AddNode(node, to); + } + + public static void RemoveNode(RepositoryNode node) { + RemoveNodeRecursive(node, _instance._repositoryNodes); + } + + public static Repository FindRepository(string path) { + var dir = new DirectoryInfo(path); + foreach (var repo in _instance.Repositories) { + if (repo.FullPath == dir.FullName) return repo; + } + return null; + } + + public static Repository AddRepository(string rootDir, string gitDir) { + var repo = FindRepository(rootDir); + if (repo != null) { + repo.GitDir = gitDir; + return repo; + } + + var dir = new DirectoryInfo(rootDir); + repo = new Repository() { + FullPath = dir.FullName, + GitDir = gitDir + }; + + _instance.Repositories.Add(repo); + return repo; + } + + public static void Save() { + var dir = Path.GetDirectoryName(_savePath); + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + + var data = JsonSerializer.Serialize(_instance, JsonSerializationCodeGen.Default.Preference); + File.WriteAllText(_savePath, data); + } + + private static bool RemoveNodeRecursive(RepositoryNode node, AvaloniaList collection) { + if (collection.Contains(node)) { + collection.Remove(node); + return true; + } + + foreach (RepositoryNode one in collection) { + if (RemoveNodeRecursive(node, one.SubNodes)) return true; + } + + return false; + } + + private static Preference _instance = null; + private static readonly string _savePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "SourceGit", + "preference.json"); + + private string _locale = "en_US"; + private string _theme = "Default"; + private int _maxHistoryCommits = 20000; + private bool _restoreTabs = false; + private bool _useMacOSStyle = OperatingSystem.IsMacOS(); + private bool _useTwoColumnsLayoutInHistories = false; + private bool _useCombinedTextDiff = true; + + private Models.ChangeViewMode _unstagedChangeViewMode = Models.ChangeViewMode.List; + private Models.ChangeViewMode _stagedChangeViewMode = Models.ChangeViewMode.List; + private Models.ChangeViewMode _commitChangeViewMode = Models.ChangeViewMode.List; + + private string _gitDefaultCloneDir = string.Empty; + private bool _gitAutoFetch = false; + + private int _externalMergeToolType = 0; + private string _externalMergeToolPath = string.Empty; + private string _externalMergeToolCmd = string.Empty; + private string _externalMergeToolDiffCmd = string.Empty; + + private AvaloniaList _repositoryNodes = new AvaloniaList(); + } + + [JsonSourceGenerationOptions(WriteIndented = true, IgnoreReadOnlyFields = true, IgnoreReadOnlyProperties = true)] + [JsonSerializable(typeof(Preference))] + internal partial class JsonSerializationCodeGen : JsonSerializerContext { } +} \ No newline at end of file diff --git a/src/ViewModels/PruneRemote.cs b/src/ViewModels/PruneRemote.cs new file mode 100644 index 00000000..9d93fd18 --- /dev/null +++ b/src/ViewModels/PruneRemote.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class PruneRemote : Popup { + public Models.Remote Remote { + get; + private set; + } + + public PruneRemote(Repository repo, Models.Remote remote) { + _repo = repo; + Remote = remote; + View = new Views.PruneRemote() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription("Run `prune` on remote ..."); + var succ = new Commands.Remote(_repo.FullPath).Prune(Remote.Name); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/Pull.cs b/src/ViewModels/Pull.cs new file mode 100644 index 00000000..fab1afb3 --- /dev/null +++ b/src/ViewModels/Pull.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Pull : Popup { + public List Remotes => _repo.Remotes; + public Models.Branch Current => _current; + + public bool HasSpecifiedRemoteBranch { + get; + private set; + } + + public Models.Remote SelectedRemote { + get => _selectedRemote; + set { + if (SetProperty(ref _selectedRemote, value)) { + var branches = new List(); + foreach (var branch in _repo.Branches) { + if (branch.Remote == value.Name) branches.Add(branch); + } + RemoteBranches = branches; + SelectedBranch = branches.Count > 0 ? branches[0] : null; + } + } + } + + public List RemoteBranches { + get => _remoteBranches; + private set => SetProperty(ref _remoteBranches, value); + } + + [Required(ErrorMessage = "Remote branch to pull is required!!!")] + public Models.Branch SelectedBranch { + get => _selectedBranch; + set => SetProperty(ref _selectedBranch, value); + } + + public bool UseRebase { + get; + set; + } + + public bool AutoStash { + get; + set; + } + + public Pull(Repository repo, Models.Branch specifiedRemoteBranch) { + _repo = repo; + _current = repo.Branches.Find(x => x.IsCurrent); + + if (specifiedRemoteBranch != null) { + _selectedRemote = repo.Remotes.Find(x => x.Name == specifiedRemoteBranch.Remote); + _selectedBranch = specifiedRemoteBranch; + HasSpecifiedRemoteBranch = true; + } else { + if (!string.IsNullOrEmpty(_current.Upstream)) { + foreach (var branch in repo.Branches) { + if (!branch.IsLocal && _current.Upstream == branch.FullName) { + _selectedRemote = repo.Remotes.Find(x => x.Name == branch.Remote); + _selectedBranch = branch; + break; + } + } + } + + HasSpecifiedRemoteBranch = false; + } + + // Make sure remote is exists. + if (_selectedRemote == null) { + _selectedRemote = repo.Remotes[0]; + _selectedBranch = null; + HasSpecifiedRemoteBranch = false; + } + + _remoteBranches = new List(); + foreach (var branch in _repo.Branches) { + if (branch.Remote == _selectedRemote.Name) _remoteBranches.Add(branch); + } + + if (_selectedBranch == null && _remoteBranches.Count > 0) { + _selectedBranch = _remoteBranches[0]; + } + + View = new Views.Pull() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + var needPopStash = false; + if (AutoStash && _repo.WorkingCopyChangesCount > 0) { + SetProgressDescription("Adding untracked changes..."); + var succ = new Commands.Add(_repo.FullPath).Exec(); + if (succ) { + SetProgressDescription("Stash local changes..."); + succ = new Commands.Stash(_repo.FullPath).Push("PULL_AUTO_STASH"); + } + + if (!succ) { + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return false; + } + + needPopStash = true; + } + + SetProgressDescription($"Pull {_selectedRemote.Name}/{_selectedBranch.Name}..."); + var rs = new Commands.Pull(_repo.FullPath, _selectedRemote.Name, _selectedBranch.Name, UseRebase, SetProgressDescription).Exec(); + if (rs && needPopStash) { + SetProgressDescription("Re-apply local changes..."); + rs = new Commands.Stash(_repo.FullPath).Pop("stash@{0}"); + } + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return rs; + }); + } + + private Repository _repo = null; + private Models.Branch _current = null; + private Models.Remote _selectedRemote = null; + private List _remoteBranches = null; + private Models.Branch _selectedBranch = null; + } +} diff --git a/src/ViewModels/Push.cs b/src/ViewModels/Push.cs new file mode 100644 index 00000000..1a71ccc8 --- /dev/null +++ b/src/ViewModels/Push.cs @@ -0,0 +1,174 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Push : Popup { + public bool HasSpecifiedLocalBranch { + get; + private set; + } + + [Required(ErrorMessage = "Local branch is required!!!")] + public Models.Branch SelectedLocalBranch { + get => _selectedLocalBranch; + set { + if (SetProperty(ref _selectedLocalBranch, value)) { + // If selected local branch has upstream branch. Try to find it's remote. + if (!string.IsNullOrEmpty(value.Upstream)) { + var branch = _repo.Branches.Find(x => x.FullName == value.Upstream); + if (branch != null) { + var remote = _repo.Remotes.Find(x => x.Name == branch.Remote); + if (remote != null && remote != _selectedRemote) { + SelectedRemote = remote; + return; + } + } + } + + // Re-generate remote branches and auto-select remote branches. + AutoSelectBranchByRemote(); + } + } + } + + public List LocalBranches { + get; + private set; + } + + public List Remotes { + get => _repo.Remotes; + } + + [Required(ErrorMessage = "Remote is required!!!")] + public Models.Remote SelectedRemote { + get => _selectedRemote; + set { + if (SetProperty(ref _selectedRemote, value)) AutoSelectBranchByRemote(); + } + } + + public List RemoteBranches { + get => _remoteBranches; + private set => SetProperty(ref _remoteBranches, value); + } + + [Required(ErrorMessage = "Remote branch is required!!!")] + public Models.Branch SelectedRemoteBranch { + get => _selectedRemoteBranch; + set => SetProperty(ref _selectedRemoteBranch, value); + } + + public bool PushAllTags { + get; + set; + } + + public bool ForcePush { + get; + set; + } + + public Push(Repository repo, Models.Branch localBranch) { + _repo = repo; + + // Gather all local branches and find current branch. + LocalBranches = new List(); + var current = null as Models.Branch; + foreach (var branch in _repo.Branches) { + if (branch.IsLocal) LocalBranches.Add(branch); + if (branch.IsCurrent) current = branch; + } + + // Set default selected local branch. + if (localBranch != null) { + _selectedLocalBranch = localBranch; + HasSpecifiedLocalBranch = true; + } else { + _selectedLocalBranch = current; + HasSpecifiedLocalBranch = false; + } + + // Find preferred remote if selected local branch has upstream. + if (!string.IsNullOrEmpty(_selectedLocalBranch.Upstream)) { + foreach (var branch in repo.Branches) { + if (!branch.IsLocal && _selectedLocalBranch.Upstream == branch.FullName) { + _selectedRemote = repo.Remotes.Find(x => x.Name == branch.Remote); + break; + } + } + } + + // Set default remote to the first if haven't been set. + if (_selectedRemote == null) _selectedRemote = repo.Remotes[0]; + + // Auto select preferred remote branch. + AutoSelectBranchByRemote(); + + View = new Views.Push() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + var remoteBranchName = _selectedRemoteBranch.Name.Replace(" (new)", ""); + SetProgressDescription($"Push {_selectedLocalBranch.Name} -> {_selectedRemote.Name}/{remoteBranchName} ..."); + var succ = new Commands.Push( + _repo.FullPath, + _selectedLocalBranch.Name, + _selectedRemote.Name, + remoteBranchName, + PushAllTags, + ForcePush, + string.IsNullOrEmpty(_selectedLocalBranch.Upstream), + SetProgressDescription).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private void AutoSelectBranchByRemote() { + // Gather branches. + var branches = new List(); + foreach (var branch in _repo.Branches) { + if (branch.Remote == _selectedRemote.Name) branches.Add(branch); + } + + // If selected local branch has upstream branch. Try to find it in current remote branches. + if (!string.IsNullOrEmpty(_selectedLocalBranch.Upstream)) { + foreach (var branch in branches) { + if (_selectedLocalBranch.Upstream == branch.FullName) { + RemoteBranches = branches; + SelectedRemoteBranch = branch; + return; + } + } + } + + // Find best remote branch by name. + foreach (var branch in branches) { + if (_selectedLocalBranch.Name == branch.Name) { + RemoteBranches = branches; + SelectedRemoteBranch = branch; + return; + } + } + + // Add a fake new branch. + var fake = new Models.Branch() { + Name = $"{_selectedLocalBranch.Name} (new)", + Remote = _selectedRemote.Name, + }; + branches.Add(fake); + RemoteBranches = branches; + SelectedRemoteBranch = fake; + } + + private Repository _repo = null; + private Models.Branch _selectedLocalBranch = null; + private Models.Remote _selectedRemote = null; + private List _remoteBranches = new List(); + private Models.Branch _selectedRemoteBranch = null; + } +} diff --git a/src/ViewModels/PushTag.cs b/src/ViewModels/PushTag.cs new file mode 100644 index 00000000..77b70998 --- /dev/null +++ b/src/ViewModels/PushTag.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class PushTag : Popup { + public Models.Tag Target { + get; + private set; + } + + public List Remotes { + get => _repo.Remotes; + } + + public Models.Remote SelectedRemote { + get; + set; + } + + public PushTag(Repository repo, Models.Tag target) { + _repo = repo; + Target = target; + SelectedRemote = _repo.Remotes[0]; + View = new Views.PushTag() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription($"Pushing tag '{Target.Name}' to remote '{SelectedRemote.Name}' ..."); + var succ = new Commands.Push(_repo.FullPath, SelectedRemote.Name, Target.Name, false).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/Rebase.cs b/src/ViewModels/Rebase.cs new file mode 100644 index 00000000..6789c9cc --- /dev/null +++ b/src/ViewModels/Rebase.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Rebase : Popup { + public Models.Branch Current { + get; + private set; + } + + public object On { + get; + private set; + } + + public bool AutoStash { + get; + set; + } + + public Rebase(Repository repo, Models.Branch current, Models.Branch on) { + _repo = repo; + _revision = on.Head; + Current = current; + On = on; + AutoStash = true; + View = new Views.Rebase() { DataContext = this }; + } + + public Rebase(Repository repo, Models.Branch current, Models.Commit on) { + _repo = repo; + _revision = on.SHA; + Current = current; + On = on; + AutoStash = true; + View = new Views.Rebase() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + var succ = new Commands.Rebase(_repo.FullPath, _revision, AutoStash).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private string _revision = string.Empty; + } +} diff --git a/src/ViewModels/RenameBranch.cs b/src/ViewModels/RenameBranch.cs new file mode 100644 index 00000000..0db0b2e9 --- /dev/null +++ b/src/ViewModels/RenameBranch.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class RenameBranch : Popup { + public Models.Branch Target { + get; + private set; + } + + [Required(ErrorMessage = "Branch name is required!!!")] + [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] + [CustomValidation(typeof(RenameBranch), nameof(ValidateBranchName))] + public string Name { + get => _name; + set => SetProperty(ref _name, value, true); + } + + public RenameBranch(Repository repo, Models.Branch target) { + _repo = repo; + _name = target.Name; + Target = target; + View = new Views.RenameBranch() { DataContext = this }; + } + + public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) { + if (ctx.ObjectInstance is RenameBranch rename) { + foreach (var b in rename._repo.Branches) { + if (b != rename.Target && b.Name == name) { + return new ValidationResult("A branch with same name already exists!!!"); + } + } + } + + return ValidationResult.Success; + } + + public override Task Sure() { + if (_name == Target.Name) return null; + + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription($"Rename '{Target.Name}'"); + var succ = Commands.Branch.Rename(_repo.FullPath, Target.Name, _name); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private string _name = string.Empty; + } +} diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs new file mode 100644 index 00000000..5cc94874 --- /dev/null +++ b/src/ViewModels/Repository.cs @@ -0,0 +1,1082 @@ +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Repository : ObservableObject, Models.IRepository { + public string FullPath { + get => _fullpath; + set => SetProperty(ref _fullpath, value); + } + + public string GitDir { + get => _gitDir; + set => SetProperty(ref _gitDir, value); + } + + public AvaloniaList Filters { + get; + private set; + } = new AvaloniaList(); + + public AvaloniaList CommitMessages { + get; + private set; + } = new AvaloniaList(); + + [JsonIgnore] + public bool IsVSCodeFound { + get => !string.IsNullOrEmpty(Native.OS.VSCodeExecutableFile); + } + + [JsonIgnore] + public Models.GitFlow GitFlow { + get => _gitflow; + set => SetProperty(ref _gitflow, value); + } + + [JsonIgnore] + public int SelectedViewIndex { + get => _selectedViewIndex; + set { + if (SetProperty(ref _selectedViewIndex, value)) { + switch (value) { + case 1: + SelectedView = _workingCopy; + break; + case 2: + SelectedView = _stashesPage; + break; + default: + SelectedView = _histories; + break; + } + } + } + } + + [JsonIgnore] + public object SelectedView { + get => _selectedView; + set => SetProperty(ref _selectedView, value); + } + + [JsonIgnore] + public List Remotes { + get => _remotes; + private set => SetProperty(ref _remotes, value); + } + + [JsonIgnore] + public List Branches { + get => _branches; + private set => SetProperty(ref _branches, value); + } + + [JsonIgnore] + public List LocalBranchTrees { + get => _localBranchTrees; + private set => SetProperty(ref _localBranchTrees, value); + } + + [JsonIgnore] + public List RemoteBranchTrees { + get => _remoteBranchTrees; + private set => SetProperty(ref _remoteBranchTrees, value); + } + + [JsonIgnore] + public List Tags { + get => _tags; + private set => SetProperty(ref _tags, value); + } + + [JsonIgnore] + public List Submodules { + get => _submodules; + private set => SetProperty(ref _submodules, value); + } + + [JsonIgnore] + public int WorkingCopyChangesCount { + get => _workingCopy.Count; + } + + [JsonIgnore] + public int StashesCount { + get => _stashesPage.Count; + } + + [JsonIgnore] + public bool IsConflictBarVisible { + get => _isConflictBarVisible; + private set => SetProperty(ref _isConflictBarVisible, value); + } + + [JsonIgnore] + public bool HasUnsolvedConflict { + get => _hasUnsolvedConflict; + private set => SetProperty(ref _hasUnsolvedConflict, value); + } + + [JsonIgnore] + public bool CanCommitWithPush { + get => _canCommitWithPush; + private set => SetProperty(ref _canCommitWithPush, value); + } + + [JsonIgnore] + public bool IncludeUntracked { + get => _includeUntracked; + set { + if (SetProperty(ref _includeUntracked, value)) { + Task.Run(RefreshWorkingCopyChanges); + } + } + } + + [JsonIgnore] + public bool IsSearching { + get => _isSearching; + set { + if (SetProperty(ref _isSearching, value)) { + SearchedCommits = new List(); + SearchCommitFilter = string.Empty; + if (value) SelectedViewIndex = 0; + } + } + } + + [JsonIgnore] + public string SearchCommitFilter { + get => _searchCommitFilter; + set => SetProperty(ref _searchCommitFilter, value); + } + + [JsonIgnore] + public List SearchedCommits { + get => _searchedCommits; + set => SetProperty(ref _searchedCommits, value); + } + + public void Open() { + _watcher = new Models.Watcher(this); + _histories = new Histories(this); + _workingCopy = new WorkingCopy(this); + _stashesPage = new StashesPage(this); + _selectedView = _histories; + _selectedViewIndex = 0; + _isConflictBarVisible = false; + _hasUnsolvedConflict = false; + + Task.Run(() => { + RefreshBranches(); + RefreshTags(); + RefreshCommits(); + }); + + Task.Run(RefreshSubmodules); + Task.Run(RefreshWorkingCopyChanges); + Task.Run(RefreshStashes); + Task.Run(RefreshGitFlow); + } + + public void Close() { + _watcher.Dispose(); + _watcher = null; + _histories = null; + _workingCopy = null; + _stashesPage = null; + _selectedView = null; + _isSearching = false; + _searchCommitFilter = string.Empty; + + _remotes.Clear(); + _branches.Clear(); + _localBranchTrees.Clear(); + _remoteBranchTrees.Clear(); + _tags.Clear(); + _submodules.Clear(); + _searchedCommits.Clear(); + + GC.Collect(); + } + + public void OpenInFileManager() { + Native.OS.OpenInFileManager(_fullpath); + } + + public void OpenInVSCode() { + Native.OS.OpenInVSCode(_fullpath); + } + + public void OpenInTerminal() { + Native.OS.OpenTerminal(_fullpath); + } + + public void Fetch() { + if (!PopupHost.CanCreatePopup()) return; + + if (Remotes.Count == 0) { + App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + return; + } + + PopupHost.ShowPopup(new Fetch(this)); + } + + public void Pull() { + if (!PopupHost.CanCreatePopup()) return; + + if (Remotes.Count == 0) { + App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + return; + } + + PopupHost.ShowPopup(new Pull(this, null)); + } + + public void Push() { + if (!PopupHost.CanCreatePopup()) return; + + if (Remotes.Count == 0) { + App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + return; + } + + if (Branches.Find(x => x.IsCurrent) == null) App.RaiseException(_fullpath, "Can NOT found current branch!!!"); + PopupHost.ShowPopup(new Push(this, null)); + } + + public void ApplyPatch() { + if (!PopupHost.CanCreatePopup()) return; + PopupHost.ShowPopup(new Apply(this)); + } + + public void Cleanup() { + if (!PopupHost.CanCreatePopup()) return; + PopupHost.ShowAndStartPopup(new Cleanup(this)); + } + + public void OpenConfigure() { + if (!PopupHost.CanCreatePopup()) return; + PopupHost.ShowPopup(new RepositoryConfigure(this)); + } + + public void ClearSearchCommitFilter() { + SearchCommitFilter = string.Empty; + } + + public void StartSearchCommits() { + if (_histories == null) return; + + var visible = new List(); + foreach (var c in _histories.Commits) { + if (c.SHA.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase) + || c.Subject.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase) + || c.Message.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase) + || c.Author.Name.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase) + || c.Committer.Name.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase)) { + visible.Add(c); + } + } + + SearchedCommits = visible; + } + + public void ExitSearchMode() { + IsSearching = false; + } + + public void SetWatcherEnabled(bool enabled) { + if (_watcher != null) _watcher.SetEnabled(enabled); + } + + public void NavigateToCommit(string sha) { + if (_histories != null) { + SelectedViewIndex = 0; + _histories.NavigateTo(sha); + } + } + + public void UpdateFilter(string filter, bool toggle) { + var changed = false; + if (toggle) { + if (!Filters.Contains(filter)) { + Filters.Add(filter); + changed = true; + } + } else { + changed = Filters.Remove(filter); + } + + if (changed) Task.Run(RefreshCommits); + } + + public void StashAll() { + if (PopupHost.CanCreatePopup()) { + var changes = new List(); + changes.AddRange(_workingCopy.Unstaged); + changes.AddRange(_workingCopy.Staged); + PopupHost.ShowPopup(new StashChanges(this, changes, true)); + } + } + + public void GotoResolve() { + if (_workingCopy != null) SelectedViewIndex = 1; + } + + public async void ContinueMerge() { + var cherryPickMerge = Path.Combine(_gitDir, "CHERRY_PICK_HEAD"); + var rebaseMerge = Path.Combine(_gitDir, "REBASE_HEAD"); + var rebaseMergeFolder = Path.Combine(_gitDir, "rebase-merge"); + var revertMerge = Path.Combine(_gitDir, "REVERT_HEAD"); + var otherMerge = Path.Combine(_gitDir, "MERGE_HEAD"); + + var mode = ""; + if (File.Exists(cherryPickMerge)) { + mode = "cherry-pick"; + } else if (File.Exists(rebaseMerge) && Directory.Exists(rebaseMergeFolder)) { + mode = "rebase"; + } else if (File.Exists(revertMerge)) { + mode = "revert"; + } else if (File.Exists(otherMerge)) { + mode = "merge"; + } else { + await Task.Run(RefreshWorkingCopyChanges); + return; + } + + var cmd = new Commands.Command(); + cmd.WorkingDirectory = _fullpath; + cmd.Context = _fullpath; + cmd.Args = $"-c core.editor=true {mode} --continue"; + + SetWatcherEnabled(false); + var succ = await Task.Run(cmd.Exec); + SetWatcherEnabled(true); + + if (succ) { + if (_workingCopy != null) _workingCopy.CommitMessage = string.Empty; + + if (mode == "rebase") { + if (File.Exists(rebaseMerge)) File.Delete(rebaseMerge); + if (Directory.Exists(rebaseMergeFolder)) Directory.Delete(rebaseMergeFolder); + } + } + } + + public async void AbortMerge() { + var cmd = new Commands.Command(); + cmd.WorkingDirectory = _fullpath; + cmd.Context = _fullpath; + + if (File.Exists(Path.Combine(_gitDir, "CHERRY_PICK_HEAD"))) { + cmd.Args = "cherry-pick --abort"; + } else if (File.Exists(Path.Combine(_gitDir, "REBASE_HEAD"))) { + cmd.Args = "rebase --abort"; + } else if (File.Exists(Path.Combine(_gitDir, "REVERT_HEAD"))) { + cmd.Args = "revert --abort"; + } else if (File.Exists(Path.Combine(_gitDir, "MERGE_HEAD"))) { + cmd.Args = "merge --abort"; + } else { + await Task.Run(RefreshWorkingCopyChanges); + return; + } + + SetWatcherEnabled(false); + await Task.Run(cmd.Exec); + SetWatcherEnabled(true); + } + + public void RefreshBranches() { + var branches = new Commands.QueryBranches(FullPath).Result(); + var remotes = new Commands.QueryRemotes(FullPath).Result(); + + var builder = new Models.BranchTreeNode.Builder(); + builder.CollectExpandedNodes(_localBranchTrees, true); + builder.CollectExpandedNodes(_remoteBranchTrees, false); + builder.Run(branches, remotes); + + Dispatcher.UIThread.Invoke(() => { + Remotes = remotes; + Branches = branches; + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; + + var cur = Branches.Find(x => x.IsCurrent); + CanCommitWithPush = cur != null && !string.IsNullOrEmpty(cur.Upstream); + }); + } + + public void RefreshTags() { + var tags = new Commands.QueryTags(FullPath).Result(); + Dispatcher.UIThread.Invoke(() => { + Tags = tags; + }); + } + + public void RefreshCommits() { + Dispatcher.UIThread.Invoke(() => _histories.IsLoading = true); + + var limits = $"-{Preference.Instance.MaxHistoryCommits} "; + var validFilters = new List(); + foreach (var filter in Filters) { + if (filter.StartsWith("refs/")) { + if (_branches.FindIndex(x => x.FullName == filter) >= 0) validFilters.Add(filter); + } else { + if (_tags.FindIndex(t => t.Name == filter) >= 0) validFilters.Add(filter); + } + } + if (validFilters.Count > 0) { + limits += string.Join(" ", validFilters); + } else { + limits += "--branches --remotes --tags"; + } + + var commits = new Commands.QueryCommits(FullPath, limits).Result(); + Dispatcher.UIThread.Invoke(() => { + _histories.IsLoading = false; + _histories.Commits = commits; + }); + } + + public void RefreshSubmodules() { + var submodules = new Commands.QuerySubmodules(FullPath).Result(); + Dispatcher.UIThread.Invoke(() => { + Submodules = submodules; + }); + } + + public void RefreshWorkingCopyChanges() { + _watcher.MarkWorkingCopyRefreshed(); + + var changes = new Commands.QueryLocalChanges(FullPath, _includeUntracked).Result(); + var hasUnsolvedConflict = _workingCopy.SetData(changes); + + var cherryPickMerge = Path.Combine(_gitDir, "CHERRY_PICK_HEAD"); + var rebaseMerge = Path.Combine(_gitDir, "REBASE_HEAD"); + var rebaseMergeFolder = Path.Combine(_gitDir, "rebase-merge"); + var revertMerge = Path.Combine(_gitDir, "REVERT_HEAD"); + var otherMerge = Path.Combine(_gitDir, "MERGE_HEAD"); + var runningMerge = (File.Exists(cherryPickMerge) || + (File.Exists(rebaseMerge) && Directory.Exists(rebaseMergeFolder)) || + File.Exists(revertMerge) || + File.Exists(otherMerge)); + + if (!runningMerge) { + if (Directory.Exists(rebaseMergeFolder)) Directory.Delete(rebaseMergeFolder, true); + var applyFolder = Path.Combine(_gitDir, "rebase-apply"); + if (Directory.Exists(applyFolder)) Directory.Delete(applyFolder, true); + } + + Dispatcher.UIThread.Invoke(() => { + IsConflictBarVisible = runningMerge; + HasUnsolvedConflict = hasUnsolvedConflict; + OnPropertyChanged(nameof(WorkingCopyChangesCount)); + }); + } + + public void RefreshStashes() { + var stashes = new Commands.QueryStashes(FullPath).Result(); + Dispatcher.UIThread.Invoke(() => { + _stashesPage.Stashes = stashes; + OnPropertyChanged(nameof(StashesCount)); + }); + } + + public void RefreshGitFlow() { + var config = new Commands.Config(_fullpath).ListAll(); + var gitFlow = new Models.GitFlow(); + if (config.ContainsKey("gitflow.prefix.feature")) gitFlow.Feature = config["gitflow.prefix.feature"]; + if (config.ContainsKey("gitflow.prefix.release")) gitFlow.Release = config["gitflow.prefix.release"]; + if (config.ContainsKey("gitflow.prefix.hotfix")) gitFlow.Hotfix = config["gitflow.prefix.hotfix"]; + Dispatcher.UIThread.Invoke(() => { + GitFlow = gitFlow; + }); + } + + public void CreateNewBranch() { + var current = Branches.Find(x => x.IsCurrent); + if (current == null) { + App.RaiseException(_fullpath, "Git do not hold any branch until you do first commit."); + return; + } + + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(this, current)); + } + + public void CreateNewTag() { + var current = Branches.Find(x => x.IsCurrent); + if (current == null) { + App.RaiseException(_fullpath, "Git do not hold any branch until you do first commit."); + return; + } + + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateTag(this, current)); + } + + public void AddRemote() { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new AddRemote(this)); + } + + public void AddSubmodule() { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new AddSubmodule(this)); + } + + public ContextMenu CreateContextMenuForGitFlow() { + var menu = new ContextMenu(); + menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + + if (GitFlow.IsEnabled) { + var startFeature = new MenuItem(); + startFeature.Header = App.Text("GitFlow.StartFeature"); + startFeature.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Feature)); + e.Handled = true; + }; + + var startRelease = new MenuItem(); + startRelease.Header = App.Text("GitFlow.StartRelease"); + startRelease.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Release)); + e.Handled = true; + }; + + var startHotfix = new MenuItem(); + startHotfix.Header = App.Text("GitFlow.StartHotfix"); + startHotfix.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Hotfix)); + e.Handled = true; + }; + + menu.Items.Add(startFeature); + menu.Items.Add(startRelease); + menu.Items.Add(startHotfix); + } else { + var init = new MenuItem(); + init.Header = App.Text("GitFlow.Init"); + init.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new InitGitFlow(this)); + e.Handled = true; + }; + menu.Items.Add(init); + } + return menu; + } + + public ContextMenu CreateContextMenuForLocalBranch(Models.Branch branch) { + var menu = new ContextMenu(); + + var push = new MenuItem(); + push.Header = App.Text("BranchCM.Push", branch.Name); + push.Icon = CreateMenuIcon("Icons.Push"); + push.IsEnabled = Remotes.Count > 0; + push.Click += (_, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Push(this, branch)); + e.Handled = true; + }; + + if (branch.IsCurrent) { + var discard = new MenuItem(); + discard.Header = App.Text("BranchCM.DiscardAll"); + discard.Icon = CreateMenuIcon("Icons.Undo"); + discard.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Discard(this)); + e.Handled = true; + }; + + menu.Items.Add(discard); + menu.Items.Add(new MenuItem() { Header = "-" }); + + if (!string.IsNullOrEmpty(branch.Upstream)) { + var upstream = branch.Upstream.Substring(13); + var fastForward = new MenuItem(); + fastForward.Header = App.Text("BranchCM.FastForward", upstream); + fastForward.Icon = CreateMenuIcon("Icons.FastForward"); + fastForward.IsEnabled = !string.IsNullOrEmpty(branch.UpstreamTrackStatus) && branch.UpstreamTrackStatus.IndexOf('↑') < 0; + fastForward.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Merge(this, upstream, branch.Name)); + e.Handled = true; + }; + + var pull = new MenuItem(); + pull.Header = App.Text("BranchCM.Pull", upstream); + pull.Icon = CreateMenuIcon("Icons.Pull"); + pull.IsEnabled = !string.IsNullOrEmpty(branch.UpstreamTrackStatus); + pull.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Pull(this, null)); + e.Handled = true; + }; + + menu.Items.Add(fastForward); + menu.Items.Add(pull); + } + + menu.Items.Add(push); + } else { + var current = Branches.Find(x => x.IsCurrent); + + var checkout = new MenuItem(); + checkout.Header = App.Text("BranchCM.Checkout", branch.Name); + checkout.Icon = CreateMenuIcon("Icons.Check"); + checkout.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Checkout(this, branch.Name)); + e.Handled = true; + }; + menu.Items.Add(checkout); + + var upstream = Branches.Find(x => x.FullName == branch.Upstream); + if (upstream != null) { + var fastForward = new MenuItem(); + fastForward.Header = App.Text("BranchCM.FastForward", $"{upstream.Remote}/{upstream.Name}"); + fastForward.Icon = CreateMenuIcon("Icons.FastForward"); + fastForward.IsEnabled = !string.IsNullOrEmpty(branch.UpstreamTrackStatus) && branch.UpstreamTrackStatus.IndexOf('↑') < 0; + fastForward.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new FastForwardWithoutCheckout(this, branch, upstream)); + e.Handled = true; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(fastForward); + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(push); + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", branch.Name, current.Name); + merge.Icon = CreateMenuIcon("Icons.Merge"); + merge.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Merge(this, branch.Name, current.Name)); + e.Handled = true; + }; + + var rebase = new MenuItem(); + rebase.Header = App.Text("BranchCM.Rebase", current.Name, branch.Name); + rebase.Icon = CreateMenuIcon("Icons.Rebase"); + rebase.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Rebase(this, current, branch)); + e.Handled = true; + }; + + menu.Items.Add(merge); + menu.Items.Add(rebase); + } + + var type = GitFlow.GetBranchType(branch.Name); + if (type != Models.GitFlowBranchType.None) { + var finish = new MenuItem(); + finish.Header = App.Text("BranchCM.Finish", branch.Name); + finish.Icon = CreateMenuIcon("Icons.Flow"); + finish.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new GitFlowFinish(this, branch, type)); + e.Handled = true; + }; + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(finish); + } + + var rename = new MenuItem(); + rename.Header = App.Text("BranchCM.Rename", branch.Name); + rename.Icon = CreateMenuIcon("Icons.Rename"); + rename.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new RenameBranch(this, branch)); + e.Handled = true; + }; + + var delete = new MenuItem(); + delete.Header = App.Text("BranchCM.Delete", branch.Name); + delete.Icon = CreateMenuIcon("Icons.Clear"); + delete.IsEnabled = !branch.IsCurrent; + delete.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteBranch(this, branch)); + e.Handled = true; + }; + + var createBranch = new MenuItem(); + createBranch.Icon = CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(this, branch)); + e.Handled = true; + }; + + var createTag = new MenuItem(); + createTag.Icon = CreateMenuIcon("Icons.Tag.Add"); + createTag.Header = App.Text("CreateTag"); + createTag.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateTag(this, branch)); + e.Handled = true; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(rename); + menu.Items.Add(delete); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(createBranch); + menu.Items.Add(createTag); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var remoteBranches = new List(); + foreach (var b in Branches) { + if (!b.IsLocal) remoteBranches.Add(b); + } + + if (remoteBranches.Count > 0) { + var tracking = new MenuItem(); + tracking.Header = App.Text("BranchCM.Tracking"); + tracking.Icon = CreateMenuIcon("Icons.Branch"); + + foreach (var b in remoteBranches) { + var upstream = b.FullName.Replace("refs/remotes/", ""); + var target = new MenuItem(); + target.Header = upstream; + if (branch.Upstream == b.FullName) target.Icon = CreateMenuIcon("Icons.Check"); + + target.Click += (o, e) => { + if (Commands.Branch.SetUpstream(_fullpath, branch.Name, upstream)) { + Task.Run(RefreshBranches); + } + e.Handled = true; + }; + + tracking.Items.Add(target); + } + + var unsetUpstream = new MenuItem(); + unsetUpstream.Header = App.Text("BranchCM.UnsetUpstream"); + unsetUpstream.Click += (_, e) => { + if (Commands.Branch.SetUpstream(_fullpath, branch.Name, string.Empty)) { + Task.Run(RefreshBranches); + } + e.Handled = true; + }; + tracking.Items.Add(new MenuItem() { Header = "-" }); + tracking.Items.Add(unsetUpstream); + + menu.Items.Add(tracking); + } + + var archive = new MenuItem(); + archive.Icon = CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Archive(this, branch)); + e.Handled = true; + }; + menu.Items.Add(archive); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = CreateMenuIcon("Icons.Copy"); + copy.Click += (o, e) => { + App.CopyText(branch.Name); + e.Handled = true; + }; + menu.Items.Add(copy); + + return menu; + } + + public ContextMenu CreateContextMenuForRemote(Models.Remote remote) { + var menu = new ContextMenu(); + + var fetch = new MenuItem(); + fetch.Header = App.Text("RemoteCM.Fetch"); + fetch.Icon = CreateMenuIcon("Icons.Fetch"); + fetch.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Fetch(this, remote)); + e.Handled = true; + }; + + var prune = new MenuItem(); + prune.Header = App.Text("RemoteCM.Prune"); + prune.Icon = CreateMenuIcon("Icons.Clear2"); + prune.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new PruneRemote(this, remote)); + e.Handled = true; + }; + + var edit = new MenuItem(); + edit.Header = App.Text("RemoteCM.Edit"); + edit.Icon = CreateMenuIcon("Icons.Edit"); + edit.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new EditRemote(this, remote)); + e.Handled = true; + }; + + var delete = new MenuItem(); + delete.Header = App.Text("RemoteCM.Delete"); + delete.Icon = CreateMenuIcon("Icons.Clear"); + delete.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteRemote(this, remote)); + e.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("RemoteCM.CopyURL"); + copy.Icon = CreateMenuIcon("Icons.Copy"); + copy.Click += (o, e) => { + App.CopyText(remote.URL); + e.Handled = true; + }; + + menu.Items.Add(fetch); + menu.Items.Add(prune); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(edit); + menu.Items.Add(delete); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copy); + return menu; + } + + public ContextMenu CreateContextMenuForRemoteBranch(Models.Branch branch) { + var menu = new ContextMenu(); + var current = Branches.Find(x => x.IsCurrent); + + var checkout = new MenuItem(); + checkout.Header = App.Text("BranchCM.Checkout", $"{branch.Remote}/{branch.Name}"); + checkout.Icon = CreateMenuIcon("Icons.Check"); + checkout.Click += (o, e) => { + foreach (var b in Branches) { + if (b.IsLocal && b.Upstream == branch.FullName) { + if (b.IsCurrent) return; + if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Checkout(this, b.Name)); + return; + } + } + + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(this, branch)); + e.Handled = true; + }; + menu.Items.Add(checkout); + menu.Items.Add(new MenuItem() { Header = "-" }); + + if (current != null) { + var pull = new MenuItem(); + pull.Header = App.Text("BranchCM.PullInto", $"{branch.Remote}/{branch.Name}", current.Name); + pull.Icon = CreateMenuIcon("Icons.Pull"); + pull.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Pull(this, branch)); + e.Handled = true; + }; + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", $"{branch.Remote}/{branch.Name}", current.Name); + merge.Icon = CreateMenuIcon("Icons.Merge"); + merge.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Merge(this, $"{branch.Remote}/{branch.Name}", current.Name)); + e.Handled = true; + }; + + var rebase = new MenuItem(); + rebase.Header = App.Text("BranchCM.Rebase", current.Name, $"{branch.Remote}/{branch.Name}"); + rebase.Icon = CreateMenuIcon("Icons.Rebase"); + rebase.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Rebase(this, current, branch)); + e.Handled = true; + }; + + menu.Items.Add(pull); + menu.Items.Add(merge); + menu.Items.Add(rebase); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var delete = new MenuItem(); + delete.Header = App.Text("BranchCM.Delete", $"{branch.Remote}/{branch.Name}"); + delete.Icon = CreateMenuIcon("Icons.Clear"); + delete.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteBranch(this, branch)); + e.Handled = true; + }; + + var createBranch = new MenuItem(); + createBranch.Icon = CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(this, branch)); + e.Handled = true; + }; + + var createTag = new MenuItem(); + createTag.Icon = CreateMenuIcon("Icons.Tag.Add"); + createTag.Header = App.Text("CreateTag"); + createTag.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateTag(this, branch)); + e.Handled = true; + }; + + var archive = new MenuItem(); + archive.Icon = CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (o, e) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Archive(this, branch)); + e.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = CreateMenuIcon("Icons.Copy"); + copy.Click += (o, e) => { + App.CopyText(branch.Remote + "/" + branch.Name); + e.Handled = true; + }; + + menu.Items.Add(delete); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(createBranch); + menu.Items.Add(createTag); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(archive); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copy); + return menu; + } + + public ContextMenu CreateContextMenuForTag(Models.Tag tag) { + var createBranch = new MenuItem(); + createBranch.Icon = CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Click += (o, ev) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(this, tag)); + ev.Handled = true; + }; + + var pushTag = new MenuItem(); + pushTag.Header = App.Text("TagCM.Push", tag.Name); + pushTag.Icon = CreateMenuIcon("Icons.Push"); + pushTag.IsEnabled = Remotes.Count > 0; + pushTag.Click += (o, ev) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new PushTag(this, tag)); + ev.Handled = true; + }; + + var deleteTag = new MenuItem(); + deleteTag.Header = App.Text("TagCM.Delete", tag.Name); + deleteTag.Icon = CreateMenuIcon("Icons.Clear"); + deleteTag.Click += (o, ev) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteTag(this, tag)); + ev.Handled = true; + }; + + var archive = new MenuItem(); + archive.Icon = CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (o, ev) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Archive(this, tag)); + ev.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("TagCM.Copy"); + copy.Icon = CreateMenuIcon("Icons.Copy"); + copy.Click += (o, ev) => { + App.CopyText(tag.Name); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(createBranch); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(pushTag); + menu.Items.Add(deleteTag); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(archive); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copy); + return menu; + } + + public ContextMenu CreateContextMenuForSubmodule(string submodule) { + var open = new MenuItem(); + open.Header = App.Text("Submodule.Open"); + open.Icon = CreateMenuIcon("Icons.Folder.Open"); + open.Click += (o, ev) => { + var root = Path.GetFullPath(Path.Combine(_fullpath, submodule)); + var gitDir = new Commands.QueryGitDir(root).Result(); + var repo = Preference.AddRepository(root, gitDir); + var node = new RepositoryNode() { + Id = root, + Name = Path.GetFileName(root), + Bookmark = 0, + IsRepository = true, + }; + + var launcher = App.GetTopLevel().DataContext as Launcher; + if (launcher != null) { + launcher.OpenRepositoryInTab(node, null); + } + + ev.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("Submodule.CopyPath"); + copy.Icon = CreateMenuIcon("Icons.Copy"); + copy.Click += (o, ev) => { + App.CopyText(submodule); + ev.Handled = true; + }; + + var rm = new MenuItem(); + rm.Header = App.Text("Submodule.Remove"); + rm.Icon = CreateMenuIcon("Icons.Clear"); + rm.Click += (o, ev) => { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteSubmodule(this, submodule)); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(open); + menu.Items.Add(copy); + menu.Items.Add(rm); + return menu; + } + + private Avalonia.Controls.Shapes.Path CreateMenuIcon(string key) { + var icon = new Avalonia.Controls.Shapes.Path(); + icon.Width = 12; + icon.Height = 12; + icon.Stretch = Stretch.Uniform; + icon.Data = App.Current?.FindResource(key) as StreamGeometry; + return icon; + } + + private string _fullpath = string.Empty; + private string _gitDir = string.Empty; + private Models.GitFlow _gitflow = new Models.GitFlow(); + + private Models.Watcher _watcher = null; + private Histories _histories = null; + private WorkingCopy _workingCopy = null; + private StashesPage _stashesPage = null; + private int _selectedViewIndex = 0; + private object _selectedView = null; + + private bool _isSearching = false; + private string _searchCommitFilter = string.Empty; + private List _searchedCommits = new List(); + + private List _remotes = new List(); + private List _branches = new List(); + private List _localBranchTrees = new List(); + private List _remoteBranchTrees = new List(); + private List _tags = new List(); + private List _submodules = new List(); + private bool _isConflictBarVisible = false; + private bool _hasUnsolvedConflict = false; + private bool _canCommitWithPush = false; + private bool _includeUntracked = true; + } +} diff --git a/src/ViewModels/RepositoryConfigure.cs b/src/ViewModels/RepositoryConfigure.cs new file mode 100644 index 00000000..928d58fd --- /dev/null +++ b/src/ViewModels/RepositoryConfigure.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class RepositoryConfigure : Popup { + public string UserName { + get; + set; + } + + public string UserEmail { + get; + set; + } + + public bool GPGSigningEnabled { + get; + set; + } + + public string GPGUserSigningKey { + get; + set; + } + + public string HttpProxy { + get; + set; + } + + public RepositoryConfigure(Repository repo) { + _repo = repo; + + _cached = new Commands.Config(repo.FullPath).ListAll(); + if (_cached.ContainsKey("user.name")) UserName = _cached["user.name"]; + if (_cached.ContainsKey("user.email")) UserEmail = _cached["user.email"]; + if (_cached.ContainsKey("commit.gpgsign")) GPGSigningEnabled = _cached["commit.gpgsign"] == "true"; + if (_cached.ContainsKey("user.signingkey")) GPGUserSigningKey = _cached["user.signingkey"]; + if (_cached.ContainsKey("http.proxy")) HttpProxy = _cached["user.signingkey"]; + + View = new Views.RepositoryConfigure() { DataContext = this }; + } + + public override Task Sure() { + SetIfChanged("user.name", UserName); + SetIfChanged("user.email", UserEmail); + SetIfChanged("commit.gpgsign", GPGSigningEnabled ? "true" : "false"); + SetIfChanged("user.signingkey", GPGUserSigningKey); + SetIfChanged("http.proxy", HttpProxy); + return null; + } + + private void SetIfChanged(string key, string value) { + bool changed = false; + if (_cached.ContainsKey(key)) { + changed = value != _cached[key]; + } else if (!string.IsNullOrEmpty(value)) { + changed = true; + } + + if (changed) { + new Commands.Config(_repo.FullPath).Set(key, value); + } + } + + private Repository _repo = null; + private Dictionary _cached = null; + } +} diff --git a/src/ViewModels/RepositoryNode.cs b/src/ViewModels/RepositoryNode.cs new file mode 100644 index 00000000..6d3979ae --- /dev/null +++ b/src/ViewModels/RepositoryNode.cs @@ -0,0 +1,73 @@ +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using System.Text.Json.Serialization; + +namespace SourceGit.ViewModels { + public class RepositoryNode : ObservableObject { + public string Id { + get => _id; + set => SetProperty(ref _id, value); + } + + public string Name { + get => _name; + set => SetProperty(ref _name, value); + } + + public int Bookmark { + get => _bookmark; + set => SetProperty(ref _bookmark, value); + } + + public bool IsRepository { + get => _isRepository; + set => SetProperty(ref _isRepository, value); + } + + public bool IsExpanded { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + [JsonIgnore] + public bool IsVisible { + get => _isVisible; + set => SetProperty(ref _isVisible, value); + } + + public AvaloniaList SubNodes { + get => _subNodes; + set => SetProperty(ref _subNodes, value); + } + + public void Edit() { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new EditRepositoryNode(this)); + } + + public void AddSubFolder() { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateGroup(this)); + } + + public void OpenInFileManager() { + if (!IsRepository) return; + Native.OS.OpenInFileManager(_id); + } + + public void OpenTerminal() { + if (!IsRepository) return; + Native.OS.OpenTerminal(_id); + } + + public void Delete() { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteRepositoryNode(this)); + } + + private string _id = string.Empty; + private string _name = string.Empty; + private bool _isRepository = false; + private int _bookmark = 0; + private bool _isExpanded = false; + private bool _isVisible = true; + private AvaloniaList _subNodes = new AvaloniaList(); + } +} diff --git a/src/ViewModels/Reset.cs b/src/ViewModels/Reset.cs new file mode 100644 index 00000000..c415e08a --- /dev/null +++ b/src/ViewModels/Reset.cs @@ -0,0 +1,66 @@ +using Avalonia.Media; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class ResetMode { + public string Name { get; set; } + public string Desc { get; set; } + public string Arg { get; set; } + public IBrush Color { get; set; } + + public ResetMode(string n, string d, string a, IBrush b) { + Name = n; + Desc = d; + Arg = a; + Color = b; + } + } + + public class Reset : Popup { + public Models.Branch Current { + get; + private set; + } + + public Models.Commit To { + get; + private set; + } + + public List Modes { + get; + private set; + } + + public ResetMode SelectedMode { + get; + set; + } + + public Reset(Repository repo, Models.Branch current, Models.Commit to) { + _repo = repo; + Current = current; + To = to; + Modes = new List() { + new ResetMode("Soft", "Keep all changes. Stage differences", "--soft", Brushes.Green), + new ResetMode("Mixed", "Keep all changes. Unstage differences", "--mixed", Brushes.Orange), + new ResetMode("Hard", "Discard all changes", "--hard", Brushes.Red), + }; + SelectedMode = Modes[0]; + View = new Views.Reset() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription($"Reset current branch to {To.SHA} ..."); + var succ = new Commands.Reset(_repo.FullPath, To.SHA, SelectedMode.Arg).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/Revert.cs b/src/ViewModels/Revert.cs new file mode 100644 index 00000000..49a379ab --- /dev/null +++ b/src/ViewModels/Revert.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Revert : Popup { + public Models.Commit Target { + get; + private set; + } + + public bool AutoCommit { + get; + set; + } + + public Revert(Repository repo, Models.Commit target) { + _repo = repo; + Target = target; + AutoCommit = true; + View = new Views.Revert() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription($"Revert commit '{Target.SHA}' ..."); + var succ = new Commands.Revert(_repo.FullPath, Target.SHA, AutoCommit).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/RevisionCompare.cs b/src/ViewModels/RevisionCompare.cs new file mode 100644 index 00000000..c5534931 --- /dev/null +++ b/src/ViewModels/RevisionCompare.cs @@ -0,0 +1,172 @@ +using Avalonia.Controls; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class RevisionCompare : ObservableObject { + public Models.Commit StartPoint { + get; + private set; + } + + public Models.Commit EndPoint { + get; + private set; + } + + public List VisibleChanges { + get => _visibleChanges; + private set => SetProperty(ref _visibleChanges, value); + } + + public List ChangeTree { + get => _changeTree; + private set => SetProperty(ref _changeTree, value); + } + + public Models.Change SelectedChange { + get => _selectedChange; + set { + if (SetProperty(ref _selectedChange, value)) { + if (value == null) { + SelectedNode = null; + DiffContext = null; + } else { + SelectedNode = FileTreeNode.SelectByPath(_changeTree, value.Path); + DiffContext = new DiffContext(_repo, new Models.DiffOption(StartPoint.SHA, EndPoint.SHA, value)); + } + } + } + } + + public FileTreeNode SelectedNode { + get => _selectedNode; + set { + if (SetProperty(ref _selectedNode, value)) { + if (value == null) { + SelectedChange = null; + } else { + SelectedChange = value.Backend as Models.Change; + } + } + } + } + + public string SearchFilter { + get => _searchFilter; + set { + if (SetProperty(ref _searchFilter, value)) { + RefreshVisible(); + } + } + } + + public DiffContext DiffContext { + get => _diffContext; + private set => SetProperty(ref _diffContext, value); + } + + public RevisionCompare(string repo, Models.Commit startPoint, Models.Commit endPoint) { + _repo = repo; + StartPoint = startPoint; + EndPoint = endPoint; + + Task.Run(() => { + _changes = new Commands.CompareRevisions(_repo, startPoint.SHA, endPoint.SHA).Result(); + + var visible = _changes; + if (!string.IsNullOrWhiteSpace(_searchFilter)) { + visible = new List(); + foreach (var c in _changes) { + if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) { + visible.Add(c); + } + } + } + + var tree = FileTreeNode.Build(visible); + Dispatcher.UIThread.Invoke(() => { + VisibleChanges = visible; + ChangeTree = tree; + }); + }); + } + + public void NavigateTo(string commitSHA) { + var repo = Preference.FindRepository(_repo); + if (repo != null) repo.NavigateToCommit(commitSHA); + } + + public void ClearSearchFilter() { + SearchFilter = string.Empty; + } + + public ContextMenu CreateChangeContextMenu(Models.Change change) { + var menu = new ContextMenu(); + + if (change.Index != Models.ChangeState.Deleted) { + var history = new MenuItem(); + history.Header = App.Text("FileHistory"); + history.Click += (_, ev) => { + var window = new Views.FileHistories() { DataContext = new FileHistories(_repo, change.Path) }; + window.Show(); + ev.Handled = true; + }; + + var full = Path.GetFullPath(Path.Combine(_repo, change.Path)); + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.IsEnabled = File.Exists(full); + explore.Click += (_, ev) => { + Native.OS.OpenInFileManager(full, true); + ev.Handled = true; + }; + + menu.Items.Add(history); + menu.Items.Add(explore); + } + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Click += (_, ev) => { + App.CopyText(change.Path); + ev.Handled = true; + }; + + menu.Items.Add(copyPath); + return menu; + } + + private void RefreshVisible() { + if (_changes == null) return; + + if (string.IsNullOrEmpty(_searchFilter)) { + VisibleChanges = _changes; + } else { + var visible = new List(); + foreach (var c in _changes) { + if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) { + visible.Add(c); + } + } + + VisibleChanges = visible; + } + + ChangeTree = FileTreeNode.Build(_visibleChanges); + } + + private string _repo = string.Empty; + private List _changes = null; + private List _visibleChanges = null; + private List _changeTree = null; + private Models.Change _selectedChange = null; + private FileTreeNode _selectedNode = null; + private string _searchFilter = string.Empty; + private DiffContext _diffContext = null; + } +} diff --git a/src/ViewModels/Reword.cs b/src/ViewModels/Reword.cs new file mode 100644 index 00000000..f864fa6d --- /dev/null +++ b/src/ViewModels/Reword.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Reword : Popup { + public Models.Commit Head { + get; + private set; + } + + [Required(ErrorMessage = "Commit message is required!!!")] + public string Message { + get => _message; + set => SetProperty(ref _message, value, true); + } + + public Reword(Repository repo, Models.Commit head) { + _repo = repo; + Head = head; + Message = head.FullMessage; + View = new Views.Reword() { DataContext = this }; + } + + public override Task Sure() { + if (_message == Head.FullMessage) return null; + + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + SetProgressDescription($"Editing head commit message ..."); + var succ = new Commands.Commit(_repo.FullPath, _message, true, true).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private string _message = string.Empty; + } +} diff --git a/src/ViewModels/Squash.cs b/src/ViewModels/Squash.cs new file mode 100644 index 00000000..5964b51b --- /dev/null +++ b/src/ViewModels/Squash.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Squash : Popup { + public Models.Commit Head { + get; + private set; + } + + public Models.Commit Parent { + get; + private set; + } + + [Required(ErrorMessage = "Commit message is required!!!")] + public string Message { + get => _message; + set => SetProperty(ref _message, value, true); + } + + public Squash(Repository repo, Models.Commit head, Models.Commit parent) { + _repo = repo; + _message = parent.FullMessage; + Head = head; + Parent = parent; + View = new Views.Squash() { DataContext = this }; + } + + public override Task Sure() { + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + var succ = new Commands.Reset(_repo.FullPath, Parent.SHA, "--soft").Exec(); + if (succ) succ = new Commands.Commit(_repo.FullPath, _message, true).Exec(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private string _message = string.Empty; + } +} diff --git a/src/ViewModels/StashChanges.cs b/src/ViewModels/StashChanges.cs new file mode 100644 index 00000000..7bdecd7c --- /dev/null +++ b/src/ViewModels/StashChanges.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class StashChanges : Popup { + + public string Message { + get; + set; + } + + public bool CanIgnoreUntracked { + get; + private set; + } + + public bool IncludeUntracked { + get; + set; + } + + public StashChanges(Repository repo, List changes, bool canIgnoreUntracked) { + _repo = repo; + _changes = changes; + + CanIgnoreUntracked = canIgnoreUntracked; + IncludeUntracked = true; + View = new Views.StashChanges() { DataContext = this }; + } + + public override Task Sure() { + var jobs = _changes; + if (CanIgnoreUntracked && !IncludeUntracked) { + jobs = new List(); + foreach (var job in _changes) { + if (job.WorkTree != Models.ChangeState.Untracked && job.WorkTree != Models.ChangeState.Added) { + jobs.Add(job); + } + } + } + + if (jobs.Count == 0) return null; + + _repo.SetWatcherEnabled(false); + return Task.Run(() => { + new Commands.Stash(_repo.FullPath).Push(jobs, Message); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + private List _changes = null; + } +} diff --git a/src/ViewModels/StashesPage.cs b/src/ViewModels/StashesPage.cs new file mode 100644 index 00000000..12f8383b --- /dev/null +++ b/src/ViewModels/StashesPage.cs @@ -0,0 +1,105 @@ +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class StashesPage : ObservableObject { + public int Count { + get => _stashes == null ? 0 : _stashes.Count; + } + + public List Stashes { + get => _stashes; + set { + if (SetProperty(ref _stashes, value)) { + SelectedStash = null; + } + } + } + + public Models.Stash SelectedStash { + get => _selectedStash; + set { + if (SetProperty(ref _selectedStash, value)) { + if (value == null) { + Changes = null; + } else { + Task.Run(() => { + var changes = new Commands.QueryStashChanges(_repo.FullPath, value.SHA).Result(); + Dispatcher.UIThread.Invoke(() => { + Changes = changes; + }); + }); + } + } + } + } + + public List Changes { + get => _changes; + private set { + if (SetProperty(ref _changes, value)) { + SelectedChange = null; + } + } + } + + public Models.Change SelectedChange { + get => _selectedChange; + set { + if (SetProperty(ref _selectedChange, value)) { + if (value == null) { + DiffContext = null; + } else { + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption($"{_selectedStash.SHA}^", _selectedStash.SHA, value)); + } + } + } + } + + public DiffContext DiffContext { + get => _diffContext; + private set => SetProperty(ref _diffContext, value); + } + + public StashesPage(Repository repo) { + _repo = repo; + } + + public void Apply(object param) { + if (param is Models.Stash stash) { + Task.Run(() => { + new Commands.Stash(_repo.FullPath).Apply(stash.Name); + }); + } + } + + public void Pop(object param) { + if (param is Models.Stash stash) { + Task.Run(() => { + new Commands.Stash(_repo.FullPath).Pop(stash.Name); + }); + } + } + + public void Drop(object param) { + if (param is Models.Stash stash && PopupHost.CanCreatePopup()) { + PopupHost.ShowPopup(new DropStash(_repo.FullPath, stash)); + } + } + + public void Clear() { + if (PopupHost.CanCreatePopup()) { + PopupHost.ShowPopup(new ClearStashes(_repo)); + } + } + + private Repository _repo = null; + private List _stashes = null; + private Models.Stash _selectedStash = null; + private List _changes = null; + private Models.Change _selectedChange = null; + private DiffContext _diffContext = null; + } +} diff --git a/src/ViewModels/TwoSideTextDiff.cs b/src/ViewModels/TwoSideTextDiff.cs new file mode 100644 index 00000000..07f4cf95 --- /dev/null +++ b/src/ViewModels/TwoSideTextDiff.cs @@ -0,0 +1,52 @@ +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; +using System.Collections.Generic; + +namespace SourceGit.ViewModels { + public class TwoSideTextDiff : ObservableObject { + public Vector SyncScrollOffset { + get => _syncScrollOffset; + set => SetProperty(ref _syncScrollOffset, value); + } + + public string File { get; set; } = string.Empty; + public List Old { get; set; } = new List(); + public List New { get; set; } = new List(); + public int MaxLineNumber = 0; + + public TwoSideTextDiff(Models.TextDiff diff) { + File = diff.File; + MaxLineNumber = diff.MaxLineNumber; + + foreach (var line in diff.Lines) { + switch (line.Type) { + case Models.TextDiffLineType.Added: + New.Add(line); + break; + case Models.TextDiffLineType.Deleted: + Old.Add(line); + break; + default: + FillEmptyLines(); + Old.Add(line); + New.Add(line); + break; + } + } + + FillEmptyLines(); + } + + private void FillEmptyLines() { + if (Old.Count < New.Count) { + int diff = New.Count - Old.Count; + for (int i = 0; i < diff; i++) Old.Add(new Models.TextDiffLine()); + } else if (Old.Count > New.Count) { + int diff = Old.Count - New.Count; + for (int i = 0; i < diff; i++) New.Add(new Models.TextDiffLine()); + } + } + + private Vector _syncScrollOffset; + } +} diff --git a/src/ViewModels/Welcome.cs b/src/ViewModels/Welcome.cs new file mode 100644 index 00000000..b75ed664 --- /dev/null +++ b/src/ViewModels/Welcome.cs @@ -0,0 +1,102 @@ +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using System; + +namespace SourceGit.ViewModels { + public class Welcome : ObservableObject { + public bool IsClearSearchVisible { + get => !string.IsNullOrEmpty(_searchFilter); + } + + public AvaloniaList RepositoryNodes { + get => Preference.Instance.RepositoryNodes; + } + + public string SearchFilter { + get => _searchFilter; + set { + if (SetProperty(ref _searchFilter, value)) { + Referesh(); + OnPropertyChanged(nameof(IsClearSearchVisible)); + } + } + } + + public void InitRepository(string path) { + if (!Preference.Instance.IsGitConfigured) { + App.RaiseException(PopupHost.Active.GetId(), App.Text("NotConfigured")); + return; + } + + if (PopupHost.CanCreatePopup()) { + PopupHost.ShowPopup(new Init(path)); + } + } + + public void Clone(object param) { + var page = param as LauncherPage; + + if (!Preference.Instance.IsGitConfigured) { + App.RaiseException(page.GetId(), App.Text("NotConfigured")); + return; + } + + if (PopupHost.CanCreatePopup()) { + PopupHost.ShowPopup(new Clone(page)); + } + } + + public void OpenTerminal() { + if (!Preference.Instance.IsGitConfigured) { + App.RaiseException(PopupHost.Active.GetId(), App.Text("NotConfigured")); + } else { + Native.OS.OpenTerminal(null); + } + } + + public void ClearSearchFilter() { + SearchFilter = string.Empty; + } + + public void AddFolder() { + if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateGroup(null)); + } + + public void MoveNode(RepositoryNode from, RepositoryNode to) { + Preference.MoveNode(from, to); + } + + private void Referesh() { + if (string.IsNullOrWhiteSpace(_searchFilter)) { + foreach (var node in RepositoryNodes) ResetVisibility(node); + } else { + foreach (var node in RepositoryNodes) SetVisibilityBySearch(node); + } + } + + private void ResetVisibility(RepositoryNode node) { + node.IsVisible = true; + foreach (var subNode in node.SubNodes) ResetVisibility(subNode); + } + + private void SetVisibilityBySearch(RepositoryNode node) { + if (!node.IsRepository) { + if (node.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) { + node.IsVisible = true; + foreach (var subNode in node.SubNodes) ResetVisibility(subNode); + } else { + bool hasVisibleSubNode = false; + foreach (var subNode in node.SubNodes) { + SetVisibilityBySearch(subNode); + hasVisibleSubNode |= subNode.IsVisible; + } + node.IsVisible = hasVisibleSubNode; + } + } else { + node.IsVisible = node.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase); + } + } + + private string _searchFilter = string.Empty; + } +} diff --git a/src/ViewModels/WorkingCopy.cs b/src/ViewModels/WorkingCopy.cs new file mode 100644 index 00000000..a6ef0bb4 --- /dev/null +++ b/src/ViewModels/WorkingCopy.cs @@ -0,0 +1,600 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class ConflictContext { + public Models.Change Change { get; set; } + } + + public class ViewChangeDetailContext { + public string FilePath { get; set; } = string.Empty; + public bool IsUnstaged { get; set; } = false; + } + + public class WorkingCopy : ObservableObject { + public bool IsStaging { + get => _isStaging; + private set => SetProperty(ref _isStaging, value); + } + + public bool IsUnstaging { + get => _isUnstaging; + private set => SetProperty(ref _isUnstaging, value); + } + + public bool IsCommitting { + get => _isCommitting; + private set => SetProperty(ref _isCommitting, value); + } + + public bool UseAmend { + get => _useAmend; + set => SetProperty(ref _useAmend, value); + } + + public List Unstaged { + get => _unstaged; + private set => SetProperty(ref _unstaged, value); + } + + public List Staged { + get => _staged; + private set => SetProperty(ref _staged, value); + } + + public int Count { + get => _count; + } + + public List UnstagedTree { + get => _unstagedTree; + private set => SetProperty(ref _unstagedTree, value); + } + + public List StagedTree { + get => _stagedTree; + private set => SetProperty(ref _stagedTree, value); + } + + public object DetailContext { + get => _detailContext; + private set => SetProperty(ref _detailContext, value); + } + + public string CommitMessage { + get => _commitMessage; + set => SetProperty(ref _commitMessage, value); + } + + public WorkingCopy(Repository repo) { + _repo = repo; + } + + public bool SetData(List changes) { + var unstaged = new List(); + var staged = new List(); + + var viewFile = _lastViewChange == null ? string.Empty : _lastViewChange.FilePath; + var viewChange = null as Models.Change; + var hasConflict = false; + foreach (var c in changes) { + if (c.Path == viewFile) { + viewChange = c; + } + + if (c.Index == Models.ChangeState.Modified + || c.Index == Models.ChangeState.Added + || c.Index == Models.ChangeState.Deleted + || c.Index == Models.ChangeState.Renamed) { + staged.Add(c); + } + + if (c.WorkTree != Models.ChangeState.None) { + unstaged.Add(c); + hasConflict |= c.IsConflit; + } + } + + _count = changes.Count; + + var unstagedTree = FileTreeNode.Build(unstaged); + var stagedTree = FileTreeNode.Build(staged); + Dispatcher.UIThread.Invoke(() => { + _isLoadingData = true; + Unstaged = unstaged; + Staged = staged; + UnstagedTree = unstagedTree; + StagedTree = stagedTree; + _isLoadingData = false; + SetDetail(viewChange, _lastViewChange == null || _lastViewChange.IsUnstaged); + }); + + return hasConflict; + } + + public void SetDetail(Models.Change change, bool isUnstaged) { + if (_isLoadingData) return; + + if (change == null) { + _lastViewChange = null; + DetailContext = null; + } else if (change.IsConflit) { + _lastViewChange = new ViewChangeDetailContext() { FilePath = change.Path, IsUnstaged = isUnstaged }; + DetailContext = new ConflictContext() { Change = change }; + } else { + _lastViewChange = new ViewChangeDetailContext() { FilePath = change.Path, IsUnstaged = isUnstaged }; + DetailContext = new DiffContext(_repo.FullPath, new Models.DiffOption(change, isUnstaged)); + } + } + + public async void StageChanges(List changes) { + if (_unstaged.Count == 0 || changes.Count == 0) return; + + IsStaging = true; + _repo.SetWatcherEnabled(false); + if (changes.Count == _unstaged.Count) { + await Task.Run(() => new Commands.Add(_repo.FullPath).Exec()); + } else { + for (int i = 0; i < changes.Count; i += 10) { + var count = Math.Min(10, changes.Count - i); + var step = changes.GetRange(i, count); + await Task.Run(() => new Commands.Add(_repo.FullPath, step).Exec()); + } + } + _repo.RefreshWorkingCopyChanges(); + _repo.SetWatcherEnabled(true); + IsStaging = false; + } + + public async void UnstageChanges(List changes) { + if (_staged.Count == 0 || changes.Count == 0) return; + + IsUnstaging = true; + _repo.SetWatcherEnabled(false); + if (changes.Count == _staged.Count) { + await Task.Run(() => new Commands.Reset(_repo.FullPath).Exec()); + } else { + for (int i = 0; i < changes.Count; i += 10) { + var count = Math.Min(10, changes.Count - i); + var step = changes.GetRange(i, count); + await Task.Run(() => new Commands.Reset(_repo.FullPath, step).Exec()); + } + } + _repo.RefreshWorkingCopyChanges(); + _repo.SetWatcherEnabled(true); + IsUnstaging = false; + } + + public void Discard(List changes) { + if (PopupHost.CanCreatePopup()) { + if (changes.Count == _count) { + PopupHost.ShowPopup(new Discard(_repo)); + } else { + PopupHost.ShowPopup(new Discard(_repo, changes)); + } + } + } + + public async void UseTheirs() { + if (_detailContext is ConflictContext ctx) { + _repo.SetWatcherEnabled(false); + var succ = await Task.Run(() => new Commands.Checkout(_repo.FullPath).File(ctx.Change.Path, true)); + if (succ) { + await Task.Run(() => new Commands.Add(_repo.FullPath, [ctx.Change]).Exec()); + } + _repo.RefreshWorkingCopyChanges(); + _repo.SetWatcherEnabled(true); + } + } + + public async void UseMine() { + if (_detailContext is ConflictContext ctx) { + _repo.SetWatcherEnabled(false); + var succ = await Task.Run(() => new Commands.Checkout(_repo.FullPath).File(ctx.Change.Path, false)); + if (succ) { + await Task.Run(() => new Commands.Add(_repo.FullPath, [ctx.Change]).Exec()); + } + _repo.RefreshWorkingCopyChanges(); + _repo.SetWatcherEnabled(true); + } + } + + public async void UseExternalMergeTool() { + if (_detailContext is ConflictContext ctx) { + var type = Preference.Instance.ExternalMergeToolType; + var exec = Preference.Instance.ExternalMergeToolPath; + + var tool = Models.ExternalMergeTools.Supported.Find(x => x.Type == type); + if (tool == null) { + App.RaiseException(_repo.FullPath, "Invalid merge tool in preference setting!"); + return; + } + + var args = tool.Type != 0 ? tool.Cmd : Preference.Instance.ExternalMergeToolCmd; + + _repo.SetWatcherEnabled(false); + await Task.Run(() => Commands.MergeTool.OpenForMerge(_repo.FullPath, exec, args, ctx.Change.Path)); + _repo.SetWatcherEnabled(true); + } + } + + public async void DoCommit(bool autoPush) { + if (!PopupHost.CanCreatePopup()) { + App.RaiseException(_repo.FullPath, "Repository has unfinished job! Please wait!"); + return; + } + + if (_staged.Count == 0) { + App.RaiseException(_repo.FullPath, "No files added to commit!"); + return; + } + + if (string.IsNullOrWhiteSpace(_commitMessage)) { + App.RaiseException(_repo.FullPath, "Commit without message is NOT allowed!"); + return; + } + + PushCommitMessage(); + + IsCommitting = true; + _repo.SetWatcherEnabled(false); + var succ = await Task.Run(() => new Commands.Commit(_repo.FullPath, _commitMessage, _useAmend).Exec()); + if (succ) { + CommitMessage = string.Empty; + UseAmend = false; + + if (autoPush) { + PopupHost.ShowAndStartPopup(new Push(_repo, null)); + } + } + _repo.RefreshWorkingCopyChanges(); + _repo.SetWatcherEnabled(true); + IsCommitting = false; + } + + public ContextMenu CreateContextMenuForUnstagedChanges(List changes) { + if (changes.Count == 0) return null; + + var menu = new ContextMenu(); + if (changes.Count == 1) { + var change = changes[0]; + var path = Path.GetFullPath(Path.Combine(_repo.FullPath, change.Path)); + + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = CreateMenuIcon("Icons.Folder.Open"); + explore.IsEnabled = File.Exists(path) || Directory.Exists(path); + explore.Click += (_, e) => { + Native.OS.OpenInFileManager(path, true); + e.Handled = true; + }; + + var openWith = new MenuItem(); + openWith.Header = App.Text("OpenWith"); + openWith.Icon = CreateMenuIcon("Icons.OpenWith"); + openWith.IsEnabled = File.Exists(path); + openWith.Click += (_, e) => { + Native.OS.OpenWithDefaultEditor(path); + e.Handled = true; + }; + + var stage = new MenuItem(); + stage.Header = App.Text("FileCM.Stage"); + stage.Icon = CreateMenuIcon("Icons.File.Add"); + stage.Click += (_, e) => { + StageChanges(changes); + e.Handled = true; + }; + + var discard = new MenuItem(); + discard.Header = App.Text("FileCM.Discard"); + discard.Icon = CreateMenuIcon("Icons.Undo"); + discard.Click += (_, e) => { + Discard(changes); + e.Handled = true; + }; + + var stash = new MenuItem(); + stash.Header = App.Text("FileCM.Stash"); + stash.Icon = CreateMenuIcon("Icons.Stashes"); + stash.Click += (_, e) => { + if (PopupHost.CanCreatePopup()) { + PopupHost.ShowPopup(new StashChanges(_repo, changes, false)); + } + e.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = CreateMenuIcon("Icons.Diff"); + patch.Click += async (_, e) => { + var topLevel = App.GetTopLevel(); + if (topLevel == null) return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await topLevel.StorageProvider.SaveFilePickerAsync(options); + if (storageFile != null) { + var succ = await Task.Run(() => Commands.SaveChangesAsPatch.Exec(_repo.FullPath, changes, true, storageFile.Path.LocalPath)); + if (succ) App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + e.Handled = true; + }; + + var history = new MenuItem(); + history.Header = App.Text("FileHistory"); + history.Icon = CreateMenuIcon("Icons.Histories"); + history.Click += (_, e) => { + var window = new Views.FileHistories() { DataContext = new FileHistories(_repo.FullPath, change.Path) }; + window.Show(); + e.Handled = true; + }; + + var assumeUnchanged = new MenuItem(); + assumeUnchanged.Header = App.Text("FileCM.AssumeUnchanged"); + assumeUnchanged.Icon = CreateMenuIcon("Icons.File.Ignore"); + assumeUnchanged.IsEnabled = change.WorkTree != Models.ChangeState.Untracked; + assumeUnchanged.Click += (_, e) => { + new Commands.AssumeUnchanged(_repo.FullPath).Add(change.Path); + e.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("CopyPath"); + copy.Icon = CreateMenuIcon("Icons.Copy"); + copy.Click += (_, e) => { + App.CopyText(change.Path); + e.Handled = true; + }; + + menu.Items.Add(explore); + menu.Items.Add(openWith); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(stage); + menu.Items.Add(discard); + menu.Items.Add(stash); + menu.Items.Add(patch); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(history); + menu.Items.Add(assumeUnchanged); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copy); + } else { + var stage = new MenuItem(); + stage.Header = App.Text("FileCM.StageMulti", changes.Count); + stage.Icon = CreateMenuIcon("Icons.File.Add"); + stage.Click += (_, e) => { + StageChanges(changes); + e.Handled = true; + }; + + var discard = new MenuItem(); + discard.Header = App.Text("FileCM.DiscardMulti", changes.Count); + discard.Icon = CreateMenuIcon("Icons.Undo"); + discard.Click += (_, e) => { + Discard(changes); + e.Handled = true; + }; + + var stash = new MenuItem(); + stash.Header = App.Text("FileCM.StashMulti", changes.Count); + stash.Icon = CreateMenuIcon("Icons.Stashes"); + stash.Click += (_, e) => { + if (PopupHost.CanCreatePopup()) { + PopupHost.ShowPopup(new StashChanges(_repo, changes, false)); + } + e.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = CreateMenuIcon("Icons.Diff"); + patch.Click += async (o, e) => { + var topLevel = App.GetTopLevel(); + if (topLevel == null) return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await topLevel.StorageProvider.SaveFilePickerAsync(options); + if (storageFile != null) { + var succ = await Task.Run(() => Commands.SaveChangesAsPatch.Exec(_repo.FullPath, changes, true, storageFile.Path.LocalPath)); + if (succ) App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + e.Handled = true; + }; + + menu.Items.Add(stage); + menu.Items.Add(discard); + menu.Items.Add(stash); + menu.Items.Add(patch); + } + + return menu; + } + + public ContextMenu CreateContextMenuForStagedChanges(List changes) { + if (changes.Count == 0) return null; + + var menu = new ContextMenu(); + if (changes.Count == 1) { + var change = changes[0]; + var path = Path.GetFullPath(Path.Combine(_repo.FullPath, change.Path)); + + var explore = new MenuItem(); + explore.IsEnabled = File.Exists(path) || Directory.Exists(path); + explore.Header = App.Text("RevealFile"); + explore.Icon = CreateMenuIcon("Icons.Folder.Open"); + explore.Click += (o, e) => { + Native.OS.OpenInFileManager(path, true); + e.Handled = true; + }; + + var openWith = new MenuItem(); + openWith.Header = App.Text("OpenWith"); + openWith.Icon = CreateMenuIcon("Icons.OpenWith"); + openWith.IsEnabled = File.Exists(path); + openWith.Click += (_, e) => { + Native.OS.OpenWithDefaultEditor(path); + e.Handled = true; + }; + + var unstage = new MenuItem(); + unstage.Header = App.Text("FileCM.Unstage"); + unstage.Icon = CreateMenuIcon("Icons.File.Remove"); + unstage.Click += (o, e) => { + UnstageChanges(changes); + e.Handled = true; + }; + + var stash = new MenuItem(); + stash.Header = App.Text("FileCM.Stash"); + stash.Icon = CreateMenuIcon("Icons.Stashes"); + stash.Click += (_, e) => { + if (PopupHost.CanCreatePopup()) { + PopupHost.ShowPopup(new StashChanges(_repo, changes, false)); + } + e.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = CreateMenuIcon("Icons.Diff"); + patch.Click += async (o, e) => { + var topLevel = App.GetTopLevel(); + if (topLevel == null) return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await topLevel.StorageProvider.SaveFilePickerAsync(options); + if (storageFile != null) { + var succ = await Task.Run(() => Commands.SaveChangesAsPatch.Exec(_repo.FullPath, changes, false, storageFile.Path.LocalPath)); + if (succ) App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + e.Handled = true; + }; + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = CreateMenuIcon("Icons.Copy"); + copyPath.Click += (o, e) => { + App.CopyText(change.Path); + e.Handled = true; + }; + + menu.Items.Add(explore); + menu.Items.Add(openWith); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(unstage); + menu.Items.Add(stash); + menu.Items.Add(patch); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copyPath); + } else { + var unstage = new MenuItem(); + unstage.Header = App.Text("FileCM.UnstageMulti", changes.Count); + unstage.Icon = CreateMenuIcon("Icons.File.Remove"); + unstage.Click += (o, e) => { + UnstageChanges(changes); + e.Handled = true; + }; + + var stash = new MenuItem(); + stash.Header = App.Text("FileCM.StashMulti", changes.Count); + stash.Icon = CreateMenuIcon("Icons.Stashes"); + stash.Click += (_, e) => { + if (PopupHost.CanCreatePopup()) { + PopupHost.ShowPopup(new StashChanges(_repo, changes, false)); + } + e.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = CreateMenuIcon("Icons.Diff"); + patch.Click += async (_, e) => { + var topLevel = App.GetTopLevel(); + if (topLevel == null) return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await topLevel.StorageProvider.SaveFilePickerAsync(options); + if (storageFile != null) { + var succ = await Task.Run(() => Commands.SaveChangesAsPatch.Exec(_repo.FullPath, changes, false, storageFile.Path.LocalPath)); + if (succ) App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + e.Handled = true; + }; + + menu.Items.Add(unstage); + menu.Items.Add(stash); + menu.Items.Add(patch); + } + + return menu; + } + + private Avalonia.Controls.Shapes.Path CreateMenuIcon(string key) { + var icon = new Avalonia.Controls.Shapes.Path(); + icon.Width = 12; + icon.Height = 12; + icon.Stretch = Stretch.Uniform; + icon.Data = App.Current?.FindResource(key) as StreamGeometry; + return icon; + } + + private void PushCommitMessage() { + var existIdx = _repo.CommitMessages.IndexOf(CommitMessage); + if (existIdx == 0) { + return; + } else if (existIdx > 0) { + _repo.CommitMessages.Move(existIdx, 0); + return; + } + + if (_repo.CommitMessages.Count > 9) { + _repo.CommitMessages.RemoveRange(9, _repo.CommitMessages.Count - 9); + } + + _repo.CommitMessages.Insert(0, CommitMessage); + } + + private Repository _repo = null; + private bool _isLoadingData = false; + private bool _isStaging = false; + private bool _isUnstaging = false; + private bool _isCommitting = false; + private bool _useAmend = false; + private List _unstaged = null; + private List _staged = null; + private int _count = 0; + private List _unstagedTree = null; + private List _stagedTree = null; + private ViewChangeDetailContext _lastViewChange = null; + private object _detailContext = null; + private string _commitMessage = string.Empty; + } +} diff --git a/src/Views/About.axaml b/src/Views/About.axaml new file mode 100644 index 00000000..9c9f0a9f --- /dev/null +++ b/src/Views/About.axaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/About.axaml.cs b/src/Views/About.axaml.cs new file mode 100644 index 00000000..90d047ef --- /dev/null +++ b/src/Views/About.axaml.cs @@ -0,0 +1,39 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using System.Reflection; + +namespace SourceGit.Views { + public partial class About : Window { + public string Version { + get; + private set; + } + + public About() { + var ver = Assembly.GetExecutingAssembly().GetName().Version; + Version = $"{ver.Major}.{ver.Minor}"; + DataContext = this; + InitializeComponent(); + } + + private void CloseWindow(object sender, RoutedEventArgs e) { + Close(); + } + + private void OnVisitAvaloniaUI(object sender, PointerPressedEventArgs e) { + Native.OS.OpenBrowser("https://www.avaloniaui.net/"); + e.Handled = true; + } + + private void OnVisitAvaloniaEdit(object sender, PointerPressedEventArgs e) { + Native.OS.OpenBrowser("https://www.nuget.org/packages/OneWare.AvaloniaEdit"); + e.Handled = true; + } + + private void OnVisitJetBrainsMonoFont(object sender, PointerPressedEventArgs e) { + Native.OS.OpenBrowser("https://www.jetbrains.com/lp/mono/"); + e.Handled = true; + } + } +} diff --git a/src/Views/About.xaml b/src/Views/About.xaml deleted file mode 100644 index 1b754a30..00000000 --- a/src/Views/About.xaml +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/About.xaml.cs b/src/Views/About.xaml.cs deleted file mode 100644 index cce5e618..00000000 --- a/src/Views/About.xaml.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Reflection; -using System.Windows; - -namespace SourceGit.Views { - - /// - /// 关于对话框 - /// - public partial class About : Controls.Window { - - public class Keymap { - public string Key { get; set; } - public string Desc { get; set; } - public Keymap(string k, string d) { Key = k; Desc = App.Text($"Hotkeys.{d}"); } - } - - public About() { - InitializeComponent(); - - var asm = Assembly.GetExecutingAssembly().GetName(); - version.Text = $"VERSION : v{asm.Version.Major}.{asm.Version.Minor}"; - - hotkeys.ItemsSource = new List() { - new Keymap("CTRL + T", "NewTab"), - new Keymap("CTRL + W", "CloseTab"), - new Keymap("CTRL + TAB", "NextTab"), - new Keymap("CTRL + [1-9]", "SwitchTo"), - new Keymap("CTRL + F", "Search"), - new Keymap("F5", "Refresh"), - new Keymap("SPACE", "ToggleStage"), - new Keymap("ESC", "CancelPopup"), - }; - } - - private void OnRequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e) { - var info = new ProcessStartInfo("cmd", $"/c start {e.Uri.AbsoluteUri}"); - info.CreateNoWindow = true; - Process.Start(info); - } - - private void Quit(object sender, RoutedEventArgs e) { - Close(); - } - } -} diff --git a/src/Views/AddRemote.axaml b/src/Views/AddRemote.axaml new file mode 100644 index 00000000..4b460d79 --- /dev/null +++ b/src/Views/AddRemote.axaml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/AddRemote.axaml.cs b/src/Views/AddRemote.axaml.cs new file mode 100644 index 00000000..f3640e30 --- /dev/null +++ b/src/Views/AddRemote.axaml.cs @@ -0,0 +1,22 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views { + public partial class AddRemote : UserControl { + public AddRemote() { + InitializeComponent(); + } + + private async void SelectSSHKey(object sender, RoutedEventArgs e) { + var options = new FilePickerOpenOptions() { AllowMultiple = false, FileTypeFilter = [new FilePickerFileType("SSHKey") { Patterns = ["*.*"] }] }; + var toplevel = TopLevel.GetTopLevel(this); + var selected = await toplevel.StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) { + txtSSHKey.Text = selected[0].Path.LocalPath; + } + + e.Handled = true; + } + } +} diff --git a/src/Views/AddSubmodule.axaml b/src/Views/AddSubmodule.axaml new file mode 100644 index 00000000..f7c70bfd --- /dev/null +++ b/src/Views/AddSubmodule.axaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + diff --git a/src/Views/AddSubmodule.axaml.cs b/src/Views/AddSubmodule.axaml.cs new file mode 100644 index 00000000..358faaa7 --- /dev/null +++ b/src/Views/AddSubmodule.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; + +namespace SourceGit.Views { + public partial class AddSubmodule : UserControl { + public AddSubmodule() { + InitializeComponent(); + } + } +} diff --git a/src/Views/Apply.axaml b/src/Views/Apply.axaml new file mode 100644 index 00000000..979741c8 --- /dev/null +++ b/src/Views/Apply.axaml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Apply.axaml.cs b/src/Views/Apply.axaml.cs new file mode 100644 index 00000000..3f61a4dd --- /dev/null +++ b/src/Views/Apply.axaml.cs @@ -0,0 +1,24 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views { + public partial class Apply : UserControl { + public Apply() { + InitializeComponent(); + } + + private async void SelectPatchFile(object sender, RoutedEventArgs e) { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel == null) return; + + var options = new FilePickerOpenOptions() { AllowMultiple = false, FileTypeFilter = [ new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }] }; + var selected = await topLevel.StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) { + txtPatchFile.Text = selected[0].Path.LocalPath; + } + + e.Handled = true; + } + } +} diff --git a/src/Views/Archive.axaml b/src/Views/Archive.axaml new file mode 100644 index 00000000..be2d0ddc --- /dev/null +++ b/src/Views/Archive.axaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Archive.axaml.cs b/src/Views/Archive.axaml.cs new file mode 100644 index 00000000..63e73448 --- /dev/null +++ b/src/Views/Archive.axaml.cs @@ -0,0 +1,22 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views { + public partial class Archive : UserControl { + public Archive() { + InitializeComponent(); + } + + private async void SelectOutputFile(object sender, RoutedEventArgs e) { + var options = new FilePickerSaveOptions() { DefaultExtension = ".zip", FileTypeChoices = [ new FilePickerFileType("ZIP") { Patterns = [ "*.zip" ]}] }; + var toplevel = TopLevel.GetTopLevel(this); + var selected = await toplevel.StorageProvider.SaveFilePickerAsync(options); + if (selected != null) { + txtSaveFile.Text = selected.Path.LocalPath; + } + + e.Handled = true; + } + } +} diff --git a/src/Views/AssumeUnchanged.xaml b/src/Views/AssumeUnchanged.xaml deleted file mode 100644 index 01d5413f..00000000 --- a/src/Views/AssumeUnchanged.xaml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/AssumeUnchanged.xaml.cs b/src/Views/AssumeUnchanged.xaml.cs deleted file mode 100644 index 1336f0bb..00000000 --- a/src/Views/AssumeUnchanged.xaml.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Collections.ObjectModel; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; - -namespace SourceGit.Views { - /// - /// 管理不跟踪变更的文件 - /// - public partial class AssumeUnchanged : Controls.Window { - private string repo = null; - - public ObservableCollection Files { get; set; } - - public AssumeUnchanged(string repo) { - this.repo = repo; - this.Files = new ObservableCollection(); - - InitializeComponent(); - - Task.Run(() => { - var unchanged = new Commands.AssumeUnchanged(repo).View(); - Dispatcher.Invoke(() => { - if (unchanged.Count > 0) { - foreach (var file in unchanged) Files.Add(file); - - mask.Visibility = Visibility.Collapsed; - list.Visibility = Visibility.Visible; - list.ItemsSource = Files; - } else { - list.Visibility = Visibility.Collapsed; - mask.Visibility = Visibility.Visible; - } - }); - }); - } - - private void OnQuit(object sender, RoutedEventArgs e) { - Close(); - } - - private void OnRequestBringIntoView(object sender, RequestBringIntoViewEventArgs e) { - e.Handled = true; - } - - private void Remove(object sender, RoutedEventArgs e) { - var btn = sender as Button; - if (btn == null) return; - - var file = btn.DataContext as string; - if (file == null) return; - - new Commands.AssumeUnchanged(repo).Remove(file); - Files.Remove(file); - - if (Files.Count == 0) { - list.Visibility = Visibility.Collapsed; - mask.Visibility = Visibility.Visible; - } - - e.Handled = true; - } - } -} diff --git a/src/Views/AssumeUnchangedManager.axaml b/src/Views/AssumeUnchangedManager.axaml new file mode 100644 index 00000000..b7c59691 --- /dev/null +++ b/src/Views/AssumeUnchangedManager.axaml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/AssumeUnchangedManager.axaml.cs b/src/Views/AssumeUnchangedManager.axaml.cs new file mode 100644 index 00000000..616db87d --- /dev/null +++ b/src/Views/AssumeUnchangedManager.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace SourceGit.Views { + public partial class AssumeUnchangedManager : Window { + public AssumeUnchangedManager() { + InitializeComponent(); + } + + private void CloseWindow(object sender, RoutedEventArgs e) { + Close(); + } + } +} diff --git a/src/Views/Avatar.cs b/src/Views/Avatar.cs new file mode 100644 index 00000000..3feb04bb --- /dev/null +++ b/src/Views/Avatar.cs @@ -0,0 +1,122 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using System; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace SourceGit.Views { + public class Avatar : Control, Models.IAvatarHost { + private static readonly GradientStops[] FALLBACK_GRADIENTS = [ + new GradientStops() { new GradientStop(Colors.Orange, 0), new GradientStop(Color.FromRgb(255, 213, 134), 1) }, + new GradientStops() { new GradientStop(Colors.DodgerBlue, 0), new GradientStop(Colors.LightSkyBlue, 1) }, + new GradientStops() { new GradientStop(Colors.LimeGreen, 0), new GradientStop(Color.FromRgb(124, 241, 124), 1) }, + new GradientStops() { new GradientStop(Colors.Orchid, 0), new GradientStop(Color.FromRgb(248, 161, 245), 1) }, + new GradientStops() { new GradientStop(Colors.Tomato, 0), new GradientStop(Color.FromRgb(252, 165, 150), 1) }, + ]; + + public static readonly StyledProperty FallbackFontFamilyProperty = + AvaloniaProperty.Register(nameof(FallbackFontFamily)); + + public FontFamily FallbackFontFamily { + get => GetValue(FallbackFontFamilyProperty); + set => SetValue(FallbackFontFamilyProperty, value); + } + + public static readonly StyledProperty UserProperty = + AvaloniaProperty.Register(nameof(User)); + + public Models.User User { + get => GetValue(UserProperty); + set => SetValue(UserProperty, value); + } + + static Avatar() { + AffectsRender(FallbackFontFamilyProperty); + UserProperty.Changed.AddClassHandler(OnUserPropertyChanged); + } + + public Avatar() { + var refetch = new MenuItem() { Header = App.Text("RefetchAvatar") }; + refetch.Click += (o, e) => { + if (User != null) { + _image = Models.AvatarManager.Request(_emailMD5, true); + InvalidateVisual(); + } + }; + + ContextMenu = new ContextMenu(); + ContextMenu.Items.Add(refetch); + + Models.AvatarManager.Subscribe(this); + } + + public override void Render(DrawingContext context) { + if (User == null) return; + + float corner = (float)Math.Max(2, Bounds.Width / 16); + if (_image != null) { + var rect = new Rect(0, 0, Bounds.Width, Bounds.Height); + context.PushClip(new RoundedRect(rect, corner)); + context.DrawImage(_image, rect); + } else { + Point textOrigin = new Point((Bounds.Width - _fallbackLabel.Width) * 0.5, (Bounds.Height - _fallbackLabel.Height) * 0.5); + context.DrawRectangle(_fallbackBrush, null, new Rect(0, 0, Bounds.Width, Bounds.Height), corner, corner); + context.DrawText(_fallbackLabel, textOrigin); + } + } + + public void OnAvatarResourceReady(string md5, Bitmap bitmap) { + if (_emailMD5 == md5) { + _image = bitmap; + InvalidateVisual(); + } + } + + private static void OnUserPropertyChanged(Avatar avatar, AvaloniaPropertyChangedEventArgs e) { + if (avatar.User == null) { + avatar._emailMD5 = null; + return; + } + + var placeholder = string.IsNullOrWhiteSpace(avatar.User.Name) ? "?" : avatar.User.Name.Substring(0, 1); + var chars = placeholder.ToCharArray(); + var sum = 0; + foreach (var c in chars) sum += Math.Abs(c); + + var hash = MD5.Create().ComputeHash(Encoding.Default.GetBytes(avatar.User.Email.ToLower().Trim())); + var builder = new StringBuilder(); + foreach (var c in hash) builder.Append(c.ToString("x2")); + var md5 = builder.ToString(); + if (avatar._emailMD5 != md5) { + avatar._emailMD5 = md5; + avatar._image = Models.AvatarManager.Request(md5, false); + } + + avatar._fallbackBrush = new LinearGradientBrush { + GradientStops = FALLBACK_GRADIENTS[sum % FALLBACK_GRADIENTS.Length], + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }; + + var typeface = avatar.FallbackFontFamily == null ? Typeface.Default : new Typeface(avatar.FallbackFontFamily); + + avatar._fallbackLabel = new FormattedText( + placeholder, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + avatar.Width * 0.65, + Brushes.White); + + avatar.InvalidateVisual(); + } + + private FormattedText _fallbackLabel = null; + private LinearGradientBrush _fallbackBrush = null; + private string _emailMD5 = null; + private Bitmap _image = null; + } +} diff --git a/src/Views/Blame.axaml b/src/Views/Blame.axaml new file mode 100644 index 00000000..d3d054da --- /dev/null +++ b/src/Views/Blame.axaml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Blame.axaml.cs b/src/Views/Blame.axaml.cs new file mode 100644 index 00000000..5492cba7 --- /dev/null +++ b/src/Views/Blame.axaml.cs @@ -0,0 +1,261 @@ +using Avalonia; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Styling; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using AvaloniaEdit.TextMate; +using AvaloniaEdit.Utils; +using System; +using System.Globalization; +using System.IO; +using TextMateSharp.Grammars; + +namespace SourceGit.Views { + public class BlameTextEditor : TextEditor { + public class CommitInfoMargin : AbstractMargin { + public CommitInfoMargin(BlameTextEditor editor) { + _editor = editor; + ClipToBounds = true; + } + + public override void Render(DrawingContext context) { + if (_editor.BlameData == null) return; + + var view = TextView; + if (view != null && view.VisualLinesValid) { + var typeface = view.CreateTypeface(); + var underlinePen = new Pen(Brushes.DarkOrange, 1); + + foreach (var line in view.VisualLines) { + var lineNumber = line.FirstDocumentLine.LineNumber; + if (lineNumber > _editor.BlameData.LineInfos.Count) break; + + var info = _editor.BlameData.LineInfos[lineNumber - 1]; + if (!info.IsFirstInGroup) continue; + + var x = 0.0; + var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset; + + var shaLink = new FormattedText( + info.CommitSHA, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + Brushes.DarkOrange); + context.DrawText(shaLink, new Point(x, y)); + context.DrawLine(underlinePen, new Point(x, y + shaLink.Baseline + 2), new Point(x + shaLink.Width, y + shaLink.Baseline + 2)); + x += shaLink.Width + 8; + + var time = new FormattedText( + info.Time, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + _editor.Foreground); + context.DrawText(time, new Point(x, y)); + x += time.Width + 8; + + var author = new FormattedText( + info.Author, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + _editor.Foreground); + context.DrawText(author, new Point(x, y)); + } + } + } + + protected override Size MeasureOverride(Size availableSize) { + return new Size(250, 0); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) { + base.OnPointerPressed(e); + + var view = TextView; + if (!e.Handled && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && view != null && view.VisualLinesValid) { + var pos = e.GetPosition(this); + var typeface = view.CreateTypeface(); + + foreach (var line in view.VisualLines) { + var lineNumber = line.FirstDocumentLine.LineNumber; + if (lineNumber >= _editor.BlameData.LineInfos.Count) break; + + var info = _editor.BlameData.LineInfos[lineNumber - 1]; + if (!info.IsFirstInGroup) continue; + + var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset; + var shaLink = new FormattedText( + info.CommitSHA, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + Brushes.DarkOrange); + + var rect = new Rect(0, y, shaLink.Width, shaLink.Height); + if (rect.Contains(pos)) { + _editor.OnCommitSHAClicked(info.CommitSHA); + e.Handled = true; + break; + } + } + } + } + + private BlameTextEditor _editor = null; + } + + public class VerticalSeperatorMargin : AbstractMargin { + public VerticalSeperatorMargin(BlameTextEditor editor) { + _editor = editor; + } + + public override void Render(DrawingContext context) { + var pen = new Pen(_editor.BorderBrush, 1); + context.DrawLine(pen, new Point(0, 0), new Point(0, Bounds.Height)); + } + + protected override Size MeasureOverride(Size availableSize) { + return new Size(1, 0); + } + + private BlameTextEditor _editor = null; + } + + public static readonly StyledProperty BlameDataProperty = + AvaloniaProperty.Register(nameof(BlameData)); + + public Models.BlameData BlameData { + get => GetValue(BlameDataProperty); + set => SetValue(BlameDataProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextEditor); + + public BlameTextEditor() : base(new TextArea(), new TextDocument()) { + IsReadOnly = true; + ShowLineNumbers = false; + WordWrap = false; + } + + public void OnCommitSHAClicked(string sha) { + if (DataContext is ViewModels.Blame blame) { + blame.NavigateToCommit(sha); + } + } + + protected override void OnLoaded(RoutedEventArgs e) { + base.OnLoaded(e); + + TextArea.LeftMargins.Add(new LineNumberMargin() { Margin = new Thickness(8, 0) }); + TextArea.LeftMargins.Add(new VerticalSeperatorMargin(this)); + TextArea.LeftMargins.Add(new CommitInfoMargin(this) { Margin = new Thickness(8, 0) }); + TextArea.LeftMargins.Add(new VerticalSeperatorMargin(this)); + TextArea.TextView.ContextRequested += OnTextViewContextRequested; + TextArea.TextView.Margin = new Thickness(4, 0); + + if (App.Current?.ActualThemeVariant == ThemeVariant.Dark) { + _registryOptions = new RegistryOptions(ThemeName.DarkPlus); + } else { + _registryOptions = new RegistryOptions(ThemeName.LightPlus); + } + + _textMate = this.InstallTextMate(_registryOptions); + + if (BlameData != null) { + _textMate.SetGrammar(_registryOptions.GetScopeByExtension(Path.GetExtension(BlameData.File))); + } + } + + protected override void OnUnloaded(RoutedEventArgs e) { + base.OnUnloaded(e); + + TextArea.LeftMargins.Clear(); + TextArea.TextView.ContextRequested -= OnTextViewContextRequested; + + _registryOptions = null; + _textMate.Dispose(); + _textMate = null; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { + base.OnPropertyChanged(change); + + if (change.Property == BlameDataProperty) { + if (BlameData != null) { + Text = BlameData.Content; + if (_textMate != null) _textMate.SetGrammar(_registryOptions.GetScopeByExtension(Path.GetExtension(BlameData.File))); + } else { + Text = string.Empty; + } + } else if (change.Property.Name == "ActualThemeVariant" && change.NewValue != null && _textMate != null) { + if (App.Current?.ActualThemeVariant == ThemeVariant.Dark) { + _textMate.SetTheme(_registryOptions.LoadTheme(ThemeName.DarkPlus)); + } else { + _textMate.SetTheme(_registryOptions.LoadTheme(ThemeName.LightPlus)); + } + } + } + + private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e) { + var selected = SelectedText; + if (string.IsNullOrEmpty(selected)) return; + + var icon = new Avalonia.Controls.Shapes.Path(); + icon.Width = 10; + icon.Height = 10; + icon.Stretch = Stretch.Uniform; + icon.Data = App.Current?.FindResource("Icons.Copy") as StreamGeometry; + + var copy = new MenuItem(); + copy.Header = App.Text("Copy"); + copy.Icon = icon; + copy.Click += (o, ev) => { + App.CopyText(selected); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(copy); + menu.Open(TextArea.TextView); + e.Handled = true; + } + + private RegistryOptions _registryOptions = null; + private TextMate.Installation _textMate = null; + } + + public partial class Blame : Window { + public Blame() { + if (App.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + Owner = desktop.MainWindow; + } + + InitializeComponent(); + } + + protected override void OnClosed(EventArgs e) { + base.OnClosed(e); + GC.Collect(); + } + + private void OnCommitSHAPointerPressed(object sender, PointerPressedEventArgs e) { + if (DataContext is ViewModels.Blame blame) { + var txt = sender as TextBlock; + blame.NavigateToCommit(txt.Text); + } + e.Handled = true; + } + } +} diff --git a/src/Views/Blame.xaml b/src/Views/Blame.xaml deleted file mode 100644 index f2c46655..00000000 --- a/src/Views/Blame.xaml +++ /dev/null @@ -1,192 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Blame.xaml.cs b/src/Views/Blame.xaml.cs deleted file mode 100644 index 2a8c1598..00000000 --- a/src/Views/Blame.xaml.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using System.Windows.Navigation; - -namespace SourceGit.Views { - /// - /// 逐行追溯 - /// - public partial class Blame : Controls.Window { - /// - /// DataGrid数据源结构 - /// - public class Record : INotifyPropertyChanged { - public event PropertyChangedEventHandler PropertyChanged; - - /// - /// 原始Blame行数据 - /// - public Models.BlameLine Line { get; set; } - - /// - /// 是否是第一行 - /// - public bool IsFirstLine { get; set; } = false; - - /// - /// 前一行与本行的提交不同 - /// - public bool IsFirstLineInGroup { get; set; } = false; - - /// - /// 是否当前选中,会影响背景色 - /// - private bool isSelected = false; - public bool IsSelected { - get { return isSelected; } - set { - if (isSelected != value) { - isSelected = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("IsSelected")); - } - } - } - } - - /// - /// Blame数据 - /// - public ObservableCollection Records { get; set; } - - public Blame(string repo, string file, string revision) { - InitializeComponent(); - - this.repo = repo; - Records = new ObservableCollection(); - txtFile.Text = $"{file}@{revision.Substring(0, 8)}"; - - Task.Run(() => { - var lfs = new Commands.LFS(repo).IsFiltered(file); - if (lfs) { - Dispatcher.Invoke(() => { - loading.IsAnimating = false; - loading.Visibility = Visibility.Collapsed; - notSupport.Visibility = Visibility.Visible; - }); - return; - } - - var rs = new Commands.Blame(repo, file, revision).Result(); - if (rs.IsBinary) { - Dispatcher.Invoke(() => { - loading.IsAnimating = false; - loading.Visibility = Visibility.Collapsed; - notSupport.Visibility = Visibility.Visible; - }); - } else { - string lastSHA = null; - foreach (var line in rs.Lines) { - var r = new Record(); - r.Line = line; - r.IsSelected = false; - - if (line.CommitSHA != lastSHA) { - lastSHA = line.CommitSHA; - r.IsFirstLineInGroup = true; - } else { - r.IsFirstLineInGroup = false; - } - - Records.Add(r); - } - - if (Records.Count > 0) Records[0].IsFirstLine = true; - - Dispatcher.Invoke(() => { - loading.IsAnimating = false; - loading.Visibility = Visibility.Collapsed; - blame.ItemsSource = Records; - }); - } - }); - } - - #region WINDOW_COMMANDS - private void Minimize(object sender, RoutedEventArgs e) { - SystemCommands.MinimizeWindow(this); - } - - private void Quit(object sender, RoutedEventArgs e) { - Close(); - } - #endregion - - #region EVENTS - private T GetVisualChild(DependencyObject parent) where T : Visual { - T child = null; - - int count = VisualTreeHelper.GetChildrenCount(parent); - for (int i = 0; i < count; i++) { - Visual v = (Visual)VisualTreeHelper.GetChild(parent, i); - child = v as T; - - if (child == null) { - child = GetVisualChild(v); - } - - if (child != null) { - break; - } - } - - return child; - } - - private void OnViewerSizeChanged(object sender, SizeChangedEventArgs e) { - var total = blame.ActualWidth; - var offset = blame.NonFrozenColumnsViewportHorizontalOffset; - var minWidth = total - offset; - - var scroller = GetVisualChild(blame); - if (scroller != null && scroller.ComputedVerticalScrollBarVisibility == Visibility.Visible) minWidth -= 8; - - blame.Columns[2].MinWidth = minWidth; - blame.Columns[2].Width = DataGridLength.SizeToCells; - blame.UpdateLayout(); - } - - private void OnViewerRequestBringIntoView(object sender, RequestBringIntoViewEventArgs e) { - e.Handled = true; - } - - private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) { - var r = blame.SelectedItem as Record; - if (r == null) return; - - foreach (var one in Records) { - one.IsSelected = one.Line.CommitSHA == r.Line.CommitSHA; - } - } - - private void GotoCommit(object sender, RequestNavigateEventArgs e) { - Models.Watcher.Get(repo).NavigateTo(e.Uri.OriginalString); - e.Handled = true; - } - #endregion - - private string repo = null; - } -} diff --git a/src/Views/CaptionButtons.axaml b/src/Views/CaptionButtons.axaml new file mode 100644 index 00000000..581f7be1 --- /dev/null +++ b/src/Views/CaptionButtons.axaml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/src/Views/CaptionButtons.axaml.cs b/src/Views/CaptionButtons.axaml.cs new file mode 100644 index 00000000..09f70dad --- /dev/null +++ b/src/Views/CaptionButtons.axaml.cs @@ -0,0 +1,33 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace SourceGit.Views { + public partial class CaptionButtons : UserControl { + public CaptionButtons() { + InitializeComponent(); + } + + private void MinimizeWindow(object sender, RoutedEventArgs e) { + var window = this.FindAncestorOfType(); + if (window != null) { + window.WindowState = WindowState.Minimized; + } + } + + private void MaximizeOrRestoreWindow(object sender, RoutedEventArgs e) { + var window = this.FindAncestorOfType(); + if (window != null) { + window.WindowState = window.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + } + } + + private void CloseWindow(object sender, RoutedEventArgs e) { + var window = this.FindAncestorOfType(); + if (window != null) { + window.Close(); + } + } + } +} + diff --git a/src/Views/CaptionButtonsMacOS.axaml b/src/Views/CaptionButtonsMacOS.axaml new file mode 100644 index 00000000..fa3f1f34 --- /dev/null +++ b/src/Views/CaptionButtonsMacOS.axaml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/src/Views/CaptionButtonsMacOS.axaml.cs b/src/Views/CaptionButtonsMacOS.axaml.cs new file mode 100644 index 00000000..64b93d21 --- /dev/null +++ b/src/Views/CaptionButtonsMacOS.axaml.cs @@ -0,0 +1,33 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace SourceGit.Views { + public partial class CaptionButtonsMacOS : UserControl { + public CaptionButtonsMacOS() { + InitializeComponent(); + } + + private void MinimizeWindow(object sender, RoutedEventArgs e) { + var window = this.FindAncestorOfType(); + if (window != null) { + window.WindowState = WindowState.Minimized; + } + } + + private void MaximizeOrRestoreWindow(object sender, RoutedEventArgs e) { + var window = this.FindAncestorOfType(); + if (window != null) { + window.WindowState = window.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + } + } + + private void CloseWindow(object sender, RoutedEventArgs e) { + var window = this.FindAncestorOfType(); + if (window != null) { + window.Close(); + } + } + } +} + diff --git a/src/Views/ChangeStatusIcon.cs b/src/Views/ChangeStatusIcon.cs new file mode 100644 index 00000000..8d0001e6 --- /dev/null +++ b/src/Views/ChangeStatusIcon.cs @@ -0,0 +1,112 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using System; +using System.Globalization; + +namespace SourceGit.Views { + public class ChangeStatusIcon : Control { + private static readonly IBrush[] BACKGROUNDS = [ + Brushes.Transparent, + new LinearGradientBrush { + GradientStops = new GradientStops() { new GradientStop(Color.FromRgb(238, 160, 14), 0), new GradientStop(Color.FromRgb(228, 172, 67), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush { + GradientStops = new GradientStops() { new GradientStop(Color.FromRgb(47, 185, 47), 0), new GradientStop(Color.FromRgb(75, 189, 75), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush { + GradientStops = new GradientStops() { new GradientStop(Colors.Tomato, 0), new GradientStop(Color.FromRgb(252, 165, 150), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush { + GradientStops = new GradientStops() { new GradientStop(Colors.Orchid, 0), new GradientStop(Color.FromRgb(248, 161, 245), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush { + GradientStops = new GradientStops() { new GradientStop(Color.FromRgb(238, 160, 14), 0), new GradientStop(Color.FromRgb(228, 172, 67), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush { + GradientStops = new GradientStops() { new GradientStop(Color.FromRgb(238, 160, 14), 0), new GradientStop(Color.FromRgb(228, 172, 67), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush { + GradientStops = new GradientStops() { new GradientStop(Color.FromRgb(47, 185, 47), 0), new GradientStop(Color.FromRgb(75, 189, 75), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + ]; + + private static readonly string[] INDICATOR = ["?", "±", "+", "−", "➜", "❏", "U", "★"]; + + public static readonly StyledProperty IsWorkingCopyChangeProperty = + AvaloniaProperty.Register(nameof(IsWorkingCopyChange)); + + public bool IsWorkingCopyChange { + get => GetValue(IsWorkingCopyChangeProperty); + set => SetValue(IsWorkingCopyChangeProperty, value); + } + + public static readonly StyledProperty ChangeProperty = + AvaloniaProperty.Register(nameof(Change)); + + public Models.Change Change { + get => GetValue(ChangeProperty); + set => SetValue(ChangeProperty, value); + } + + public static readonly StyledProperty IconFontFamilyProperty = + AvaloniaProperty.Register(nameof(IconFontFamily)); + + public FontFamily IconFontFamily { + get => GetValue(IconFontFamilyProperty); + set => SetValue(IconFontFamilyProperty, value); + } + + static ChangeStatusIcon() { + AffectsRender(IsWorkingCopyChangeProperty, ChangeProperty, IconFontFamilyProperty); + } + + public override void Render(DrawingContext context) { + if (Change == null || Bounds.Width <= 0) return; + + var typeface = IconFontFamily == null ? Typeface.Default : new Typeface(IconFontFamily); + + IBrush background = null; + string indicator; + if (IsWorkingCopyChange) { + if (Change.IsConflit) { + background = Brushes.OrangeRed; + indicator = "!"; + } else { + background = BACKGROUNDS[(int)Change.WorkTree]; + indicator = INDICATOR[(int)Change.WorkTree]; + } + } else { + background = BACKGROUNDS[(int)Change.Index]; + indicator = INDICATOR[(int)Change.Index]; + } + + var txt = new FormattedText( + indicator, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + Bounds.Width * 0.8, + Brushes.White); + + float corner = (float)Math.Max(2, Bounds.Width / 16); + Point textOrigin = new Point((Bounds.Width - txt.Width) * 0.5, (Bounds.Height - txt.Height) * 0.5); + context.DrawRectangle(background, null, new Rect(0, 0, Bounds.Width, Bounds.Height), corner, corner); + context.DrawText(txt, textOrigin); + } + } +} diff --git a/src/Views/ChangeViewModeSwitcher.axaml b/src/Views/ChangeViewModeSwitcher.axaml new file mode 100644 index 00000000..32d4f1e9 --- /dev/null +++ b/src/Views/ChangeViewModeSwitcher.axaml @@ -0,0 +1,35 @@ + + + diff --git a/src/Views/ChangeViewModeSwitcher.axaml.cs b/src/Views/ChangeViewModeSwitcher.axaml.cs new file mode 100644 index 00000000..59a041e9 --- /dev/null +++ b/src/Views/ChangeViewModeSwitcher.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.Controls; + +namespace SourceGit.Views { + public partial class ChangeViewModeSwitcher : UserControl { + public static readonly StyledProperty ViewModeProperty = + AvaloniaProperty.Register(nameof(ViewMode)); + + public Models.ChangeViewMode ViewMode { + get => GetValue(ViewModeProperty); + set => SetValue(ViewModeProperty, value); + } + + public ChangeViewModeSwitcher() { + DataContext = this; + InitializeComponent(); + } + + public void SwitchMode(object param) { + ViewMode = (Models.ChangeViewMode)param; + } + } +} diff --git a/src/Views/Checkout.axaml b/src/Views/Checkout.axaml new file mode 100644 index 00000000..f921326b --- /dev/null +++ b/src/Views/Checkout.axaml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/src/Views/Checkout.axaml.cs b/src/Views/Checkout.axaml.cs new file mode 100644 index 00000000..d2d9edca --- /dev/null +++ b/src/Views/Checkout.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; + +namespace SourceGit.Views { + public partial class Checkout : UserControl { + public Checkout() { + InitializeComponent(); + } + } +} diff --git a/src/Views/CherryPick.axaml b/src/Views/CherryPick.axaml new file mode 100644 index 00000000..a4b654fd --- /dev/null +++ b/src/Views/CherryPick.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + diff --git a/src/Views/CherryPick.axaml.cs b/src/Views/CherryPick.axaml.cs new file mode 100644 index 00000000..306d4702 --- /dev/null +++ b/src/Views/CherryPick.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; + +namespace SourceGit.Views { + public partial class CherryPick : UserControl { + public CherryPick() { + InitializeComponent(); + } + } +} diff --git a/src/Views/Cleanup.axaml b/src/Views/Cleanup.axaml new file mode 100644 index 00000000..7e5d8ff9 --- /dev/null +++ b/src/Views/Cleanup.axaml @@ -0,0 +1,19 @@ + + + + + + diff --git a/src/Views/Cleanup.axaml.cs b/src/Views/Cleanup.axaml.cs new file mode 100644 index 00000000..a098d9a4 --- /dev/null +++ b/src/Views/Cleanup.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; + +namespace SourceGit.Views { + public partial class Cleanup : UserControl { + public Cleanup() { + InitializeComponent(); + } + } +} diff --git a/src/Views/ClearStashes.axaml b/src/Views/ClearStashes.axaml new file mode 100644 index 00000000..666bf273 --- /dev/null +++ b/src/Views/ClearStashes.axaml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/src/Views/ClearStashes.axaml.cs b/src/Views/ClearStashes.axaml.cs new file mode 100644 index 00000000..d95e18c1 --- /dev/null +++ b/src/Views/ClearStashes.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; + +namespace SourceGit.Views { + public partial class ClearStashes : UserControl { + public ClearStashes() { + InitializeComponent(); + } + } +} diff --git a/src/Views/Clone.axaml b/src/Views/Clone.axaml new file mode 100644 index 00000000..ca3a4d3f --- /dev/null +++ b/src/Views/Clone.axaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Clone.axaml.cs b/src/Views/Clone.axaml.cs new file mode 100644 index 00000000..cd52e0bf --- /dev/null +++ b/src/Views/Clone.axaml.cs @@ -0,0 +1,33 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views { + public partial class Clone : UserControl { + public Clone() { + InitializeComponent(); + } + + private async void SelectParentFolder(object sender, RoutedEventArgs e) { + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + var toplevel = TopLevel.GetTopLevel(this); + var selected = await toplevel.StorageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) { + txtParentFolder.Text = selected[0].Path.LocalPath; + } + + e.Handled = true; + } + + private async void SelectSSHKey(object sender, RoutedEventArgs e) { + var options = new FilePickerOpenOptions() { AllowMultiple = false, FileTypeFilter = [new FilePickerFileType("SSHKey") { Patterns = ["*.*"] }] }; + var toplevel = TopLevel.GetTopLevel(this); + var selected = await toplevel.StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) { + txtSSHKey.Text = selected[0].Path.LocalPath; + } + + e.Handled = true; + } + } +} diff --git a/src/Views/Clone.xaml b/src/Views/Clone.xaml deleted file mode 100644 index 07536e82..00000000 --- a/src/Views/Clone.xaml +++ /dev/null @@ -1,261 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CommitChanges.axaml.cs b/src/Views/CommitChanges.axaml.cs new file mode 100644 index 00000000..db65922d --- /dev/null +++ b/src/Views/CommitChanges.axaml.cs @@ -0,0 +1,39 @@ +using Avalonia.Controls; + +namespace SourceGit.Views { + public partial class CommitChanges : UserControl { + public CommitChanges() { + InitializeComponent(); + } + + private void OnDataGridSelectionChanged(object sender, SelectionChangedEventArgs e) { + if (sender is DataGrid datagrid && datagrid.IsVisible && datagrid.SelectedItem != null) { + datagrid.ScrollIntoView(datagrid.SelectedItem, null); + } + e.Handled = true; + } + + private void OnDataGridContextRequested(object sender, ContextRequestedEventArgs e) { + if (sender is DataGrid datagrid && datagrid.SelectedItem != null) { + var detail = DataContext as ViewModels.CommitDetail; + var menu = detail.CreateChangeContextMenu(datagrid.SelectedItem as Models.Change); + menu.Open(datagrid); + } + + e.Handled = true; + } + + private void OnTreeViewContextRequested(object sender, ContextRequestedEventArgs e) { + if (sender is TreeView view && view.SelectedItem != null) { + var detail = DataContext as ViewModels.CommitDetail; + var node = view.SelectedItem as ViewModels.FileTreeNode; + if (node != null && !node.IsFolder) { + var menu = detail.CreateChangeContextMenu(node.Backend as Models.Change); + menu.Open(view); + } + } + + e.Handled = true; + } + } +} diff --git a/src/Views/CommitDetail.axaml b/src/Views/CommitDetail.axaml new file mode 100644 index 00000000..47d9fde7 --- /dev/null +++ b/src/Views/CommitDetail.axaml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CommitDetail.axaml.cs b/src/Views/CommitDetail.axaml.cs new file mode 100644 index 00000000..326c612d --- /dev/null +++ b/src/Views/CommitDetail.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views { + public partial class CommitDetail : UserControl { + public CommitDetail() { + InitializeComponent(); + } + + private void OnChangeListDoubleTapped(object sender, TappedEventArgs e) { + if (DataContext is ViewModels.CommitDetail detail) { + var datagrid = sender as DataGrid; + detail.ActivePageIndex = 1; + detail.SelectedChange = datagrid.SelectedItem as Models.Change; + } + e.Handled = true; + } + + private void OnChangeListContextRequested(object sender, ContextRequestedEventArgs e) { + if (DataContext is ViewModels.CommitDetail detail) { + var datagrid = sender as DataGrid; + var menu = detail.CreateChangeContextMenu(datagrid.SelectedItem as Models.Change); + menu.Open(datagrid); + } + e.Handled = true; + } + } +} diff --git a/src/Views/ConfirmDialog.xaml b/src/Views/ConfirmDialog.xaml deleted file mode 100644 index 867fedb1..00000000 --- a/src/Views/ConfirmDialog.xaml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -