diff --git a/src/Commands/ExecuteCustomAction.cs b/src/Commands/ExecuteCustomAction.cs new file mode 100644 index 00000000..0573aed9 --- /dev/null +++ b/src/Commands/ExecuteCustomAction.cs @@ -0,0 +1,63 @@ +using System; +using System.Diagnostics; +using System.Text; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class ExecuteCustomAction + { + public static void Run(string repo, string file, string args, Action outputHandler) + { + var start = new ProcessStartInfo(); + start.FileName = file; + start.Arguments = args; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.RedirectStandardOutput = true; + start.RedirectStandardError = true; + start.StandardOutputEncoding = Encoding.UTF8; + start.StandardErrorEncoding = Encoding.UTF8; + start.WorkingDirectory = repo; + + // Force using en_US.UTF-8 locale to avoid GCM crash + if (OperatingSystem.IsLinux()) + start.Environment.Add("LANG", "en_US.UTF-8"); + + // Fix macOS `PATH` env + if (OperatingSystem.IsMacOS() && !string.IsNullOrEmpty(Native.OS.CustomPathEnv)) + start.Environment.Add("PATH", Native.OS.CustomPathEnv); + + var proc = new Process() { StartInfo = start }; + proc.OutputDataReceived += (_, e) => + { + if (e.Data != null) + outputHandler?.Invoke(e.Data); + }; + + proc.ErrorDataReceived += (_, e) => + { + if (e.Data != null) + outputHandler?.Invoke(e.Data); + }; + + try + { + proc.Start(); + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, e.Message); + }); + } + + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + proc.WaitForExit(); + proc.Close(); + } + } +} diff --git a/src/Models/CustomAction.cs b/src/Models/CustomAction.cs new file mode 100644 index 00000000..2a400b02 --- /dev/null +++ b/src/Models/CustomAction.cs @@ -0,0 +1,42 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public enum CustomActionScope + { + Repository, + Commit, + } + + public class CustomAction : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public CustomActionScope Scope + { + get => _scope; + set => SetProperty(ref _scope, value); + } + + public string Executable + { + get => _executable; + set => SetProperty(ref _executable, value); + } + + public string Arguments + { + get => _arguments; + set => SetProperty(ref _arguments, value); + } + + private string _name = string.Empty; + private CustomActionScope _scope = CustomActionScope.Repository; + private string _executable = string.Empty; + private string _arguments = string.Empty; + } +} diff --git a/src/Models/RepositorySettings.cs b/src/Models/RepositorySettings.cs index a8cc5770..77c58ee7 100644 --- a/src/Models/RepositorySettings.cs +++ b/src/Models/RepositorySettings.cs @@ -100,6 +100,12 @@ namespace SourceGit.Models set; } = new AvaloniaList(); + public AvaloniaList CustomActions + { + get; + set; + } = new AvaloniaList(); + public bool EnableAutoFetch { get; @@ -230,5 +236,22 @@ namespace SourceGit.Models if (rule != null) IssueTrackerRules.Remove(rule); } + + public CustomAction AddNewCustomAction() + { + var act = new CustomAction() + { + Name = "Unnamed Custom Action", + }; + + CustomActions.Add(act); + return act; + } + + public void RemoveCustomAction(CustomAction act) + { + if (act != null) + CustomActions.Remove(act); + } } } diff --git a/src/Resources/Icons.axaml b/src/Resources/Icons.axaml index bf664de2..6cf53d96 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -1,4 +1,5 @@ + M41 512c0-128 46-241 138-333C271 87 384 41 512 41s241 46 333 138c92 92 138 205 138 333s-46 241-138 333c-92 92-205 138-333 138s-241-46-333-138C87 753 41 640 41 512zm87 0c0 108 36 195 113 271s164 113 271 113c108 0 195-36 271-113s113-164 113-271-36-195-113-271c-77-77-164-113-271-113-108 0-195 36-271 113C164 317 128 404 128 512zm256 148V292l195 113L768 512l-195 113-195 113v-77zm148-113-61 36V440l61 36 61 36-61 36z M951 419a255 255 0 00-22-209 258 258 0 00-278-124A259 259 0 00213 178a255 255 0 00-171 124 258 258 0 0032 303 255 255 0 0022 210 258 258 0 00278 124A255 255 0 00566 1024a258 258 0 00246-179 256 256 0 00171-124 258 258 0 00-32-302zM566 957a191 191 0 01-123-44l6-3 204-118a34 34 0 0017-29v-287l86 50a3 3 0 012 2v238a192 192 0 01-192 192zM154 781a191 191 0 01-23-129l6 4 204 118a33 33 0 0033 0l249-144v99a3 3 0 01-1 3L416 851a192 192 0 01-262-70zM100 337a191 191 0 01101-84V495a33 33 0 0017 29l248 143-86 50a3 3 0 01-3 0l-206-119A192 192 0 01100 336zm708 164-249-145L645 307a3 3 0 013 0l206 119a192 192 0 01-29 346v-242a34 34 0 00-17-28zm86-129-6-4-204-119a33 33 0 00-33 0L401 394V294a3 3 0 011-3l206-119a192 192 0 01285 199zm-539 176-86-50a3 3 0 01-2-2V259a192 192 0 01315-147l-6 3-204 118a34 34 0 00-17 29zm47-101 111-64 111 64v128l-111 64-111-64z M296 392h64v64h-64zM296 582v160h128V582h-64v-62h-64v62zm80 48v64h-32v-64h32zM360 328h64v64h-64zM296 264h64v64h-64zM360 456h64v64h-64zM360 200h64v64h-64zM855 289 639 73c-6-6-14-9-23-9H192c-18 0-32 14-32 32v832c0 18 14 32 32 32h640c18 0 32-14 32-32V311c0-9-3-17-9-23zM790 326H602V138L790 326zm2 562H232V136h64v64h64v-64h174v216c0 23 19 42 42 42h216v494z M71 1024V0h661L953 219V1024H71zm808-731-220-219H145V951h735V293zM439 512h-220V219h220V512zm-74-219H292v146h74v-146zm0 512h74v73h-220v-73H292v-146H218V585h147v219zm294-366h74V512H512v-73h74v-146H512V219h147v219zm74 439H512V585h220v293zm-74-219h-74v146h74v-146z diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index c6f88ed1..2e96e391 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -108,6 +108,7 @@ Compare with Worktree Copy Info Copy SHA + Custom Action Interactive Rebase ${0}$ to Here Rebase ${0}$ to Here Reset ${0}$ to Here @@ -139,6 +140,14 @@ COMMIT TEMPLATE Template Name: Template Content: + CUSTOM ACTION + Arguments: + ${REPO} - Repository's path; ${SHA} - Selected commit's SHA + Executable File: + Name: + Scope: + Commit + Repository Email Address Email address GIT @@ -252,6 +261,8 @@ Target: Edit Selected Group Edit Selected Repository + Run Custom Action + Action Name: Fast-Forward (without checkout) Fetch Fetch all remotes @@ -516,6 +527,8 @@ Clear all Configure this repository CONTINUE + Custom Actions + No Custom Actions Enable '--reflog' Option Open In File Browser Search Branches/Tags/Submodules diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index a274c06c..469a88bb 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -111,6 +111,7 @@ 与本地工作树比较 复制简要信息 复制提交指纹 + 自定义操作 交互式变基(rebase -i) ${0}$ 到此处 变基(rebase) ${0}$ 到此处 重置(reset) ${0}$ 到此处 @@ -142,6 +143,14 @@ 提交信息模板 模板名 : 模板内容 : + 自定义操作 + 命令行参数 : + 请使用${REPO}代替仓库路径,${SHA}代替提交哈希 + 可执行文件路径 : + 名称 : + 作用目标 : + 选中的提交 + 仓库 电子邮箱 邮箱地址 GIT配置 @@ -255,6 +264,8 @@ 目标 : 编辑分组 编辑仓库 + 执行自定义操作 + 自定义操作 : 快进(fast-forward,无需checkout) 拉取(fetch) 拉取所有的远程仓库 @@ -519,6 +530,8 @@ 清空过滤规则 配置本仓库 下一步 + 自定义操作 + 自定义操作未设置 启用 --reflog 选项 在文件浏览器中打开 快速查找分支/标签/子模块 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index ce62c299..4526cce5 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -111,6 +111,7 @@ 與本機工作區比較 複製摘要資訊 複製提交編號 + 自訂動作 互動式重定基底 (rebase -i) ${0}$ 到此處 重定基底 (rebase) ${0}$ 到此處 重設 (reset) ${0}$ 到此處 @@ -142,6 +143,14 @@ 提交訊息範本 範本名稱: 範本內容: + 自訂動作 + 指令行參數: + 使用${REPO}代表儲存庫的路徑,${SHA}代表所選的提交編號 + 可執行檔案路徑: + 名稱 : + 執行範圍: + 選取的提交 + 存放庫 電子郵件 電子郵件地址 Git 設定 @@ -255,6 +264,8 @@ 目標: 編輯群組 編輯存放庫 + 執行自訂動作 + 自訂動作: 快進 (fast-forward,無需 checkout) 提取 (fetch) 提取所有的遠端存放庫 @@ -519,6 +530,8 @@ 清空篩選規則 設定本存放庫 下一步 + 自訂動作 + 沒有自訂的動作 啟用 [--reflog] 選項 在檔案瀏覽器中開啟 快速搜尋分支/標籤/子模組 diff --git a/src/ViewModels/ExecuteCustomAction.cs b/src/ViewModels/ExecuteCustomAction.cs new file mode 100644 index 00000000..e8893f5a --- /dev/null +++ b/src/ViewModels/ExecuteCustomAction.cs @@ -0,0 +1,40 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class ExecuteCustomAction : Popup + { + public Models.CustomAction CustomAction + { + get; + private set; + } + + public ExecuteCustomAction(Repository repo, Models.CustomAction action, string sha) + { + _repo = repo; + _args = action.Arguments.Replace("${REPO}", _repo.FullPath); + if (!string.IsNullOrEmpty(sha)) + _args = _args.Replace("${SHA}", sha); + + CustomAction = action; + View = new Views.ExecuteCustomAction() { DataContext = this }; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Run custom action ..."; + + return Task.Run(() => + { + Commands.ExecuteCustomAction.Run(_repo.FullPath, CustomAction.Executable, _args, SetProgressDescription); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + private string _args = string.Empty; + } +} diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 179f6d97..fc973948 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -605,6 +605,39 @@ namespace SourceGit.ViewModels menu.Items.Add(archive); menu.Items.Add(new MenuItem() { Header = "-" }); + var actions = new List(); + foreach (var action in _repo.Settings.CustomActions) + { + if (action.Scope == Models.CustomActionScope.Commit) + actions.Add(action); + } + if (actions.Count > 0) + { + var custom = new MenuItem(); + custom.Header = App.Text("CommitCM.CustomAction"); + custom.Icon = App.CreateMenuIcon("Icons.Action"); + + foreach (var action in actions) + { + var dup = action; + var item = new MenuItem(); + item.Icon = App.CreateMenuIcon("Icons.Action"); + item.Header = dup.Name; + item.Click += (_, e) => + { + if (PopupHost.CanCreatePopup()) + PopupHost.ShowAndStartPopup(new ExecuteCustomAction(_repo, action, commit.SHA)); + + e.Handled = true; + }; + + custom.Items.Add(item); + } + + menu.Items.Add(custom); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + var copySHA = new MenuItem(); copySHA.Header = App.Text("CommitCM.CopySHA"); copySHA.Icon = App.CreateMenuIcon("Icons.Copy"); diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index c64967d1..ae24d5ef 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -1287,6 +1287,45 @@ namespace SourceGit.ViewModels return menu; } + public ContextMenu CreateContextMenuForCustomAction() + { + var actions = new List(); + foreach (var action in _settings.CustomActions) + { + if (action.Scope == Models.CustomActionScope.Repository) + actions.Add(action); + } + + var menu = new ContextMenu(); + menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + + if (actions.Count > 0) + { + foreach (var action in actions) + { + var dup = action; + var item = new MenuItem(); + item.Icon = App.CreateMenuIcon("Icons.Action"); + item.Header = dup.Name; + item.Click += (_, e) => + { + if (PopupHost.CanCreatePopup()) + PopupHost.ShowAndStartPopup(new ExecuteCustomAction(this, action, null)); + + e.Handled = true; + }; + + menu.Items.Add(item); + } + } + else + { + menu.Items.Add(new MenuItem() { Header = App.Text("Repository.CustomActions.Empty") }); + } + + return menu; + } + public ContextMenu CreateContextMenuForLocalBranch(Models.Branch branch) { var menu = new ContextMenu(); diff --git a/src/ViewModels/RepositoryConfigure.cs b/src/ViewModels/RepositoryConfigure.cs index 94efc09d..620db074 100644 --- a/src/ViewModels/RepositoryConfigure.cs +++ b/src/ViewModels/RepositoryConfigure.cs @@ -127,6 +127,17 @@ namespace SourceGit.ViewModels set => _repo.Settings.PreferedOpenAIService = value; } + public AvaloniaList CustomActions + { + get => _repo.Settings.CustomActions; + } + + public Models.CustomAction SelectedCustomAction + { + get => _selectedCustomAction; + set => SetProperty(ref _selectedCustomAction, value); + } + public RepositoryConfigure(Repository repo) { _repo = repo; @@ -233,11 +244,21 @@ namespace SourceGit.ViewModels public void RemoveSelectedIssueTracker() { - if (_selectedIssueTrackerRule != null) - _repo.Settings.RemoveIssueTracker(_selectedIssueTrackerRule); + _repo.Settings.RemoveIssueTracker(_selectedIssueTrackerRule); SelectedIssueTrackerRule = null; } + public void AddNewCustomAction() + { + SelectedCustomAction = _repo.Settings.AddNewCustomAction(); + } + + public void RemoveSelectedCustomAction() + { + _repo.Settings.RemoveCustomAction(_selectedCustomAction); + SelectedCustomAction = null; + } + public void Save() { SetIfChanged("user.name", UserName, ""); @@ -271,5 +292,6 @@ namespace SourceGit.ViewModels private string _httpProxy; private Models.CommitTemplate _selectedCommitTemplate = null; private Models.IssueTrackerRule _selectedIssueTrackerRule = null; + private Models.CustomAction _selectedCustomAction = null; } } diff --git a/src/Views/ExecuteCustomAction.axaml b/src/Views/ExecuteCustomAction.axaml new file mode 100644 index 00000000..9ee2b55d --- /dev/null +++ b/src/Views/ExecuteCustomAction.axaml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/src/Views/ExecuteCustomAction.axaml.cs b/src/Views/ExecuteCustomAction.axaml.cs new file mode 100644 index 00000000..e4f9cecf --- /dev/null +++ b/src/Views/ExecuteCustomAction.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class ExecuteCustomAction : UserControl + { + public ExecuteCustomAction() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/RepositoryConfigure.axaml b/src/Views/RepositoryConfigure.axaml index 58dbebab..f1deca3a 100644 --- a/src/Views/RepositoryConfigure.axaml +++ b/src/Views/RepositoryConfigure.axaml @@ -5,6 +5,7 @@ xmlns:m="using:SourceGit.Models" xmlns:vm="using:SourceGit.ViewModels" xmlns:v="using:SourceGit.Views" + xmlns:ac="using:Avalonia.Controls.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.RepositoryConfigure" x:DataType="vm:RepositoryConfigure" @@ -338,6 +339,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/RepositoryConfigure.axaml.cs b/src/Views/RepositoryConfigure.axaml.cs index 21f6ad23..3faba5ee 100644 --- a/src/Views/RepositoryConfigure.axaml.cs +++ b/src/Views/RepositoryConfigure.axaml.cs @@ -1,4 +1,6 @@ using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; namespace SourceGit.Views { @@ -14,5 +16,20 @@ namespace SourceGit.Views (DataContext as ViewModels.RepositoryConfigure)?.Save(); base.OnClosing(e); } + + private async void SelectExecutableForCustomAction(object sender, RoutedEventArgs e) + { + var options = new FilePickerOpenOptions() + { + FileTypeFilter = [new FilePickerFileType("Executable file(script)") { Patterns = ["*.*"] }], + AllowMultiple = false, + }; + + var selected = await StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1 && sender is Button { DataContext: Models.CustomAction action }) + action.Executable = selected[0].Path.LocalPath; + + e.Handled = true; + } } } diff --git a/src/Views/RepositoryToolbar.axaml b/src/Views/RepositoryToolbar.axaml index b76cfd63..c1eec786 100644 --- a/src/Views/RepositoryToolbar.axaml +++ b/src/Views/RepositoryToolbar.axaml @@ -96,6 +96,10 @@ + + diff --git a/src/Views/RepositoryToolbar.axaml.cs b/src/Views/RepositoryToolbar.axaml.cs index 27ac43cd..55132620 100644 --- a/src/Views/RepositoryToolbar.axaml.cs +++ b/src/Views/RepositoryToolbar.axaml.cs @@ -91,6 +91,17 @@ namespace SourceGit.Views e.Handled = true; } + + private void OpenCustomActionMenu(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Repository repo) + { + var menu = repo.CreateContextMenuForCustomAction(); + (sender as Control)?.OpenContextMenu(menu); + } + + e.Handled = true; + } } }