From 8a8aabede36633bcb4225059fa109299756b906d Mon Sep 17 00:00:00 2001 From: leo Date: Thu, 27 Jun 2024 18:25:16 +0800 Subject: [PATCH] feature: add worktree support (#205) --- README.md | 1 + src/Commands/Worktree.cs | 124 +++++++++++++++++++++++++ src/Models/Watcher.cs | 2 + src/Models/Worktree.cs | 39 ++++++++ src/Resources/Icons.axaml | 2 + src/Resources/Locales/en_US.axaml | 22 +++++ src/Resources/Locales/zh_CN.axaml | 22 +++++ src/Resources/Locales/zh_TW.axaml | 22 +++++ src/ViewModels/AddWorktree.cs | 122 +++++++++++++++++++++++++ src/ViewModels/LockWorktree.cs | 44 +++++++++ src/ViewModels/PruneWorktrees.cs | 28 ++++++ src/ViewModels/RemoveWorktree.cs | 41 +++++++++ src/ViewModels/Repository.cs | 147 ++++++++++++++++++++++++++++-- src/Views/AddWorktree.axaml | 71 +++++++++++++++ src/Views/AddWorktree.axaml.cs | 28 ++++++ src/Views/LockWorktree.axaml | 41 +++++++++ src/Views/LockWorktree.axaml.cs | 12 +++ src/Views/PruneWorktrees.axaml | 15 +++ src/Views/PruneWorktrees.axaml.cs | 12 +++ src/Views/RemoveWorktree.axaml | 34 +++++++ src/Views/RemoveWorktree.axaml.cs | 12 +++ src/Views/Repository.axaml | 94 ++++++++++++++++++- src/Views/Repository.axaml.cs | 45 ++++++--- 23 files changed, 959 insertions(+), 21 deletions(-) create mode 100644 src/Commands/Worktree.cs create mode 100644 src/Models/Worktree.cs create mode 100644 src/ViewModels/AddWorktree.cs create mode 100644 src/ViewModels/LockWorktree.cs create mode 100644 src/ViewModels/PruneWorktrees.cs create mode 100644 src/ViewModels/RemoveWorktree.cs create mode 100644 src/Views/AddWorktree.axaml create mode 100644 src/Views/AddWorktree.axaml.cs create mode 100644 src/Views/LockWorktree.axaml create mode 100644 src/Views/LockWorktree.axaml.cs create mode 100644 src/Views/PruneWorktrees.axaml create mode 100644 src/Views/PruneWorktrees.axaml.cs create mode 100644 src/Views/RemoveWorktree.axaml create mode 100644 src/Views/RemoveWorktree.axaml.cs diff --git a/README.md b/README.md index b8523562..93b5eea5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Opensource Git GUI client. * Tags * Stashes * Submodules + * Worktrees * Archive * Diff * Save as patch/apply diff --git a/src/Commands/Worktree.cs b/src/Commands/Worktree.cs new file mode 100644 index 00000000..74b41358 --- /dev/null +++ b/src/Commands/Worktree.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class Worktree : Command + { + [GeneratedRegex(@"^(\w)\s(\d+)$")] + private static partial Regex REG_AHEAD_BEHIND(); + + public Worktree(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public List List() + { + Args = "worktree list --porcelain"; + + var rs = ReadToEnd(); + var worktrees = new List(); + var last = null as Models.Worktree; + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.StartsWith("worktree ", StringComparison.Ordinal)) + { + last = new Models.Worktree() { FullPath = line.Substring(9).Trim() }; + worktrees.Add(last); + } + else if (line.StartsWith("bare", StringComparison.Ordinal)) + { + last.IsBare = true; + } + else if (line.StartsWith("HEAD ", StringComparison.Ordinal)) + { + last.Head = line.Substring(5).Trim(); + } + else if (line.StartsWith("branch ", StringComparison.Ordinal)) + { + last.Branch = line.Substring(7).Trim(); + } + else if (line.StartsWith("detached", StringComparison.Ordinal)) + { + last.IsDetached = true; + } + else if (line.StartsWith("locked", StringComparison.Ordinal)) + { + last.IsLocked = true; + } + else if (line.StartsWith("prunable", StringComparison.Ordinal)) + { + last.IsPrunable = true; + } + } + } + + return worktrees; + } + + public bool Add(string fullpath, string name, string tracking, Action outputHandler) + { + Args = "worktree add "; + + if (!string.IsNullOrEmpty(tracking)) + Args += "--track "; + + if (!string.IsNullOrEmpty(name)) + Args += $"-b {name} "; + + Args += $"\"{fullpath}\" "; + + if (!string.IsNullOrEmpty(tracking)) + Args += tracking; + + _outputHandler = outputHandler; + return Exec(); + } + + public bool Prune(Action outputHandler) + { + Args = "worktree prune -v"; + _outputHandler = outputHandler; + return Exec(); + } + + public bool Lock(string fullpath, string reason) + { + if (string.IsNullOrEmpty(reason)) + Args = $"worktree lock \"{fullpath}\""; + else + Args = $"worktree lock --reason \"{reason}\" \"{fullpath}\""; + return Exec(); + } + + public bool Unlock(string fullpath) + { + Args = $"worktree unlock \"{fullpath}\""; + return Exec(); + } + + public bool Remove(string fullpath, bool force, Action outputHandler) + { + if (force) + Args = $"worktree remove -f \"{fullpath}\""; + else + Args = $"worktree remove \"{fullpath}\""; + + _outputHandler = outputHandler; + return Exec(); + } + + protected override void OnReadline(string line) + { + _outputHandler?.Invoke(line); + } + + private Action _outputHandler = null; + } +} diff --git a/src/Models/Watcher.cs b/src/Models/Watcher.cs index e4eb6851..de3a42c4 100644 --- a/src/Models/Watcher.cs +++ b/src/Models/Watcher.cs @@ -11,6 +11,7 @@ namespace SourceGit.Models string GitDir { get; set; } void RefreshBranches(); + void RefreshWorktrees(); void RefreshTags(); void RefreshCommits(); void RefreshSubmodules(); @@ -132,6 +133,7 @@ namespace SourceGit.Models } Task.Run(_repo.RefreshWorkingCopyChanges); + Task.Run(_repo.RefreshWorktrees); } if (_updateWC > 0 && now > _updateWC) diff --git a/src/Models/Worktree.cs b/src/Models/Worktree.cs new file mode 100644 index 00000000..5fbd4436 --- /dev/null +++ b/src/Models/Worktree.cs @@ -0,0 +1,39 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public class Worktree : ObservableObject + { + public string Branch { get; set; } = string.Empty; + public string FullPath { get; set; } = string.Empty; + public string Head { get; set; } = string.Empty; + public bool IsBare { get; set; } = false; + public bool IsDetached { get; set; } = false; + public bool IsPrunable { get; set; } = false; + + public bool IsLocked + { + get => _isLocked; + set => SetProperty(ref _isLocked, value); + } + + public string Name + { + get + { + if (IsDetached) + return $"(deteched HEAD at {Head.Substring(10)})"; + + if (Branch.StartsWith("refs/heads/", System.StringComparison.Ordinal)) + return $"({Branch.Substring(11)})"; + + if (Branch.StartsWith("refs/remotes/", System.StringComparison.Ordinal)) + return $"({Branch.Substring(13)})"; + + return $"({Branch})"; + } + } + + private bool _isLocked = false; + } +} diff --git a/src/Resources/Icons.axaml b/src/Resources/Icons.axaml index 0b863eb6..1af75c64 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -104,4 +104,6 @@ M153 154h768v768h-768v-768zm64 64v640h640v-640h-640z M256 128l0 192L64 320l0 576 704 0 0-192 192 0L960 128 256 128zM704 832 128 832 128 384l576 0L704 832zM896 640l-128 0L768 320 320 320 320 192l576 0L896 640z M248 221a77 77 0 00-30-21c-18-7-40-10-68-5a224 224 0 00-45 13c-5 2-10 5-15 8l-3 2v68l11-9c10-8 21-14 34-19 13-5 26-7 39-7 12 0 21 3 28 10 6 6 9 16 9 29l-62 9c-14 2-26 6-36 11a80 80 0 00-25 20c-7 8-12 17-15 27-6 21-6 44 1 65a70 70 0 0041 43c10 4 21 6 34 6a80 80 0 0063-28v22h64V298c0-16-2-31-6-44a91 91 0 00-18-33zm-41 121v15c0 8-1 15-4 22a48 48 0 01-24 29 44 44 0 01-33 2 29 29 0 01-10-6 25 25 0 01-6-9 30 30 0 01-2-12c0-5 1-9 2-14a21 21 0 015-9 28 28 0 0110-7 83 83 0 0120-5l42-6zm323-68a144 144 0 00-16-42 87 87 0 00-28-29 75 75 0 00-41-11 73 73 0 00-44 14c-6 5-12 11-17 17V64H326v398h59v-18c8 10 18 17 30 21 6 2 13 3 21 3 16 0 31-4 43-11 12-7 23-18 31-31a147 147 0 0019-46 248 248 0 006-57c0-17-2-33-5-49zm-55 49c0 15-1 28-4 39-2 11-6 20-10 27a41 41 0 01-15 15 37 37 0 01-36 1 44 44 0 01-13-12 59 59 0 01-9-18A76 76 0 01384 352v-33c0-10 1-20 4-29 2-8 6-15 10-22a43 43 0 0115-13 37 37 0 0119-5 35 35 0 0132 18c4 6 7 14 9 23 2 9 3 20 3 31zM154 634a58 58 0 0120-15c14-6 35-7 49-1 7 3 13 6 20 12l21 17V572l-6-4a124 124 0 00-58-14c-20 0-38 4-54 11-16 7-30 17-41 30-12 13-20 29-26 46-6 17-9 36-9 57 0 18 3 36 8 52 6 16 14 30 24 42 10 12 23 21 38 28 15 7 32 10 50 10 15 0 28-2 39-5 11-3 21-8 30-14l5-4v-57l-13 6a26 26 0 01-5 2c-3 1-6 2-8 3-2 1-15 6-15 6-4 2-9 3-14 4a63 63 0 01-38-4 53 53 0 01-20-14 70 70 0 01-13-24 111 111 0 01-5-34c0-13 2-26 5-36 3-10 8-19 14-26zM896 384h-256V320h288c21 1 32 12 32 32v384c0 18-12 32-32 32H504l132 133-45 45-185-185c-16-21-16-25 0-45l185-185L637 576l-128 128H896V384z + M512 0C229 0 0 72 0 160v128C0 376 229 448 512 448s512-72 512-160v-128C1024 72 795 0 512 0zM512 544C229 544 0 472 0 384v192c0 88 229 160 512 160s512-72 512-160V384c0 88-229 160-512 160zM512 832c-283 0-512-72-512-160v192C0 952 229 1024 512 1024s512-72 512-160v-192c0 88-229 160-512 160z + M640 725 768 725 768 597 853 597 853 725 981 725 981 811 853 811 853 939 768 939 768 811 640 811 640 725M384 128C573 128 725 204 725 299 725 393 573 469 384 469 195 469 43 393 43 299 43 204 195 128 384 128M43 384C43 478 195 555 384 555 573 555 725 478 725 384L725 512 683 512 683 595C663 612 640 627 610 640L555 640 555 660C504 675 446 683 384 683 195 683 43 606 43 512L43 384M43 597C43 692 195 768 384 768 446 768 504 760 555 745L555 873C504 888 446 896 384 896 195 896 43 820 43 725L43 597Z diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 165afaa4..8158d38a 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -7,6 +7,12 @@ • Monospace fonts come from • Source code can be found at Opensource & Free Git GUI Client + Add Worktree + Location: + Branch Name: + Optional. Default is the destination folder name. + Track Branch: + Tracking remote branch Patch Error Raise errors and refuses to apply the patch @@ -305,6 +311,10 @@ ERROR NOTICE Open Main Menu + Lock Worktree + Reason: + Optional, specify a reason for the lock. + Target: Merge Branch Into: Merge Option: @@ -367,6 +377,8 @@ Tool Prune Remote Target: + Prune Worktrees + Prune worktree information in `$GIT_DIR/worktrees` Pull Branch: Into: @@ -408,6 +420,9 @@ Open In Browser Prune Target: + Confirm to Remove Worktree + Enable `--force` Option + Target: Rename Branch New Name: Unique name for this branch @@ -441,6 +456,9 @@ TAGS NEW TAG Open In Terminal + WORKTREES + ADD WORKTREE + PRUNE Git Repository URL Reset Current Branch To Revision Reset Mode: @@ -541,4 +559,8 @@ VIEW ASSUME UNCHANGED Right-click the selected file(s), and make your choice to resolve conflicts. WORKTREE + Copy Path + Lock + Remove + Unlock diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index 59a1f3ba..81f42741 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -10,6 +10,12 @@ • 等宽字体来自于 • 项目源代码地址 开源免费的Git客户端 + 新增工作树 + 工作树路径 : + 自定义分支名 : + 选填。默认使用目标文件夹名称。 + 跟踪分支 + 设置上游跟踪分支 应用补丁(apply) 错误 输出错误,并终止应用补丁 @@ -308,6 +314,10 @@ 出错了 系统提示 主菜单 + 锁定工作树 + 原因 : + 选填,为此锁定操作描述原因。 + 目标工作树 : 合并分支 目标分支 : 合并方式 : @@ -370,6 +380,8 @@ 工具 清理远程已删除分支 目标 : + 清理工作树 + 清理在`$GIT_DIR/worktrees`中的无效工作树信息 拉回(pull) 拉取分支 : 本地分支 : @@ -410,6 +422,9 @@ 拉取(fetch)更新 在浏览器中打开 清理远程已删除分支 + 移除工作树操作确认 + 启用`--force`选项 + 目标工作树 : 分支重命名 新的名称 : 新的分支名不能与现有分支名相同 @@ -443,6 +458,9 @@ 标签列表 新建标签 在终端中打开 + 工作树列表 + 新增工作树 + 清理 远程仓库地址 重置(reset)当前分支到指定版本 重置模式 : @@ -543,4 +561,8 @@ 查看忽略变更文件 请选中冲突文件,打开右键菜单,选择合适的解决方式 本地工作树 + 复制工作树路径 + 锁定工作树 + 移除工作树 + 解除工作树锁定 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index 90476146..25299cda 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -10,6 +10,12 @@ • 等寬字型來自於 • 專案原始碼地址 開源免費的Git客戶端 + 新增工作樹 + 工作樹路徑 : + 自定义分支名 : + 選填。 預設使用目標資料夾名稱。 + 跟蹤分支 + 設置上游跟蹤分支 應用補丁(apply) 錯誤 輸出錯誤,並終止應用補丁 @@ -308,6 +314,10 @@ 出錯了 系統提示 主選單 + 鎖定工作樹 + 原因 : + 選填,為此鎖定操作描述原因。 + 目標工作樹 : 合併分支 目標分支 : 合併方式 : @@ -370,6 +380,8 @@ 工具 清理遠端已刪除分支 目標 : + 清理工作樹 + 清理在`$GIT_DIR/worktrees`中的無效工作樹資訊 拉回(pull) 拉取分支 : 本地分支 : @@ -410,6 +422,9 @@ 拉取(fetch)更新 在瀏覽器中訪問網址 清理遠端已刪除分支 + 刪除工作樹操作確認 + 啟用`--force`選項 + 目標工作樹 : 分支重新命名 新的名稱 : 新的分支名不能與現有分支名相同 @@ -443,6 +458,9 @@ 標籤列表 新建標籤 在終端中開啟 + 工作樹清單 + 新建工作樹 + 清理 遠端倉庫地址 重置(reset)當前分支到指定版本 重置模式 : @@ -543,4 +561,8 @@ 檢視忽略變更檔案 請選中衝突檔案,開啟右鍵選單,選擇合適的解決方式 本地工作樹 + 拷贝工作樹路徑 + 鎖定工作樹 + 移除工作樹 + 解除鎖定工作樹 diff --git a/src/ViewModels/AddWorktree.cs b/src/ViewModels/AddWorktree.cs new file mode 100644 index 00000000..afa42ebf --- /dev/null +++ b/src/ViewModels/AddWorktree.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public partial class AddWorktree : Popup + { + [GeneratedRegex(@"^[\w\-/\.]+$")] + private static partial Regex REG_NAME(); + + [Required(ErrorMessage = "Worktree path is required!")] + [CustomValidation(typeof(AddWorktree), nameof(ValidateWorktreePath))] + public string FullPath + { + get => _fullPath; + set => SetProperty(ref _fullPath, value, true); + } + + [CustomValidation(typeof(AddWorktree), nameof(ValidateBranchName))] + public string CustomName + { + get => _customName; + set => SetProperty(ref _customName, value, true); + } + + public bool SetTrackingBranch + { + get => _setTrackingBranch; + set => SetProperty(ref _setTrackingBranch, value); + } + + public List TrackingBranches + { + get; + private set; + } + + public string SelectedTrackingBranch + { + get; + set; + } + + public AddWorktree(Repository repo) + { + _repo = repo; + + TrackingBranches = new List(); + foreach (var branch in repo.Branches) + { + if (!branch.IsLocal) + TrackingBranches.Add($"{branch.Remote}/{branch.Name}"); + } + + if (TrackingBranches.Count > 0) + SelectedTrackingBranch = TrackingBranches[0]; + else + SelectedTrackingBranch = string.Empty; + + View = new Views.AddWorktree() { DataContext = this }; + } + + public static ValidationResult ValidateWorktreePath(string folder, ValidationContext _) + { + var info = new DirectoryInfo(folder); + if (info.Exists) + { + var files = info.GetFiles(); + if (files.Length > 0) + return new ValidationResult("Given path is not empty!!!"); + + var folders = info.GetDirectories(); + if (folders.Length > 0) + return new ValidationResult("Given path is not empty!!!"); + } + + return ValidationResult.Success; + } + + public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) + { + if (string.IsNullOrEmpty(name)) + return ValidationResult.Success; + + var creator = ctx.ObjectInstance as AddWorktree; + 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); + ProgressDescription = "Adding worktree ..."; + + var tracking = _setTrackingBranch ? SelectedTrackingBranch : string.Empty; + + return Task.Run(() => + { + var succ = new Commands.Worktree(_repo.FullPath).Add(_fullPath, _customName, tracking, SetProgressDescription); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private string _fullPath = string.Empty; + private string _customName = string.Empty; + private bool _setTrackingBranch = false; + } +} diff --git a/src/ViewModels/LockWorktree.cs b/src/ViewModels/LockWorktree.cs new file mode 100644 index 00000000..e8a181c6 --- /dev/null +++ b/src/ViewModels/LockWorktree.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class LockWorktree : Popup + { + public Models.Worktree Target + { + get; + private set; + } = null; + + public string Reason + { + get; + set; + } = string.Empty; + + public LockWorktree(Repository repo, Models.Worktree target) + { + _repo = repo; + Target = target; + View = new Views.LockWorktree() { DataContext = this }; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Locking worktrees ..."; + + return Task.Run(() => + { + var succ = new Commands.Worktree(_repo.FullPath).Lock(Target.FullPath, Reason); + if (succ) + Target.IsLocked = true; + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/PruneWorktrees.cs b/src/ViewModels/PruneWorktrees.cs new file mode 100644 index 00000000..754ea4cb --- /dev/null +++ b/src/ViewModels/PruneWorktrees.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class PruneWorktrees : Popup + { + public PruneWorktrees(Repository repo) + { + _repo = repo; + View = new Views.PruneWorktrees() { DataContext = this }; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Prune worktrees ..."; + + return Task.Run(() => + { + new Commands.Worktree(_repo.FullPath).Prune(SetProgressDescription); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/RemoveWorktree.cs b/src/ViewModels/RemoveWorktree.cs new file mode 100644 index 00000000..0a2e620b --- /dev/null +++ b/src/ViewModels/RemoveWorktree.cs @@ -0,0 +1,41 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class RemoveWorktree : Popup + { + public Models.Worktree Target + { + get; + private set; + } = null; + + public bool Force + { + get; + set; + } = false; + + public RemoveWorktree(Repository repo, Models.Worktree target) + { + _repo = repo; + Target = target; + View = new Views.RemoveWorktree() { DataContext = this }; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Remove worktrees ..."; + + return Task.Run(() => + { + var succ = new Commands.Worktree(_repo.FullPath).Remove(Target.FullPath, Force, SetProgressDescription); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + } +} diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 9528611a..2e980575 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -131,6 +131,13 @@ namespace SourceGit.ViewModels private set => SetProperty(ref _remoteBranchTrees, value); } + [JsonIgnore] + public List Worktrees + { + get => _worktrees; + private set => SetProperty(ref _worktrees, value); + } + [JsonIgnore] public List Tags { @@ -219,6 +226,13 @@ namespace SourceGit.ViewModels set => SetProperty(ref _isSubmoduleGroupExpanded, value); } + [JsonIgnore] + public bool IsWorktreeGroupExpanded + { + get => _isWorktreeGroupExpanded; + set => SetProperty(ref _isWorktreeGroupExpanded, value); + } + [JsonIgnore] public InProgressContext InProgressContext { @@ -295,6 +309,7 @@ namespace SourceGit.ViewModels }); Task.Run(RefreshSubmodules); + Task.Run(RefreshWorktrees); Task.Run(RefreshWorkingCopyChanges); Task.Run(RefreshStashes); } @@ -590,11 +605,31 @@ namespace SourceGit.ViewModels }); } + public void RefreshWorktrees() + { + var worktrees = new Commands.Worktree(_fullpath).List(); + var cleaned = new List(); + + foreach (var worktree in worktrees) + { + if (worktree.IsBare || worktree.FullPath.Equals(_fullpath)) + continue; + + cleaned.Add(worktree); + } + + Dispatcher.UIThread.Invoke(() => + { + Worktrees = cleaned; + }); + } + public void RefreshTags() { var tags = new Commands.QueryTags(FullPath).Result(); foreach (var tag in tags) tag.IsFiltered = Filters.Contains(tag.Name); + Dispatcher.UIThread.Invoke(() => { Tags = tags; @@ -656,10 +691,7 @@ namespace SourceGit.ViewModels public void RefreshSubmodules() { var submodules = new Commands.QuerySubmodules(FullPath).Result(); - Dispatcher.UIThread.Invoke(() => - { - Submodules = submodules; - }); + Dispatcher.UIThread.Invoke(() => Submodules = submodules); } public void RefreshWorkingCopyChanges() @@ -732,6 +764,16 @@ namespace SourceGit.ViewModels public void CheckoutBranch(Models.Branch branch) { + if (branch.IsLocal) + { + var worktree = _worktrees.Find(x => x.Branch == branch.FullName); + if (worktree != null) + { + OpenWorktree(worktree); + return; + } + } + if (!PopupHost.CanCreatePopup()) return; @@ -817,6 +859,36 @@ namespace SourceGit.ViewModels } } + public void AddWorktree() + { + if (PopupHost.CanCreatePopup()) + PopupHost.ShowPopup(new AddWorktree(this)); + } + + public void PruneWorktrees() + { + if (PopupHost.CanCreatePopup()) + PopupHost.ShowAndStartPopup(new PruneWorktrees(this)); + } + + public void OpenWorktree(Models.Worktree worktree) + { + var gitDir = new Commands.QueryGitDir(worktree.FullPath).Result(); + var repo = Preference.AddRepository(worktree.FullPath, gitDir); + + var node = new RepositoryNode() + { + Id = repo.FullPath, + Name = Path.GetFileName(repo.FullPath), + Bookmark = 0, + IsRepository = true, + }; + + var launcher = App.GetTopLevel().DataContext as Launcher; + if (launcher != null) + launcher.OpenRepositoryInTab(node, null); + } + public ContextMenu CreateContextMenuForGitFlow() { var menu = new ContextMenu(); @@ -1260,9 +1332,8 @@ namespace SourceGit.ViewModels target.Click += (o, e) => { if (Commands.Branch.SetUpstream(_fullpath, branch.Name, upstream)) - { Task.Run(RefreshBranches); - } + e.Handled = true; }; @@ -1274,9 +1345,8 @@ namespace SourceGit.ViewModels unsetUpstream.Click += (_, e) => { if (Commands.Branch.SetUpstream(_fullpath, branch.Name, string.Empty)) - { Task.Run(RefreshBranches); - } + e.Handled = true; }; tracking.Items.Add(new MenuItem() { Header = "-" }); @@ -1634,6 +1704,65 @@ namespace SourceGit.ViewModels return menu; } + public ContextMenu CreateContextMenuForWorktree(Models.Worktree worktree) + { + var menu = new ContextMenu(); + + if (worktree.IsLocked) + { + var unlock = new MenuItem(); + unlock.Header = App.Text("Worktree.Unlock"); + unlock.Icon = App.CreateMenuIcon("Icons.Unlock"); + unlock.Click += (o, ev) => + { + SetWatcherEnabled(false); + var succ = new Commands.Worktree(_fullpath).Unlock(worktree.FullPath); + if (succ) + worktree.IsLocked = false; + SetWatcherEnabled(true); + ev.Handled = true; + }; + menu.Items.Add(unlock); + } + else + { + var loc = new MenuItem(); + loc.Header = App.Text("Worktree.Lock"); + loc.Icon = App.CreateMenuIcon("Icons.Lock"); + loc.Click += (o, ev) => + { + if (PopupHost.CanCreatePopup()) + PopupHost.ShowPopup(new LockWorktree(this, worktree)); + ev.Handled = true; + }; + menu.Items.Add(loc); + } + + var remove = new MenuItem(); + remove.Header = App.Text("Worktree.Remove"); + remove.Icon = App.CreateMenuIcon("Icons.Clear"); + remove.Click += (o, ev) => + { + if (PopupHost.CanCreatePopup()) + PopupHost.ShowPopup(new RemoveWorktree(this, worktree)); + ev.Handled = true; + }; + menu.Items.Add(remove); + + var copy = new MenuItem(); + copy.Header = App.Text("Worktree.CopyPath"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (o, e) => + { + App.CopyText(worktree.FullPath); + e.Handled = true; + }; + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copy); + + return menu; + } + private MenuItem CreateMenuItemToCompareBranches(Models.Branch branch) { if (Branches.Count == 1) @@ -1712,6 +1841,7 @@ namespace SourceGit.ViewModels private bool _isTagGroupExpanded = false; private bool _isSubmoduleGroupExpanded = false; + private bool _isWorktreeGroupExpanded = false; private string _searchBranchFilter = string.Empty; @@ -1719,6 +1849,7 @@ namespace SourceGit.ViewModels private List _branches = new List(); private List _localBranchTrees = new List(); private List _remoteBranchTrees = new List(); + private List _worktrees = new List(); private List _tags = new List(); private List _submodules = new List(); private bool _includeUntracked = true; diff --git a/src/Views/AddWorktree.axaml b/src/Views/AddWorktree.axaml new file mode 100644 index 00000000..11bc2186 --- /dev/null +++ b/src/Views/AddWorktree.axaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/AddWorktree.axaml.cs b/src/Views/AddWorktree.axaml.cs new file mode 100644 index 00000000..bd2a5e30 --- /dev/null +++ b/src/Views/AddWorktree.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class AddWorktree : UserControl + { + public AddWorktree() + { + InitializeComponent(); + } + + private async void SelectLocation(object sender, RoutedEventArgs e) + { + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + return; + + var selected = await toplevel.StorageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) + TxtLocation.Text = selected[0].Path.LocalPath; + + e.Handled = true; + } + } +} diff --git a/src/Views/LockWorktree.axaml b/src/Views/LockWorktree.axaml new file mode 100644 index 00000000..29da003a --- /dev/null +++ b/src/Views/LockWorktree.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Views/LockWorktree.axaml.cs b/src/Views/LockWorktree.axaml.cs new file mode 100644 index 00000000..44bf363c --- /dev/null +++ b/src/Views/LockWorktree.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class LockWorktree : UserControl + { + public LockWorktree() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/PruneWorktrees.axaml b/src/Views/PruneWorktrees.axaml new file mode 100644 index 00000000..a3a0f770 --- /dev/null +++ b/src/Views/PruneWorktrees.axaml @@ -0,0 +1,15 @@ + + + + + + diff --git a/src/Views/PruneWorktrees.axaml.cs b/src/Views/PruneWorktrees.axaml.cs new file mode 100644 index 00000000..fbb5cf3c --- /dev/null +++ b/src/Views/PruneWorktrees.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class PruneWorktrees : UserControl + { + public PruneWorktrees() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/RemoveWorktree.axaml b/src/Views/RemoveWorktree.axaml new file mode 100644 index 00000000..08f37371 --- /dev/null +++ b/src/Views/RemoveWorktree.axaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Views/RemoveWorktree.axaml.cs b/src/Views/RemoveWorktree.axaml.cs new file mode 100644 index 00000000..24b075af --- /dev/null +++ b/src/Views/RemoveWorktree.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class RemoveWorktree : UserControl + { + public RemoveWorktree() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Repository.axaml b/src/Views/Repository.axaml index 15d9f258..722e3ffc 100644 --- a/src/Views/Repository.axaml +++ b/src/Views/Repository.axaml @@ -119,7 +119,7 @@ - + @@ -463,7 +463,7 @@ Command="{Binding UpdateSubmodules}" IsVisible="{Binding Submodules, Converter={x:Static c:ListConverters.IsNotNullOrEmpty}}" ToolTip.Tip="{DynamicResource Text.Repository.Submodules.Update}"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Repository.axaml.cs b/src/Views/Repository.axaml.cs index 49befbb8..4bf30822 100644 --- a/src/Views/Repository.axaml.cs +++ b/src/Views/Repository.axaml.cs @@ -315,6 +315,40 @@ namespace SourceGit.Views e.Handled = true; } + private void OnDoubleTappedSubmodule(object sender, TappedEventArgs e) + { + if (sender is DataGrid datagrid && datagrid.SelectedItem != null && DataContext is ViewModels.Repository repo) + { + var submodule = datagrid.SelectedItem as string; + (DataContext as ViewModels.Repository).OpenSubmodule(submodule); + } + + e.Handled = true; + } + + private void OnWorktreeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is DataGrid datagrid && datagrid.SelectedItem != null && DataContext is ViewModels.Repository repo) + { + var worktree = datagrid.SelectedItem as Models.Worktree; + var menu = repo.CreateContextMenuForWorktree(worktree); + datagrid.OpenContextMenu(menu); + } + + e.Handled = true; + } + + private void OnDoubleTappedWorktree(object sender, TappedEventArgs e) + { + if (sender is DataGrid datagrid && datagrid.SelectedItem != null && DataContext is ViewModels.Repository repo) + { + var worktree = datagrid.SelectedItem as Models.Worktree; + (DataContext as ViewModels.Repository).OpenWorktree(worktree); + } + + e.Handled = true; + } + private void CollectBranchesFromNode(List outs, ViewModels.BranchTreeNode node) { if (node == null || node.IsRemote) @@ -332,16 +366,5 @@ namespace SourceGit.Views outs.Add(b); } } - - private void OnDoubleTappedSubmodule(object sender, TappedEventArgs e) - { - if (sender is DataGrid datagrid && datagrid.SelectedItem != null && DataContext is ViewModels.Repository repo) - { - var submodule = datagrid.SelectedItem as string; - (DataContext as ViewModels.Repository).OpenSubmodule(submodule); - } - - e.Handled = true; - } } }