diff --git a/src/Models/ExternalTerminal.cs b/src/Models/ExternalTerminal.cs new file mode 100644 index 00000000..7f5dd261 --- /dev/null +++ b/src/Models/ExternalTerminal.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace SourceGit.Models +{ + public class ExternalTerminal + { + public string Name { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + public string Executable { get; set; } = string.Empty; + public string OpenCmdArgs { get; set; } = string.Empty; + + public void Open(string repo) + { + Process.Start(new ProcessStartInfo() + { + WorkingDirectory = repo, + FileName = Executable, + Arguments = string.Format(OpenCmdArgs, repo), + UseShellExecute = false, + }); + } + } + + public class ExternalTerminalFinder + { + public List Terminals + { + get; + private set; + } = new List(); + + public void WindowsGitBash(Func platform_finder) + { + TryAdd("Git Bash", "git-bash.png", "bash", "\"{0}\"", platform_finder); + } + + public void Gnome(Func platform_finder) + { + TryAdd("gnome-terminal", "gnome.png", "/usr/bin/gnome-terminal", "--working-directory=\"{0}\"", platform_finder); + } + + public void Konsole(Func platform_finder) + { + TryAdd("gnome-terminal", "gnome.png", "/usr/bin/konsole", "--workdir \"{0}\"", platform_finder); + } + + public void osaScript(Func platform_finder) + { + TryAdd("AppleScript", "osascript.png", "/usr/bin/osascript", + """ + on run argv + tell application "Terminal" + do script "cd '{0}'" + activate + end tell + end run + """, + platform_finder); + } + + public void PowerShell(Func platform_finder) + { + TryAdd("PowerShell", "pwsh.png", "pwsh", "-WorkingDirectory \"{0}\"", platform_finder); + } + + public void WindowsTerminal(Func platform_finder) + { + TryAdd("Windows Terminal", "wt.png", "wt", "-d \"{0}\"", platform_finder); + } + + public void Xfce4(Func platform_finder) + { + TryAdd("gnome-terminal", "xfce4.png", "/usr/bin/xfce4-terminal", "--working-directory=\"{0}\"", platform_finder); + } + + private void TryAdd(string name, string icon, string cmd, string args, Func finder) + { + var path = Environment.GetEnvironmentVariable(cmd); + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + path = finder(); + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + return; + } + + Terminals.Add(new ExternalTerminal + { + Name = name, + Icon = icon, + OpenCmdArgs = args, + Executable = path, + }); + } + } +} diff --git a/src/Native/Linux.cs b/src/Native/Linux.cs index 56d42306..6adb64a4 100644 --- a/src/Native/Linux.cs +++ b/src/Native/Linux.cs @@ -31,6 +31,15 @@ namespace SourceGit.Native return string.Empty; } + public List FindExternalTerminals() + { + var finder = new Models.ExternalTerminalFinder(); + finder.Gnome(() => "/usr/bin/gnome-terminal"); + finder.Konsole(() => "/usr/bin/konsole"); + finder.Xfce4(() => "/usr/bin/xfce4-terminal"); + return finder.Terminals; + } + public List FindExternalEditors() { var finder = new Models.ExternalEditorFinder(); diff --git a/src/Native/MacOS.cs b/src/Native/MacOS.cs index ac864986..d94797dc 100644 --- a/src/Native/MacOS.cs +++ b/src/Native/MacOS.cs @@ -28,6 +28,13 @@ namespace SourceGit.Native return string.Empty; } + public List FindExternalTerminals() + { + var finder = new Models.ExternalTerminalFinder(); + finder.osaScript(() => "/usr/bin/osascript"); + return finder.Terminals; + } + public List FindExternalEditors() { var finder = new Models.ExternalEditorFinder(); diff --git a/src/Native/OS.cs b/src/Native/OS.cs index 61a33ed6..44f07bef 100644 --- a/src/Native/OS.cs +++ b/src/Native/OS.cs @@ -12,6 +12,8 @@ namespace SourceGit.Native void SetupApp(AppBuilder builder); string FindGitExecutable(); + + List FindExternalTerminals(); List FindExternalEditors(); void OpenTerminal(string workdir); @@ -21,6 +23,7 @@ namespace SourceGit.Native } public static string GitExecutable { get; set; } = string.Empty; + public static List ExternalTerminals { get; set; } = new List(); public static List ExternalEditors { get; set; } = new List(); static OS() @@ -42,6 +45,7 @@ namespace SourceGit.Native throw new Exception("Platform unsupported!!!"); } + ExternalTerminals = _backend.FindExternalTerminals(); ExternalEditors = _backend.FindExternalEditors(); } diff --git a/src/Native/Windows.cs b/src/Native/Windows.cs index 78fef704..0679a257 100644 --- a/src/Native/Windows.cs +++ b/src/Native/Windows.cs @@ -114,6 +114,15 @@ namespace SourceGit.Native return null; } + public List FindExternalTerminals() + { + var finder = new Models.ExternalTerminalFinder(); + finder.WindowsGitBash(() => FindExternalTerminal("bash")); + finder.PowerShell(() => FindExternalTerminal("pwsh")); + finder.WindowsTerminal(() => FindExternalTerminal("wt")); + return finder.Terminals; + } + public List FindExternalEditors() { var finder = new Models.ExternalEditorFinder(); @@ -130,6 +139,26 @@ namespace SourceGit.Native info.CreateNoWindow = true; Process.Start(info); } + + /// + /// Find the external terminal full path via the command name (e.g. "bash"). + /// + /// + /// + public string FindExternalTerminal(string cmd) + { + using var process = Process.Start(new ProcessStartInfo( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32", "where.exe"), + cmd) + { + RedirectStandardOutput = true, + CreateNoWindow = true, + UseShellExecute = false, + })!; + var fullPath = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + return fullPath; + } public void OpenTerminal(string workdir) { diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 61f034ff..05acadd9 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Text.Json.Serialization; using System.Threading.Tasks; +using System.Windows.Input; using Avalonia.Collections; using Avalonia.Controls; @@ -12,6 +14,9 @@ using Avalonia.Platform; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +using SourceGit.Commands; namespace SourceGit.ViewModels { @@ -89,6 +94,16 @@ namespace SourceGit.ViewModels get => _selectedView; set => SetProperty(ref _selectedView, value); } + + public AvaloniaList ExternalTerminals + { + get; + } = new AvaloniaList(); + + public AvaloniaList ExternalEditors + { + get; + } = new AvaloniaList(); [JsonIgnore] public List Remotes @@ -244,6 +259,9 @@ namespace SourceGit.ViewModels Task.Run(RefreshWorkingCopyChanges); Task.Run(RefreshStashes); Task.Run(RefreshGitFlow); + + RefreshExternalTerminals(); + RefreshExternalEditors(); } public void Close() @@ -287,36 +305,77 @@ namespace SourceGit.ViewModels Native.OS.OpenTerminal(_fullpath); } - public ContextMenu CreateContextMenuForExternalEditors() + public void OpenWithExternalTool() + { + Native.OS.OpenWithDefaultEditor(_fullpath); + } + + private void RefreshExternalTerminals() + { + ExternalTerminals.Clear(); + var terminals = CreateContextMenuForExternalTerminals(); + foreach (var terminal in terminals) + { + ExternalTerminals.Add(terminal); + } + } + + private void RefreshExternalEditors() + { + ExternalEditors.Clear(); + var editors = CreateContextMenuForExternalEditors(); + foreach (var editor in editors) + { + ExternalEditors.Add(editor); + } + } + + public ImmutableArray CreateContextMenuForExternalTerminals() + { + var terminals = Native.OS.ExternalTerminals; + if (terminals.Count == 0) + { + App.RaiseException(_fullpath, "No available external terminals found!"); + return [new ExternalMenuItem("No terminal found") + { + IsEnabled = false, + }]; + } + + var items = new List(terminals.Count); + foreach (var terminal in terminals) + { + var dupTerminal = terminal; + var icon = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/ExternalTerminalIcons/{dupTerminal.Icon}", UriKind.RelativeOrAbsolute)); + var item = new ExternalMenuItem(App.Text("Repository.OpenIn", dupTerminal.Name), new Bitmap(icon), () => dupTerminal.Open(_fullpath)); + items.Add(item); + } + + return [..items]; + } + + public ImmutableArray CreateContextMenuForExternalEditors() { var editors = Native.OS.ExternalEditors; if (editors.Count == 0) { App.RaiseException(_fullpath, "No available external editors found!"); - return null; + return [new ExternalMenuItem("No editor found") + { + IsEnabled = false, + }]; } - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; - RenderOptions.SetBitmapInterpolationMode(menu, BitmapInterpolationMode.HighQuality); - + var items = new List(editors.Count); foreach (var editor in editors) { var dupEditor = editor; var icon = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/ExternalToolIcons/{dupEditor.Icon}", UriKind.RelativeOrAbsolute)); - var item = new MenuItem(); - item.Header = App.Text("Repository.OpenIn", dupEditor.Name); - item.Icon = new Image { Width = 16, Height = 16, Source = new Bitmap(icon) }; - item.Click += (o, e) => - { - dupEditor.Open(_fullpath); - e.Handled = true; - }; - - menu.Items.Add(item); + var item = new ExternalMenuItem(App.Text("Repository.OpenIn", dupEditor.Name), new Bitmap(icon), () => dupEditor.Open(_fullpath)); + items.Add(item); } - return menu; + return [..items]; } public void Fetch() @@ -1356,4 +1415,44 @@ namespace SourceGit.ViewModels private InProgressContext _inProgressContext = null; private bool _hasUnsolvedConflicts = false; } + + /// + /// A menu item for external tools. + /// + public readonly record struct ExternalMenuItem + { + public ExternalMenuItem(string header) + { + Header = header; + Icon = null; + Command = null; + } + + public ExternalMenuItem(string header, Bitmap icon, Action click) + { + Header = header; + Icon = icon; + Command = new RelayCommand(click); + } + + /// + /// The external tool name. + /// + public string Header { get; } + + /// + /// The resource key of the icon. + /// + public Bitmap? Icon { get; init; } + + /// + /// The command when the user click the menu item. + /// + public ICommand Command { get; } + + /// + /// if the menu item is enabled; otherwise, . + /// + public bool IsEnabled { get; init; } + } } diff --git a/src/Views/Repository.axaml b/src/Views/Repository.axaml index 1baccbe0..2978bf6c 100644 --- a/src/Views/Repository.axaml +++ b/src/Views/Repository.axaml @@ -12,12 +12,8 @@ - - - - @@ -36,7 +32,7 @@ - diff --git a/src/Views/Repository.axaml.cs b/src/Views/Repository.axaml.cs index 858f5f3d..a6d1585a 100644 --- a/src/Views/Repository.axaml.cs +++ b/src/Views/Repository.axaml.cs @@ -61,19 +61,6 @@ namespace SourceGit.Views InitializeComponent(); } - private void OnOpenWithExternalEditor(object sender, RoutedEventArgs e) - { - if (sender is Button button && DataContext is ViewModels.Repository repo) - { - var menu = repo.CreateContextMenuForExternalEditors(); - if (menu != null) - { - menu.Open(button); - e.Handled = true; - } - } - } - private void OnLocalBranchTreeLostFocus(object sender, RoutedEventArgs e) { if (sender is TreeView tree)