From e614c889e34540b870e564238e12fea772cdd702 Mon Sep 17 00:00:00 2001 From: leo Date: Mon, 17 Aug 2026 16:06:33 +0800 Subject: [PATCH 01/42] ux: use `BranchOrTagNameTextBox` in `Push to a NEW Branch` dialog Signed-off-by: leo --- src/Views/PushToNewBranch.axaml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Views/PushToNewBranch.axaml b/src/Views/PushToNewBranch.axaml index 0c4eef922..6c10b65de 100644 --- a/src/Views/PushToNewBranch.axaml +++ b/src/Views/PushToNewBranch.axaml @@ -39,17 +39,17 @@ - + - + diff --git a/src/Views/LauncherPage.axaml.cs b/src/Views/LauncherPage.axaml.cs index 7a9506412..88270a5ad 100644 --- a/src/Views/LauncherPage.axaml.cs +++ b/src/Views/LauncherPage.axaml.cs @@ -59,6 +59,14 @@ private void OnPopupCancel(object _, RoutedEventArgs e) e.Handled = true; } + private void OnPopupTerminate(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.LauncherPage page) + page.TerminatePopup(); + + e.Handled = true; + } + private void OnMaskClicked(object sender, PointerPressedEventArgs e) { OnPopupCancel(sender, e); From ea8f7ddba41c58ffba0c71ef6129f9f691f56f0e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 18 Aug 2026 07:49:47 +0000 Subject: [PATCH 08/42] doc: Update translation status and sort locale files --- TRANSLATION.md | 51 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/TRANSLATION.md b/TRANSLATION.md index cfeaeae25..1f529bc2f 100644 --- a/TRANSLATION.md +++ b/TRANSLATION.md @@ -6,7 +6,7 @@ This document shows the translation status of each locale file in the repository ### ![en_US](https://img.shields.io/badge/en__US-%E2%88%9A-brightgreen) -### ![de__DE](https://img.shields.io/badge/de__DE-99.51%25-yellow) +### ![de__DE](https://img.shields.io/badge/de__DE-99.42%25-yellow)
Missing keys in de_DE.axaml @@ -16,10 +16,11 @@ This document shows the translation status of each locale file in the repository - Text.File.HexViewer - Text.File.HexViewer.GotoAddress - Text.Preferences.AI.ReasoningEffortLevel +- Text.Terminate
-### ![el__GR](https://img.shields.io/badge/el__GR-99.03%25-yellow) +### ![el__GR](https://img.shields.io/badge/el__GR-98.93%25-yellow)
Missing keys in el_GR.axaml @@ -33,13 +34,21 @@ This document shows the translation status of each locale file in the repository - Text.Repository.FilterCommits.Collapse - Text.Repository.FilterCommits.Expand - Text.Repository.FilterCommits.Summary +- Text.Terminate - Text.WorkingCopy.FilterChanges
-### ![es__ES](https://img.shields.io/badge/es__ES-%E2%88%9A-brightgreen) +### ![es__ES](https://img.shields.io/badge/es__ES-99.90%25-yellow) -### ![fr__FR](https://img.shields.io/badge/fr__FR-95.13%25-yellow) +
+Missing keys in es_ES.axaml + +- Text.Terminate + +
+ +### ![fr__FR](https://img.shields.io/badge/fr__FR-95.03%25-yellow)
Missing keys in fr_FR.axaml @@ -91,13 +100,14 @@ This document shows the translation status of each locale file in the repository - Text.Repository.FilterCommits.Summary - Text.TagCM.Checkout - Text.TagCM.Merge +- Text.Terminate - Text.UpdateSubmodules.Recursive - Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder - Text.WorkingCopy.FilterChanges
-### ![he__IL](https://img.shields.io/badge/he__IL-95.13%25-yellow) +### ![he__IL](https://img.shields.io/badge/he__IL-95.03%25-yellow)
Missing keys in he_IL.axaml @@ -149,13 +159,14 @@ This document shows the translation status of each locale file in the repository - Text.Repository.FilterCommits.Summary - Text.TagCM.Checkout - Text.TagCM.Merge +- Text.Terminate - Text.UpdateSubmodules.Recursive - Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder - Text.WorkingCopy.FilterChanges
-### ![id__ID](https://img.shields.io/badge/id__ID-99.42%25-yellow) +### ![id__ID](https://img.shields.io/badge/id__ID-99.32%25-yellow)
Missing keys in id_ID.axaml @@ -165,11 +176,12 @@ This document shows the translation status of each locale file in the repository - Text.File.HexViewer - Text.File.HexViewer.GotoAddress - Text.Preferences.AI.ReasoningEffortLevel +- Text.Terminate - Text.WorkingCopy.FilterChanges
-### ![it__IT](https://img.shields.io/badge/it__IT-88.69%25-yellow) +### ![it__IT](https://img.shields.io/badge/it__IT-88.61%25-yellow)
Missing keys in it_IT.axaml @@ -284,6 +296,7 @@ This document shows the translation status of each locale file in the repository - Text.SubmoduleRevisionCompare.OpenDetails - Text.TagCM.Checkout - Text.TagCM.Merge +- Text.Terminate - Text.UpdateSubmodules.Recursive - Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder - Text.WorkingCopy.FilterChanges @@ -293,7 +306,7 @@ This document shows the translation status of each locale file in the repository
-### ![ja__JP](https://img.shields.io/badge/ja__JP-99.03%25-yellow) +### ![ja__JP](https://img.shields.io/badge/ja__JP-98.93%25-yellow)
Missing keys in ja_JP.axaml @@ -307,11 +320,12 @@ This document shows the translation status of each locale file in the repository - Text.Repository.FilterCommits.Collapse - Text.Repository.FilterCommits.Expand - Text.Repository.FilterCommits.Summary +- Text.Terminate - Text.WorkingCopy.FilterChanges
-### ![ko__KR](https://img.shields.io/badge/ko__KR-96.59%25-yellow) +### ![ko__KR](https://img.shields.io/badge/ko__KR-96.49%25-yellow)
Missing keys in ko_KR.axaml @@ -348,13 +362,14 @@ This document shows the translation status of each locale file in the repository - Text.Repository.FilterCommits.Summary - Text.TagCM.Checkout - Text.TagCM.Merge +- Text.Terminate - Text.UpdateSubmodules.Recursive - Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder - Text.WorkingCopy.FilterChanges
-### ![pt__BR](https://img.shields.io/badge/pt__BR-62.57%25-red) +### ![pt__BR](https://img.shields.io/badge/pt__BR-62.51%25-red)
Missing keys in pt_BR.axaml @@ -716,6 +731,7 @@ This document shows the translation status of each locale file in the repository - Text.TagCM.CustomAction - Text.TagCM.DeleteMultiple - Text.TagCM.Merge +- Text.Terminate - Text.UpdateSubmodules.Recursive - Text.UpdateSubmodules.UpdateToRemoteTrackingBranch - Text.ViewLogs @@ -746,9 +762,16 @@ This document shows the translation status of each locale file in the repository
-### ![ru__RU](https://img.shields.io/badge/ru__RU-%E2%88%9A-brightgreen) +### ![ru__RU](https://img.shields.io/badge/ru__RU-99.90%25-yellow) + +
+Missing keys in ru_RU.axaml + +- Text.Terminate + +
-### ![ta__IN](https://img.shields.io/badge/ta__IN-64.23%25-red) +### ![ta__IN](https://img.shields.io/badge/ta__IN-64.17%25-red)
Missing keys in ta_IN.axaml @@ -1095,6 +1118,7 @@ This document shows the translation status of each locale file in the repository - Text.TagCM.CustomAction - Text.TagCM.DeleteMultiple - Text.TagCM.Merge +- Text.Terminate - Text.UpdateSubmodules.Target - Text.UpdateSubmodules.Recursive - Text.UpdateSubmodules.UpdateToRemoteTrackingBranch @@ -1123,7 +1147,7 @@ This document shows the translation status of each locale file in the repository
-### ![uk__UA](https://img.shields.io/badge/uk__UA-65.01%25-red) +### ![uk__UA](https://img.shields.io/badge/uk__UA-64.95%25-red)
Missing keys in uk_UA.axaml @@ -1466,6 +1490,7 @@ This document shows the translation status of each locale file in the repository - Text.TagCM.CustomAction - Text.TagCM.DeleteMultiple - Text.TagCM.Merge +- Text.Terminate - Text.UpdateSubmodules.Recursive - Text.UpdateSubmodules.UpdateToRemoteTrackingBranch - Text.ViewLogs From 36c0e216965bd41216d68c29a17051e456c282aa Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 19 Aug 2026 10:37:34 +0800 Subject: [PATCH 09/42] fix: `IsLFSEnabled` does not support submodule/worktree (#2635) Signed-off-by: leo --- src/ViewModels/Repository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 526a9dbd2..e72b6d2d4 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -570,7 +570,7 @@ public Models.GitFlowBranchType GetGitFlowType(Models.Branch b) public bool IsLFSEnabled() { - var path = Path.Combine(FullPath, ".git", "hooks", "pre-push"); + var path = Path.Combine(GitDir, "hooks", "pre-push"); if (!File.Exists(path)) return false; From 415564bbbd14fd742fd99befc5bc7daaac17c30a Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 19 Aug 2026 11:21:24 +0800 Subject: [PATCH 10/42] enhance: do not wait for the cancel operation finished (the UI will auto wait for `Sure` complete) Signed-off-by: leo --- src/ViewModels/Clone.cs | 3 ++- src/ViewModels/Fetch.cs | 3 ++- src/ViewModels/Pull.cs | 3 ++- src/ViewModels/Push.cs | 3 ++- src/ViewModels/PushRevision.cs | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/ViewModels/Clone.cs b/src/ViewModels/Clone.cs index 27472f0c4..7e4c8ae84 100644 --- a/src/ViewModels/Clone.cs +++ b/src/ViewModels/Clone.cs @@ -189,7 +189,8 @@ public override async Task Sure() public override void Terminate() { - _cancellation?.Cancel(); + // Just fire cancel event and UI will auto wait the `Sure` complete + var _ = _cancellation?.CancelAsync(); } private void CollectGroups(List outs, List collections) diff --git a/src/ViewModels/Fetch.cs b/src/ViewModels/Fetch.cs index ef4a55709..b167e73b9 100644 --- a/src/ViewModels/Fetch.cs +++ b/src/ViewModels/Fetch.cs @@ -124,7 +124,8 @@ public override async Task Sure() public override void Terminate() { - _cancellation?.Cancel(); + // Just fire cancel event and UI will auto wait the `Sure` complete + var _ = _cancellation?.CancelAsync(); } private readonly Repository _repo = null; diff --git a/src/ViewModels/Pull.cs b/src/ViewModels/Pull.cs index 528dc4990..877b86df2 100644 --- a/src/ViewModels/Pull.cs +++ b/src/ViewModels/Pull.cs @@ -183,7 +183,8 @@ public override async Task Sure() public override void Terminate() { - _cancellation?.Cancel(); + // Just fire cancel event and UI will auto wait the `Sure` complete + var _ = _cancellation?.CancelAsync(); } private void PostRemoteSelected() diff --git a/src/ViewModels/Push.cs b/src/ViewModels/Push.cs index 6c23d5630..014649b9d 100644 --- a/src/ViewModels/Push.cs +++ b/src/ViewModels/Push.cs @@ -226,7 +226,8 @@ public override async Task Sure() public override void Terminate() { - _cancellation?.Cancel(); + // Just fire cancel event and UI will auto wait the `Sure` complete + var _ = _cancellation?.CancelAsync(); } private void AutoSelectBranchByRemote() diff --git a/src/ViewModels/PushRevision.cs b/src/ViewModels/PushRevision.cs index ff54bafbe..f0d2e46bc 100644 --- a/src/ViewModels/PushRevision.cs +++ b/src/ViewModels/PushRevision.cs @@ -61,7 +61,8 @@ public override async Task Sure() public override void Terminate() { - _cancellation?.Cancel(); + // Just fire cancel event and UI will auto wait the `Sure` complete + var _ = _cancellation?.CancelAsync(); } private readonly Repository _repo; From 4fca32f110dcda6bd2d58e79551815b63ef7ede9 Mon Sep 17 00:00:00 2001 From: weiningwei Date: Thu, 20 Aug 2026 10:21:01 +0800 Subject: [PATCH 11/42] fix: split shell exec pattern for terminal file picker on Windows (#2637) Signed-off-by: weiningwei --- src/Views/Preferences.axaml.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Views/Preferences.axaml.cs b/src/Views/Preferences.axaml.cs index 99ec73a5f..773a0f3e7 100644 --- a/src/Views/Preferences.axaml.cs +++ b/src/Views/Preferences.axaml.cs @@ -341,7 +341,11 @@ private async void SelectShellOrTerminal(object _, RoutedEventArgs e) { options = new FilePickerOpenOptions() { - FileTypeFilter = [new FilePickerFileType(shell.Name) { Patterns = [shell.Exec] }], + FileTypeFilter = [new FilePickerFileType(shell.Name) + { + Patterns = shell.Exec.Split('|', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + }], AllowMultiple = false, }; } From 3b9f55f23e669f63cd8eff2d7b6dc6350a2a65f7 Mon Sep 17 00:00:00 2001 From: leo Date: Thu, 20 Aug 2026 17:20:48 +0800 Subject: [PATCH 12/42] feature: using `setsid` to support terminating clone/fetch/pull/push on Linux gracefully Signed-off-by: leo --- src/Commands/Command.cs | 53 +++++++++++++++++++--------------- src/Native/Linux.cs | 7 ++++- src/ViewModels/Clone.cs | 2 +- src/ViewModels/CommitDetail.cs | 1 - src/ViewModels/Popup.cs | 4 +-- 5 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs index d432b645c..7626dd295 100644 --- a/src/Commands/Command.cs +++ b/src/Commands/Command.cs @@ -158,8 +158,36 @@ protected async Task ReadToEndAsync() protected ProcessStartInfo CreateGitStartInfo(bool redirect) { + var useSetSid = OperatingSystem.IsLinux() && CancellationToken.CanBeCanceled; + var selfExecFile = Environment.ProcessPath; + var builder = new StringBuilder(2048); + + if (useSetSid) + builder.Append(Native.OS.GitExecutable.Quoted()).Append(' '); + + builder + .Append("--no-pager -c core.quotepath=off -c credential.helper=") + .Append(Native.OS.CredentialHelper) + .Append(' '); + + switch (Editor) + { + case EditorType.CoreEditor: + builder.Append($"""-c core.editor="\"{selfExecFile}\" --core-editor" """); + break; + case EditorType.RebaseEditor: + builder.Append($"""-c core.editor="\"{selfExecFile}\" --rebase-message-editor" -c sequence.editor="\"{selfExecFile}\" --rebase-todo-editor" -c rebase.abbreviateCommands=true """); + break; + default: + builder.Append("-c core.editor=true "); + break; + } + + builder.Append(Args); + var start = new ProcessStartInfo(); - start.FileName = Native.OS.GitExecutable; + start.FileName = useSetSid ? "setsid" : Native.OS.GitExecutable; + start.Arguments = builder.ToString(); start.UseShellExecute = false; start.CreateNoWindow = true; @@ -172,7 +200,6 @@ protected ProcessStartInfo CreateGitStartInfo(bool redirect) } // Force using this app as SSH askpass program - var selfExecFile = Environment.ProcessPath; start.Environment.Add("SSH_ASKPASS", selfExecFile); // Can not use parameter here, because it invoked by SSH with `exec` start.Environment.Add("SSH_ASKPASS_REQUIRE", "prefer"); start.Environment.Add("SOURCEGIT_LAUNCH_AS_ASKPASS", "TRUE"); @@ -190,28 +217,6 @@ protected ProcessStartInfo CreateGitStartInfo(bool redirect) start.Environment.Add("LC_ALL", "C"); } - var builder = new StringBuilder(2048); - builder - .Append("--no-pager -c core.quotepath=off -c credential.helper=") - .Append(Native.OS.CredentialHelper) - .Append(' '); - - switch (Editor) - { - case EditorType.CoreEditor: - builder.Append($"""-c core.editor="\"{selfExecFile}\" --core-editor" """); - break; - case EditorType.RebaseEditor: - builder.Append($"""-c core.editor="\"{selfExecFile}\" --rebase-message-editor" -c sequence.editor="\"{selfExecFile}\" --rebase-todo-editor" -c rebase.abbreviateCommands=true """); - break; - default: - builder.Append("-c core.editor=true "); - break; - } - - builder.Append(Args); - start.Arguments = builder.ToString(); - // Working directory if (!string.IsNullOrEmpty(WorkingDirectory)) start.WorkingDirectory = WorkingDirectory; diff --git a/src/Native/Linux.cs b/src/Native/Linux.cs index 8e59dd45f..364530f26 100644 --- a/src/Native/Linux.cs +++ b/src/Native/Linux.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; using System.Runtime.Versioning; using Avalonia; @@ -13,6 +14,9 @@ namespace SourceGit.Native [SupportedOSPlatform("linux")] internal class Linux : OS.IBackend { + [DllImport("libc", SetLastError = true)] + private static extern int kill(int pid, int sig); + public void SetupApp(AppBuilder builder) { builder.With(new X11PlatformOptions() { EnableIme = true }); @@ -140,7 +144,8 @@ public void OpenWithDefaultEditor(string file) public void TerminateProcess(Process proc) { - proc.Kill(true); + if (kill(-proc.Id, 15) != 0) + proc.Kill(true); // Fallback to force kill if the process is not terminated by SIGTERM } private string FindExecutable(string filename) diff --git a/src/ViewModels/Clone.cs b/src/ViewModels/Clone.cs index 7e4c8ae84..dc7cf4833 100644 --- a/src/ViewModels/Clone.cs +++ b/src/ViewModels/Clone.cs @@ -120,7 +120,7 @@ public override async Task Sure() .WithCancellation(token) .Use(log) .ExecAsync(); - if (!succ) + if (!succ || token.IsCancellationRequested) return false; var path = _parentFolder; diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs index 0daf09cf9..66291659a 100644 --- a/src/ViewModels/CommitDetail.cs +++ b/src/ViewModels/CommitDetail.cs @@ -514,7 +514,6 @@ private void Refresh() Task.Run(async () => { var changes = await new Commands.CompareRevisions(_repo.FullPath, _commit.FirstParentToCompare, _commit.SHA) - .WithCancellation(token) .ReadAsync() .ConfigureAwait(false); diff --git a/src/ViewModels/Popup.cs b/src/ViewModels/Popup.cs index a69f7b13e..e224c8121 100644 --- a/src/ViewModels/Popup.cs +++ b/src/ViewModels/Popup.cs @@ -12,8 +12,8 @@ public bool CanTerminate get => _canTerminate; protected set { - // Terminating a process is only supported on Windows. - if (OperatingSystem.IsWindows()) + // Terminating a process is only supported on Windows/Linux. + if (!OperatingSystem.IsMacOS()) SetProperty(ref _canTerminate, value); } } From 1fd81089a02624135d353a19fe7d889be45827aa Mon Sep 17 00:00:00 2001 From: leo Date: Thu, 20 Aug 2026 18:40:32 +0800 Subject: [PATCH 13/42] refactor: history filter icon - Show a folder icon instead of branch icon for local/remote branch folder filter - Show a warning icon if the branch/tag filter is not existed - Fix the issue that wrong filter added after renaming a local branch Signed-off-by: leo --- src/Models/HistoryFilter.cs | 10 +++- src/Models/RepositoryUIStates.cs | 2 +- src/ViewModels/Repository.cs | 34 +++++++++++ src/Views/HistoryFilterIcon.cs | 96 ++++++++++++++++++++++++++++++++ src/Views/Repository.axaml | 7 ++- 5 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 src/Views/HistoryFilterIcon.cs diff --git a/src/Models/HistoryFilter.cs b/src/Models/HistoryFilter.cs index b09f074cb..369b237d7 100644 --- a/src/Models/HistoryFilter.cs +++ b/src/Models/HistoryFilter.cs @@ -1,4 +1,5 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System.Text.Json.Serialization; +using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.Models { @@ -38,9 +39,11 @@ public FilterMode Mode set => SetProperty(ref _mode, value); } - public bool IsBranch + [JsonIgnore] + public bool IsValid { - get => Type != FilterType.Tag; + get => _isValid; + set => SetProperty(ref _isValid, value); } public HistoryFilter() @@ -56,5 +59,6 @@ public HistoryFilter(string pattern, FilterType type, FilterMode mode) private string _pattern = string.Empty; private FilterMode _mode = FilterMode.None; + private bool _isValid = true; } } diff --git a/src/Models/RepositoryUIStates.cs b/src/Models/RepositoryUIStates.cs index 723fe900b..0b35f8d61 100644 --- a/src/Models/RepositoryUIStates.cs +++ b/src/Models/RepositoryUIStates.cs @@ -376,7 +376,7 @@ public void RenameBranchFilter(string oldName, string newName) if (filter.Type == FilterType.LocalBranch && filter.Pattern.Equals(oldName, StringComparison.Ordinal)) { - filter.Pattern = $"refs/heads/{newName}"; + filter.Pattern = newName; break; } } diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index e72b6d2d4..c8452cc38 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -1169,6 +1169,8 @@ public void RefreshBranches() LocalBranchTrees = builder.Locals; RemoteBranchTrees = builder.Remotes; + ValidateHistoryFilters(true); + var localBranchesCount = 0; foreach (var b in branches) { @@ -1214,6 +1216,7 @@ public void RefreshTags() Tags = tags; VisibleTags = BuildVisibleTags(); + ValidateHistoryFilters(false); }); }, token); } @@ -1841,6 +1844,37 @@ private void ResetTagFilterMode() } } + private void ValidateHistoryFilters(bool forBranch) + { + if (_historyFilterMode == Models.FilterMode.None) + return; + + var set = new HashSet(); + + if (forBranch) + { + foreach (var b in _branches) + set.Add(b.FullName); + + foreach (var f in _uiStates.HistoryFilters) + { + if (f.Type is Models.FilterType.LocalBranch or Models.FilterType.RemoteBranch) + f.IsValid = set.Contains(f.Pattern); + } + } + else + { + foreach (var t in _tags) + set.Add(t.Name); + + foreach (var f in _uiStates.HistoryFilters) + { + if (f.Type is Models.FilterType.Tag) + f.IsValid = set.Contains(f.Pattern); + } + } + } + private BranchTreeNode FindBranchNode(List nodes, string path) { if (string.IsNullOrEmpty(path)) diff --git a/src/Views/HistoryFilterIcon.cs b/src/Views/HistoryFilterIcon.cs new file mode 100644 index 000000000..6fae16ccf --- /dev/null +++ b/src/Views/HistoryFilterIcon.cs @@ -0,0 +1,96 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class HistoryFilterIcon : ContentControl + { + public static readonly DirectProperty FilterProperty = + AvaloniaProperty.RegisterDirect( + nameof(Filter), + static o => o.Filter, + static (o, v) => o.Filter = v); + + public Models.HistoryFilter Filter + { + get => _filter; + set => SetAndRaise(FilterProperty, ref _filter, value); + } + + public static readonly DirectProperty IsFilterValidProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsFilterValid), + static o => o.IsFilterValid, + static (o, v) => o.IsFilterValid = v); + + public bool IsFilterValid + { + get => _isFilterValid; + set => SetAndRaise(IsFilterValidProperty, ref _isFilterValid, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == FilterProperty || change.Property == IsFilterValidProperty) + UpdateContent(); + } + + private void UpdateContent() + { + if (_filter == null) + { + Content = null; + return; + } + + if (_filter.Type is Models.FilterType.LocalBranch or Models.FilterType.RemoteBranch) + { + Padding = new Thickness(0); + + if (_isFilterValid) + CreateContent("Icons.Branch"); + else + CreateContent("Icons.Error", Brushes.DarkOrange); + } + else if (_filter.Type is Models.FilterType.Tag) + { + Padding = new Thickness(0); + + if (_isFilterValid) + CreateContent("Icons.Tag"); + else + CreateContent("Icons.Error", Brushes.DarkOrange); + } + else + { + Padding = new Thickness(0, 1, 0, 0); + CreateContent("Icons.Folder"); + } + } + + private void CreateContent(string iconKey, IBrush fill = null) + { + if (this.FindResource(iconKey) is not StreamGeometry geo) + return; + + var path = new Path() + { + Width = 10, + Height = 10, + Data = geo, + }; + + if (fill != null) + path.Fill = fill; + + Content = path; + } + + private Models.HistoryFilter _filter = null; + private bool _isFilterValid = true; + } +} diff --git a/src/Views/Repository.axaml b/src/Views/Repository.axaml index cda8c45e0..673b341e5 100644 --- a/src/Views/Repository.axaml +++ b/src/Views/Repository.axaml @@ -960,8 +960,11 @@ - - + Date: Fri, 21 Aug 2026 11:38:08 +0800 Subject: [PATCH 14/42] ux: new blame style Signed-off-by: leo --- src/Views/Blame.axaml.cs | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/Views/Blame.axaml.cs b/src/Views/Blame.axaml.cs index e1b5fb1ff..c06121e7d 100644 --- a/src/Views/Blame.axaml.cs +++ b/src/Views/Blame.axaml.cs @@ -36,7 +36,6 @@ public override void Render(DrawingContext context) if (view is { VisualLinesValid: true }) { var typeface = view.CreateTypeface(); - var underlinePen = new Pen(Brushes.DarkOrange); var width = Bounds.Width; var lineHeight = view.DefaultLineHeight; var pixelHeight = PixelSnapHelpers.GetPixelSize(view).Height; @@ -51,9 +50,9 @@ public override void Render(DrawingContext context) break; var info = _editor.BlameData.LineInfos[lineNumber - 1]; - var x = 0.0; + var x = 8.0; var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - view.VerticalOffset; - if (!info.IsFirstInGroup && y > lineHeight) + if (!info.IsFirstInGroup) continue; var shaLink = new FormattedText( @@ -66,7 +65,6 @@ public override void Render(DrawingContext context) var shaLinkTop = y - shaLink.Height * 0.5; var underlineY = PixelSnapHelpers.PixelAlign(y + shaLink.Height * 0.5 + 0.5, pixelHeight); context.DrawText(shaLink, new Point(x, shaLinkTop)); - context.DrawLine(underlinePen, new Point(x, underlineY), new Point(x + shaLink.Width, underlineY)); x += shaLink.Width + 8; var author = new FormattedText( @@ -88,7 +86,13 @@ public override void Render(DrawingContext context) _editor.FontSize, _editor.Foreground); var timeTop = y - time.Height * 0.5; - context.DrawText(time, new Point(width - time.Width, timeTop)); + context.DrawText(time, new Point(width - time.Width - 8, timeTop)); + + if (lineNumber > 1) + { + var lineTop = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - view.VerticalOffset; + context.DrawLine(new Pen(_editor.BorderBrush, 1), new Point(0, lineTop), new Point(Bounds.Width, lineTop)); + } } } } @@ -101,14 +105,8 @@ protected override Size MeasureOverride(Size availableSize) { var typeface = view.CreateTypeface(); var calculated = new HashSet(); - foreach (var line in view.VisualLines) + foreach (var info in _editor.BlameData.LineInfos) { - var lineNumber = line.FirstDocumentLine.LineNumber; - if (lineNumber > _editor.BlameData.LineInfos.Count) - break; - - var info = _editor.BlameData.LineInfos[lineNumber - 1]; - if (!calculated.Add(info.CommitSHA)) continue; @@ -141,8 +139,9 @@ protected override Size MeasureOverride(Size availableSize) _editor.Foreground); x += time.Width; - if (maxWidth < x) - maxWidth = x; + var required = x + 16; + if (maxWidth < required) + maxWidth = required; } } @@ -179,7 +178,7 @@ protected override void OnPointerMoved(PointerEventArgs e) _editor.FontSize, Brushes.DarkOrange); - var rect = new Rect(0, y, shaLink.Width, lineHeight); + var rect = new Rect(8, y, shaLink.Width, lineHeight); if (rect.Contains(pos)) { Cursor = Cursor.Parse("Hand"); @@ -228,7 +227,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) _editor.FontSize, Brushes.DarkOrange); - var rect = new Rect(0, y, shaLink.Width, shaLink.Height); + var rect = new Rect(8, y, shaLink.Width, shaLink.Height); if (rect.Contains(pos)) { if (DataContext is ViewModels.Blame blame) @@ -363,7 +362,7 @@ public int TabWidth _textMate = Models.TextMateHelper.CreateForEditor(this); - TextArea.LeftMargins.Add(new CommitInfoMargin(this) { Margin = new Thickness(8, 0) }); + TextArea.LeftMargins.Add(new CommitInfoMargin(this)); TextArea.LeftMargins.Add(new VerticalSeparatorMargin(this)); TextArea.LeftMargins.Add(new LineNumberMargin() { Margin = new Thickness(8, 0) }); TextArea.LeftMargins.Add(new VerticalSeparatorMargin(this)); From f9ec08b36f3dc553b6dd252997de11a872a8674c Mon Sep 17 00:00:00 2001 From: leo Date: Fri, 21 Aug 2026 14:48:40 +0800 Subject: [PATCH 15/42] enhance: always enable syntax highlighting in file preview (just like blame) Signed-off-by: leo --- src/Converters/ObjectConverters.cs | 27 --------------- src/Models/TextMateHelper.cs | 2 +- src/Views/FileHistories.axaml | 18 ++-------- src/Views/RevisionFileContentViewer.axaml | 1 - src/Views/RevisionFileContentViewer.axaml.cs | 35 +++----------------- src/Views/RevisionFiles.axaml | 20 ++--------- 6 files changed, 9 insertions(+), 94 deletions(-) delete mode 100644 src/Converters/ObjectConverters.cs diff --git a/src/Converters/ObjectConverters.cs b/src/Converters/ObjectConverters.cs deleted file mode 100644 index f7c57764d..000000000 --- a/src/Converters/ObjectConverters.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Globalization; -using Avalonia.Data.Converters; - -namespace SourceGit.Converters -{ - public static class ObjectConverters - { - public class IsTypeOfConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value == null || parameter == null) - return false; - - return value.GetType().IsAssignableTo((Type)parameter); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - return new NotImplementedException(); - } - } - - public static readonly IsTypeOfConverter IsTypeOf = new IsTypeOfConverter(); - } -} diff --git a/src/Models/TextMateHelper.cs b/src/Models/TextMateHelper.cs index 4f63c3c00..bdddeb1b5 100644 --- a/src/Models/TextMateHelper.cs +++ b/src/Models/TextMateHelper.cs @@ -41,7 +41,7 @@ public static string GetScope(string file, RegistryOptions reg) var extension = Path.GetExtension(file); if (extension == ".h") extension = ".cpp"; - else if (extension is ".resx" or ".plist" or ".manifest") + else if (extension is ".resx" or ".plist" or ".manifest" or ".sln" or ".slnx") extension = ".xml"; else if (extension == ".command") extension = ".sh"; diff --git a/src/Views/FileHistories.axaml b/src/Views/FileHistories.axaml index f499c2caf..e0b07572a 100644 --- a/src/Views/FileHistories.axaml +++ b/src/Views/FileHistories.axaml @@ -152,28 +152,14 @@ - + - - - - - - -
-### ![zh__CN](https://img.shields.io/badge/zh__CN-99.90%25-yellow) +### ![zh__CN](https://img.shields.io/badge/zh__CN-%E2%88%9A-brightgreen) -
-Missing keys in zh_CN.axaml - -- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent - -
- -### ![zh__TW](https://img.shields.io/badge/zh__TW-99.90%25-yellow) - -
-Missing keys in zh_TW.axaml - -- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent - -
\ No newline at end of file +### ![zh__TW](https://img.shields.io/badge/zh__TW-%E2%88%9A-brightgreen) \ No newline at end of file From 717a7df77e1495b8b944d5db2296332ce34a1b72 Mon Sep 17 00:00:00 2001 From: leo Date: Mon, 24 Aug 2026 10:35:13 +0800 Subject: [PATCH 20/42] refactor: rewrite the code to calculate highlighting state of a commit Signed-off-by: leo --- src/Models/CommitGraph.cs | 50 +++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index f2c821c36..4a8b726ac 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -83,6 +83,7 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna var ended = new List(); var offsetY = -halfHeight; var colorPicker = new ColorPicker(); + var defHighlighting = highlighting == CommitGraphHighlighting.All; foreach (var commit in commits) { @@ -94,7 +95,7 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna // Find first curves that links to this commit and marks others that links to this commit ended. var offsetX = 4 - halfWidth; var maxOffsetOld = unsolved.Count > 0 ? unsolved[^1].LastX : offsetX + unitWidth; - var isHighlighted = false; + var isHighlighted = defHighlighting; foreach (var l in unsolved) { if (l.Next.Equals(commit.SHA, StringComparison.Ordinal)) @@ -143,27 +144,34 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna // Calculate highlighted state if (!isHighlighted) { - if (highlighting == CommitGraphHighlighting.All) + switch (highlighting) { - isHighlighted = true; - } - - if (!isHighlighted && - (highlighting == CommitGraphHighlighting.CurrentBranchOnly || - highlighting == CommitGraphHighlighting.CurrentBranchAndSelectedCommits)) - { - isHighlighted = commit.IsMerged; - } - - if (!isHighlighted && - (highlighting == CommitGraphHighlighting.SelectedCommitsOnly || - highlighting == CommitGraphHighlighting.SelectedCommitsOnlyFirstParent || - highlighting == CommitGraphHighlighting.CurrentBranchAndSelectedCommits)) - { - isHighlighted = highlightExtraCommits.Remove(commit.SHA); - // Highlight first parent, other parents are dealt with later - if (isHighlighted && commit.Parents.Count > 0) - highlightExtraCommits.Add(commit.Parents[0]); + case CommitGraphHighlighting.CurrentBranchOnly: + isHighlighted = commit.IsMerged; + break; + case CommitGraphHighlighting.SelectedCommitsOnly: + case CommitGraphHighlighting.SelectedCommitsOnlyFirstParent: + if (highlightExtraCommits.Remove(commit.SHA)) + { + isHighlighted = true; + // Highlight first parent, other parents are dealt with later + if (commit.Parents.Count > 0) + highlightExtraCommits.Add(commit.Parents[0]); + } + break; + default: // CommitGraphHighlighting.CurrentBranchAndSelectedCommits + if (commit.IsMerged) + { + isHighlighted = true; + } + else if (highlightExtraCommits.Remove(commit.SHA)) + { + isHighlighted = true; + // Highlight first parent, other parents are dealt with later + if (commit.Parents.Count > 0) + highlightExtraCommits.Add(commit.Parents[0]); + } + break; } } commit.IsHighlightedInGraph = isHighlighted; From 0e129cbcff2c3e023d8630513ff2e032a5bb91d6 Mon Sep 17 00:00:00 2001 From: leo Date: Mon, 24 Aug 2026 10:44:33 +0800 Subject: [PATCH 21/42] enhance: calculate the merge state for history commits in background thread instead of UIThread Signed-off-by: leo --- src/ViewModels/Histories.cs | 18 ------------------ src/ViewModels/Repository.cs | 13 +++++++++++++ 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index a4a52e116..7245c302e 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -75,7 +75,6 @@ public List Commits get => _commits; set { - RecalculateMergeState(value); GenerateGraph(value); if (SetProperty(ref _commits, value)) PostCommitsChanged(); @@ -511,23 +510,6 @@ private void PostSelectedCommitsChanged() GenerateGraph(_commits); } - private void RecalculateMergeState(List commits) - { - var merged = new HashSet(); - - foreach (var commit in commits) - { - if (merged.Remove(commit.SHA)) - commit.IsMerged = true; - - if (commit.IsMerged) - { - foreach (var p in commit.Parents) - merged.Add(p); - } - } - } - private void GenerateGraph(List commits) { var firstParentOnly = _repo.UIStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly); diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index c8452cc38..b8bf2e0a6 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -1242,6 +1242,19 @@ public void RefreshCommits() .GetResultAsync() .ConfigureAwait(false); + var merged = new HashSet(); + foreach (var c in commits) + { + if (merged.Remove(c.SHA)) + c.IsMerged = true; + + if (c.IsMerged) + { + foreach (var p in c.Parents) + merged.Add(p); + } + } + Dispatcher.UIThread.Invoke(() => { if (token.IsCancellationRequested) From 98ba6428a9cf1d165058fd284e9069739d20a894 Mon Sep 17 00:00:00 2001 From: leo Date: Mon, 24 Aug 2026 11:13:27 +0800 Subject: [PATCH 22/42] enhance: remove extra empty line in `git blame` result Signed-off-by: leo --- src/Commands/Blame.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Commands/Blame.cs b/src/Commands/Blame.cs index 0f247d736..27a9ff874 100644 --- a/src/Commands/Blame.cs +++ b/src/Commands/Blame.cs @@ -66,7 +66,9 @@ private void ParseLine(string line) if (!match.Success) return; - _content.AppendLine(match.Groups[5].Value); + if (_content.Length > 0) + _content.Append('\n'); + _content.Append(match.Groups[5].Value); var commit = match.Groups[1].Value; var file = match.Groups[2].Value.Trim(); From e308279e9669c71be5187a9ae68cc5c8370b82fb Mon Sep 17 00:00:00 2001 From: leo Date: Mon, 24 Aug 2026 11:23:52 +0800 Subject: [PATCH 23/42] fix: maintain compatibility with older versions Signed-off-by: leo --- src/Models/CommitGraph.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 4a8b726ac..916a79b1b 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -13,8 +13,8 @@ public enum CommitGraphHighlighting All = 0, CurrentBranchOnly, SelectedCommitsOnly, - SelectedCommitsOnlyFirstParent, CurrentBranchAndSelectedCommits, + SelectedCommitsOnlyFirstParent, } public class CommitGraph From aad040375390941232cfaf6d3acd9ab3ead80559 Mon Sep 17 00:00:00 2001 From: leo Date: Mon, 24 Aug 2026 12:45:54 +0800 Subject: [PATCH 24/42] feature: use XDG standard directories on Linux - Save `preference.json` to `${XDG_CONFIG_HOME}/SourceGit/` - Read `external_editors.json` from `${XDG_CONFIG_HOME}/SourceGit/` - Save avatars to `${XDG_CACHE_HOME}/SourceGit/avatars` - Save crash logs to `${XDG_CACHE_HOME}/SourceGit/crashes` - On Linux, `Open Data Storage Directory` menu has two sub-menu items: `Config` and `Cache` - Auto migrate legacy data dir `~/.sourcegit` to XDG standard Signed-off-by: leo --- README.md | 12 +++---- src/App.Commands.cs | 7 +++- src/App.axaml.cs | 2 +- src/Models/AvatarManager.cs | 2 +- src/Models/ExternalTool.cs | 2 +- src/Models/IpcChannel.cs | 4 +-- src/Native/Linux.cs | 54 ++++++++++++++++++++++++++++--- src/Native/MacOS.cs | 14 ++++++-- src/Native/OS.cs | 20 +++++++----- src/Native/Windows.cs | 24 +++++++++++--- src/Resources/Locales/en_US.axaml | 2 ++ src/Resources/Locales/zh_CN.axaml | 2 ++ src/Resources/Locales/zh_TW.axaml | 2 ++ src/ViewModels/Preferences.cs | 6 ++-- src/Views/Launcher.axaml | 10 +++++- 15 files changed, 126 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 38a7e99fa..68a1a4535 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,11 @@ You can download the latest stable from [Releases](https://github.com/sourcegit- This software creates a folder, which is platform-dependent, to store user settings, downloaded avatars and crash logs. -| OS | PATH | -|---------|-------------------------------------------| -| Windows | `%APPDATA%\SourceGit` | -| Linux | `~/.sourcegit` | -| macOS | `~/Library/Application Support/SourceGit` | +| OS | PATH | +|---------|--------------------------------------------------------------------------------------------------| +| Windows | `%APPDATA%\SourceGit` | +| Linux | `${XDG_CONFIG_HOME}/SourceGit` (preference.json) `${XDG_CACHE_HOME}/SourceGit` (avatars & logs) | +| macOS | `~/Library/Application Support/SourceGit` | > [!TIP] > * You can open this data storage directory from the main menu `Open Data Storage Directory`. @@ -215,7 +215,7 @@ This app supports open repository in external tools listed in the table below. > [!NOTE] > This app will try to find those tools based on some pre-defined or expected locations automatically. If you are using one portable version of these tools, it will not be detected by this app. -> To solve this problem you can add a file named `external_editors.json` in app data storage directory and provide the path directly. +> To solve this problem you can add a file named `external_editors.json` in app data storage directory (config dir for Linux) and provide the path directly. > User can also exclude some editors by using `external_editors.json`. The format of `external_editors.json`: diff --git a/src/App.Commands.cs b/src/App.Commands.cs index 06c0afbf1..0c1057063 100644 --- a/src/App.Commands.cs +++ b/src/App.Commands.cs @@ -55,9 +55,14 @@ public static bool IsCheckForUpdateCommandVisible } }); + public static readonly Command OpenAppConfigDirCommand = new Command(_ => + { + Native.OS.OpenInFileManager(Native.OS.BasicDirectories.ConfigDir); + }); + public static readonly Command OpenAppDataDirCommand = new Command(_ => { - Native.OS.OpenInFileManager(Native.OS.DataDir); + Native.OS.OpenInFileManager(Native.OS.BasicDirectories.CacheDir); }); public static readonly Command OpenAboutCommand = new Command(async _ => diff --git a/src/App.axaml.cs b/src/App.axaml.cs index a22f764d6..eef4684ce 100644 --- a/src/App.axaml.cs +++ b/src/App.axaml.cs @@ -23,7 +23,7 @@ public partial class App : Application [STAThread] public static void Main(string[] args) { - Native.OS.SetupDataDir(); + Native.OS.SetupBasicDirectories(); AppDomain.CurrentDomain.UnhandledException += (_, e) => { diff --git a/src/Models/AvatarManager.cs b/src/Models/AvatarManager.cs index 9616ac64d..043dd2f1b 100644 --- a/src/Models/AvatarManager.cs +++ b/src/Models/AvatarManager.cs @@ -44,7 +44,7 @@ public static AvatarManager Instance public void Start() { - _storePath = Path.Combine(Native.OS.DataDir, "avatars"); + _storePath = Path.Combine(Native.OS.BasicDirectories.CacheDir, "avatars"); if (!Directory.Exists(_storePath)) Directory.CreateDirectory(_storePath); diff --git a/src/Models/ExternalTool.cs b/src/Models/ExternalTool.cs index 7f30963a5..f9660e834 100644 --- a/src/Models/ExternalTool.cs +++ b/src/Models/ExternalTool.cs @@ -120,7 +120,7 @@ public List Tools public ExternalToolsFinder() { - var customPathsConfig = Path.Combine(Native.OS.DataDir, "external_editors.json"); + var customPathsConfig = Path.Combine(Native.OS.BasicDirectories.ConfigDir, "external_editors.json"); try { if (File.Exists(customPathsConfig)) diff --git a/src/Models/IpcChannel.cs b/src/Models/IpcChannel.cs index 86c8167ae..5d804374e 100644 --- a/src/Models/IpcChannel.cs +++ b/src/Models/IpcChannel.cs @@ -18,7 +18,7 @@ public IpcChannel() { try { - _singletonLock = File.Open(Path.Combine(Native.OS.DataDir, "process.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + _singletonLock = File.Open(Path.Combine(Native.OS.BasicDirectories.CacheDir, "process.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); IsFirstInstance = true; _server = new NamedPipeServerStream( GetPipeName(), @@ -76,7 +76,7 @@ private static string GetPipeName() return "SourceGit"; // Windows and Linux can have multiple instances of SourceGit running (portable-mode), so we need to generate a unique pipe name based on the data directory. - var dataDir = Native.OS.DataDir.Replace('\\', '/').TrimEnd('/'); + var dataDir = Native.OS.BasicDirectories.CacheDir.Replace('\\', '/').TrimEnd('/'); var hashStr = $"{Environment.UserName}_{dataDir}"; var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hashStr))).Substring(0, 10); return $"SG_{hash}"; diff --git a/src/Native/Linux.cs b/src/Native/Linux.cs index 364530f26..d56723773 100644 --- a/src/Native/Linux.cs +++ b/src/Native/Linux.cs @@ -39,20 +39,55 @@ public void SetupWindow(Window window) } } - public string GetDataDir() + public OS.Directories GetOrCreateDirectories() { + var dirs = new OS.Directories(); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + // AppImage supports portable mode var appImage = Environment.GetEnvironmentVariable("APPIMAGE"); if (!string.IsNullOrEmpty(appImage) && File.Exists(appImage)) { var portableDir = Path.Combine(Path.GetDirectoryName(appImage)!, "data"); if (Directory.Exists(portableDir)) - return portableDir; + { + dirs.ConfigDir = portableDir; + dirs.CacheDir = portableDir; + return dirs; + } } - // Runtime data dir: ~/.sourcegit - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return Path.Combine(home, ".sourcegit"); + // XDG Base Directory Specification: https://specifications.freedesktop.org/basedir/latest/ + dirs.ConfigDir = GetXdgDirectory("XDG_CONFIG_HOME", Path.Combine(home, ".config"), "SourceGit"); + dirs.CacheDir = GetXdgDirectory("XDG_CACHE_HOME", Path.Combine(home, ".cache"), "SourceGit"); + + // If the app basic dirs already exist, we can skip the migration step + if (Directory.Exists(dirs.ConfigDir)) + return dirs; + + // Migrate legacy data dir: ~/.sourcegit to XDG standard directories + var legacyDir = Path.Combine(home, ".sourcegit"); + if (Directory.Exists(legacyDir)) + { + try + { + File.Copy(Path.Combine(legacyDir, "preference.json"), Path.Combine(dirs.ConfigDir, "preference.json"), true); + Directory.Move(Path.Combine(legacyDir, "avatars"), Path.Combine(dirs.CacheDir, "avatars")); + Directory.Delete(legacyDir, true); + } + catch + { + // Ignore any errors during migration + } + } + + // Create the config and cache directories if they don't exist + if (!Directory.Exists(dirs.ConfigDir)) + Directory.CreateDirectory(dirs.ConfigDir); + if (!Directory.Exists(dirs.CacheDir)) + Directory.CreateDirectory(dirs.CacheDir); + + return dirs; } public string FindGitExecutable() @@ -162,5 +197,14 @@ private string FindExecutable(string filename) var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", filename); return File.Exists(local) ? local : string.Empty; } + + private string GetXdgDirectory(string envVar, string fallback, string subDirName) + { + var dir = Environment.GetEnvironmentVariable(envVar); + if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) + return Path.Combine(dir, subDirName); + + return Path.Combine(fallback, subDirName); + } } } diff --git a/src/Native/MacOS.cs b/src/Native/MacOS.cs index ae80a11ca..99ff7e04a 100644 --- a/src/Native/MacOS.cs +++ b/src/Native/MacOS.cs @@ -29,7 +29,7 @@ public void SetupApp(AppBuilder builder) else if (!path.Contains("/opt/homebrew/", StringComparison.Ordinal)) path = "/opt/homebrew/bin:/opt/homebrew/sbin:" + path; - var customPathFile = Path.Combine(OS.DataDir, "PATH"); + var customPathFile = Path.Combine(OS.BasicDirectories.ConfigDir, "PATH"); if (File.Exists(customPathFile)) { var env = File.ReadAllText(customPathFile).Trim(); @@ -47,11 +47,19 @@ public void SetupWindow(Window window) window.BorderThickness = new Thickness(0); } - public string GetDataDir() + public OS.Directories GetOrCreateDirectories() { - return Path.Combine( + var dirs = new OS.Directories(); + var appDataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "SourceGit"); + + if (!Directory.Exists(appDataDir)) + Directory.CreateDirectory(appDataDir); + + dirs.ConfigDir = appDataDir; + dirs.CacheDir = appDataDir; + return dirs; } public string FindGitExecutable() diff --git a/src/Native/OS.cs b/src/Native/OS.cs index 8cc9b59de..7178b50ab 100644 --- a/src/Native/OS.cs +++ b/src/Native/OS.cs @@ -14,12 +14,18 @@ namespace SourceGit.Native { public static partial class OS { + public class Directories + { + public string ConfigDir { get; set; } = string.Empty; + public string CacheDir { get; set; } = string.Empty; + } + public interface IBackend { void SetupApp(AppBuilder builder); void SetupWindow(Window window); - string GetDataDir(); + Directories GetOrCreateDirectories(); string FindGitExecutable(); string FindTerminal(Models.ShellOrTerminal shell); List FindExternalTools(); @@ -32,11 +38,11 @@ public interface IBackend void TerminateProcess(Process proc); } - public static string DataDir + public static Directories BasicDirectories { get; private set; - } = string.Empty; + } public static string GitExecutable { @@ -135,11 +141,9 @@ static OS() throw new PlatformNotSupportedException(); } - public static void SetupDataDir() + public static void SetupBasicDirectories() { - DataDir = _backend.GetDataDir(); - if (!Directory.Exists(DataDir)) - Directory.CreateDirectory(DataDir); + BasicDirectories = _backend.GetOrCreateDirectories(); } public static void SetupApp(AppBuilder builder) @@ -162,7 +166,7 @@ public static void LogException(Exception ex) if (ex == null) return; - var crashDir = Path.Combine(DataDir, "crashes"); + var crashDir = Path.Combine(BasicDirectories.CacheDir, "crashes"); if (!Directory.Exists(crashDir)) Directory.CreateDirectory(crashDir); diff --git a/src/Native/Windows.cs b/src/Native/Windows.cs index 188caaf8b..5a84a02d4 100644 --- a/src/Native/Windows.cs +++ b/src/Native/Windows.cs @@ -54,16 +54,30 @@ public void SetupWindow(Window window) window.Padding = new Thickness(0); } - public string GetDataDir() + public OS.Directories GetOrCreateDirectories() { + var dirs = new OS.Directories(); var execFile = Environment.ProcessPath; var portableDir = Path.Combine(Path.GetDirectoryName(execFile)!, "data"); if (Directory.Exists(portableDir)) - return portableDir; + { + dirs.ConfigDir = portableDir; + dirs.CacheDir = portableDir; + } + else + { + var appDataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "SourceGit"); + + if (!Directory.Exists(appDataDir)) + Directory.CreateDirectory(appDataDir); + + dirs.ConfigDir = appDataDir; + dirs.CacheDir = appDataDir; + } - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "SourceGit"); + return dirs; } public string FindGitExecutable() diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 47a8a47df..e9b95f8a1 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -627,6 +627,8 @@ Open Default Editor (System) Open Data Storage Directory + Cache + Config Open File Open in External Merge Tool Open Local Repository diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index 980c68e83..47f533d9a 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -631,6 +631,8 @@ 打开 系统默认编辑器 浏览应用数据目录 + 缓存数据 + 偏好设置数据 打开文件 使用外部对比工具查看 打开本地仓库 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index 424814f78..20343afae 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -631,6 +631,8 @@ 開啟 系統預設編輯器 瀏覽程式資料目錄 + 快取資料 + 偏好設定資料 開啟檔案 使用外部比對工具檢視 開啟本機存放庫 diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 25bbb431f..7895c50ae 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -639,17 +639,17 @@ public void Save() if (_isLoading || _isReadonly) return; - var tmpfile = Path.Combine(Native.OS.DataDir, "preference_tmp.json"); + var tmpfile = Path.Combine(Native.OS.BasicDirectories.ConfigDir, "preference_tmp.json"); var content = JsonSerializer.Serialize(this, JsonCodeGen.Default.Preferences); File.WriteAllText(tmpfile, content); - var finalFile = Path.Combine(Native.OS.DataDir, "preference.json"); + var finalFile = Path.Combine(Native.OS.BasicDirectories.ConfigDir, "preference.json"); File.Move(tmpfile, finalFile, true); } private static Preferences Load() { - var path = Path.Combine(Native.OS.DataDir, "preference.json"); + var path = Path.Combine(Native.OS.BasicDirectories.ConfigDir, "preference.json"); if (!File.Exists(path)) return new Preferences(); diff --git a/src/Views/Launcher.axaml b/src/Views/Launcher.axaml index e3cc1eb89..290dde939 100644 --- a/src/Views/Launcher.axaml +++ b/src/Views/Launcher.axaml @@ -42,11 +42,19 @@ - + + + + + + + + + From 7220b141f80da866c5b6f70e32b2f2e0f34a9c34 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 04:47:29 +0000 Subject: [PATCH 25/42] doc: Update translation status and sort locale files --- TRANSLATION.md | 52 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/TRANSLATION.md b/TRANSLATION.md index 06da3a44d..a722d770b 100644 --- a/TRANSLATION.md +++ b/TRANSLATION.md @@ -6,7 +6,7 @@ This document shows the translation status of each locale file in the repository ### ![en_US](https://img.shields.io/badge/en__US-%E2%88%9A-brightgreen) -### ![de__DE](https://img.shields.io/badge/de__DE-99.32%25-yellow) +### ![de__DE](https://img.shields.io/badge/de__DE-99.13%25-yellow)
Missing keys in de_DE.axaml @@ -16,12 +16,14 @@ This document shows the translation status of each locale file in the repository - Text.File.HexViewer - Text.File.HexViewer.GotoAddress - Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.Preferences.AI.ReasoningEffortLevel - Text.Terminate
-### ![el__GR](https://img.shields.io/badge/el__GR-98.83%25-yellow) +### ![el__GR](https://img.shields.io/badge/el__GR-98.64%25-yellow)
Missing keys in el_GR.axaml @@ -32,6 +34,8 @@ This document shows the translation status of each locale file in the repository - Text.File.HexViewer.GotoAddress - Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent - Text.Launcher.NewVersion +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.Preferences.AI.ReasoningEffortLevel - Text.Repository.FilterCommits.Collapse - Text.Repository.FilterCommits.Expand @@ -41,17 +45,19 @@ This document shows the translation status of each locale file in the repository
-### ![es__ES](https://img.shields.io/badge/es__ES-99.81%25-yellow) +### ![es__ES](https://img.shields.io/badge/es__ES-99.61%25-yellow)
Missing keys in es_ES.axaml - Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.Terminate
-### ![fr__FR](https://img.shields.io/badge/fr__FR-94.94%25-yellow) +### ![fr__FR](https://img.shields.io/badge/fr__FR-94.76%25-yellow)
Missing keys in fr_FR.axaml @@ -93,6 +99,8 @@ This document shows the translation status of each locale file in the repository - Text.Merge.Test.NoConflicts - Text.Merge.Test.UnknownError - Text.Merge.Test.WillCauseConflicts +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.Preferences.AI.ReasoningEffortLevel - Text.Preferences.General.UseCompactBranchNames - Text.Rebase.Test @@ -111,7 +119,7 @@ This document shows the translation status of each locale file in the repository
-### ![he__IL](https://img.shields.io/badge/he__IL-94.94%25-yellow) +### ![he__IL](https://img.shields.io/badge/he__IL-94.76%25-yellow)
Missing keys in he_IL.axaml @@ -153,6 +161,8 @@ This document shows the translation status of each locale file in the repository - Text.Merge.Test.NoConflicts - Text.Merge.Test.UnknownError - Text.Merge.Test.WillCauseConflicts +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.Preferences.AI.ReasoningEffortLevel - Text.Preferences.General.UseCompactBranchNames - Text.Rebase.Test @@ -171,7 +181,7 @@ This document shows the translation status of each locale file in the repository
-### ![id__ID](https://img.shields.io/badge/id__ID-99.22%25-yellow) +### ![id__ID](https://img.shields.io/badge/id__ID-99.03%25-yellow)
Missing keys in id_ID.axaml @@ -181,13 +191,15 @@ This document shows the translation status of each locale file in the repository - Text.File.HexViewer - Text.File.HexViewer.GotoAddress - Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.Preferences.AI.ReasoningEffortLevel - Text.Terminate - Text.WorkingCopy.FilterChanges
-### ![it__IT](https://img.shields.io/badge/it__IT-88.52%25-yellow) +### ![it__IT](https://img.shields.io/badge/it__IT-88.35%25-yellow)
Missing keys in it_IT.axaml @@ -274,6 +286,8 @@ This document shows the translation status of each locale file in the repository - Text.Merge.Test.NoConflicts - Text.Merge.Test.UnknownError - Text.Merge.Test.WillCauseConflicts +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.OpenLocalRepository - Text.OpenLocalRepository.Bookmark - Text.OpenLocalRepository.Group @@ -313,7 +327,7 @@ This document shows the translation status of each locale file in the repository
-### ![ja__JP](https://img.shields.io/badge/ja__JP-98.83%25-yellow) +### ![ja__JP](https://img.shields.io/badge/ja__JP-98.64%25-yellow)
Missing keys in ja_JP.axaml @@ -324,6 +338,8 @@ This document shows the translation status of each locale file in the repository - Text.File.HexViewer.GotoAddress - Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent - Text.Launcher.NewVersion +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.Preferences.AI.ReasoningEffortLevel - Text.Repository.FilterCommits.Collapse - Text.Repository.FilterCommits.Expand @@ -333,7 +349,7 @@ This document shows the translation status of each locale file in the repository
-### ![ko__KR](https://img.shields.io/badge/ko__KR-96.40%25-yellow) +### ![ko__KR](https://img.shields.io/badge/ko__KR-96.21%25-yellow)
Missing keys in ko_KR.axaml @@ -365,6 +381,8 @@ This document shows the translation status of each locale file in the repository - Text.GitFlow.StartName - Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent - Text.Launcher.NewVersion +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.Preferences.AI.ReasoningEffortLevel - Text.Repository.FilterCommits.Collapse - Text.Repository.FilterCommits.Expand @@ -378,7 +396,7 @@ This document shows the translation status of each locale file in the repository
-### ![pt__BR](https://img.shields.io/badge/pt__BR-62.45%25-red) +### ![pt__BR](https://img.shields.io/badge/pt__BR-62.33%25-red)
Missing keys in pt_BR.axaml @@ -616,6 +634,8 @@ This document shows the translation status of each locale file in the repository - Text.No - Text.Open - Text.Open.SystemDefaultEditor +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.OpenFile - Text.OpenLocalRepository - Text.OpenLocalRepository.Bookmark @@ -772,17 +792,19 @@ This document shows the translation status of each locale file in the repository
-### ![ru__RU](https://img.shields.io/badge/ru__RU-99.81%25-yellow) +### ![ru__RU](https://img.shields.io/badge/ru__RU-99.61%25-yellow)
Missing keys in ru_RU.axaml - Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.Terminate
-### ![ta__IN](https://img.shields.io/badge/ta__IN-64.11%25-red) +### ![ta__IN](https://img.shields.io/badge/ta__IN-63.98%25-red)
Missing keys in ta_IN.axaml @@ -1025,6 +1047,8 @@ This document shows the translation status of each locale file in the repository - Text.No - Text.Open - Text.Open.SystemDefaultEditor +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.OpenFile - Text.OpenLocalRepository - Text.OpenLocalRepository.Bookmark @@ -1159,7 +1183,7 @@ This document shows the translation status of each locale file in the repository
-### ![uk__UA](https://img.shields.io/badge/uk__UA-64.88%25-red) +### ![uk__UA](https://img.shields.io/badge/uk__UA-64.76%25-red)
Missing keys in uk_UA.axaml @@ -1398,6 +1422,8 @@ This document shows the translation status of each locale file in the repository - Text.No - Text.Open - Text.Open.SystemDefaultEditor +- Text.OpenAppDataDir.Cache +- Text.OpenAppDataDir.Config - Text.OpenFile - Text.OpenLocalRepository - Text.OpenLocalRepository.Bookmark From 0c342cb517651833daea417ebdaa719d702a6a1d Mon Sep 17 00:00:00 2001 From: leo Date: Mon, 24 Aug 2026 13:33:14 +0800 Subject: [PATCH 26/42] fix: make sure the target directories exists before migartion Signed-off-by: leo --- src/Native/Linux.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Native/Linux.cs b/src/Native/Linux.cs index d56723773..5ebf1b0d3 100644 --- a/src/Native/Linux.cs +++ b/src/Native/Linux.cs @@ -62,9 +62,15 @@ public OS.Directories GetOrCreateDirectories() dirs.CacheDir = GetXdgDirectory("XDG_CACHE_HOME", Path.Combine(home, ".cache"), "SourceGit"); // If the app basic dirs already exist, we can skip the migration step - if (Directory.Exists(dirs.ConfigDir)) + if (Directory.Exists(dirs.ConfigDir) && Directory.Exists(dirs.CacheDir)) return dirs; + // Create the config and cache directories if they don't exist + if (!Directory.Exists(dirs.ConfigDir)) + Directory.CreateDirectory(dirs.ConfigDir); + if (!Directory.Exists(dirs.CacheDir)) + Directory.CreateDirectory(dirs.CacheDir); + // Migrate legacy data dir: ~/.sourcegit to XDG standard directories var legacyDir = Path.Combine(home, ".sourcegit"); if (Directory.Exists(legacyDir)) @@ -81,12 +87,6 @@ public OS.Directories GetOrCreateDirectories() } } - // Create the config and cache directories if they don't exist - if (!Directory.Exists(dirs.ConfigDir)) - Directory.CreateDirectory(dirs.ConfigDir); - if (!Directory.Exists(dirs.CacheDir)) - Directory.CreateDirectory(dirs.CacheDir); - return dirs; } From 2464fa403ce066814fc5c5e175b45f7b8e5836b5 Mon Sep 17 00:00:00 2001 From: leo Date: Mon, 24 Aug 2026 15:57:15 +0800 Subject: [PATCH 27/42] fix: preview does not work in design mode Signed-off-by: leo --- src/Native/OS.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Native/OS.cs b/src/Native/OS.cs index 7178b50ab..c951bafbf 100644 --- a/src/Native/OS.cs +++ b/src/Native/OS.cs @@ -42,7 +42,7 @@ public static Directories BasicDirectories { get; private set; - } + } = new(); public static string GitExecutable { From a984953a46b9e13cd779219d4621cad4069f1e43 Mon Sep 17 00:00:00 2001 From: leo Date: Mon, 24 Aug 2026 18:02:15 +0800 Subject: [PATCH 28/42] feature: add a built-in `SSH Key Helper` (#2647) Signed-off-by: leo --- src/App.Commands.cs | 10 ++ src/App.axaml | 1 + src/Models/SSHKeyPair.cs | 35 ++++ src/Resources/Icons.axaml | 1 + src/Resources/Locales/en_US.axaml | 15 ++ src/Resources/Locales/zh_CN.axaml | 15 ++ src/ViewModels/SSHKeyGenerator.cs | 140 ++++++++++++++++ src/ViewModels/SSHKeyHelper.cs | 129 +++++++++++++++ src/Views/Launcher.axaml | 15 +- src/Views/SSHKeyHelper.axaml | 264 ++++++++++++++++++++++++++++++ src/Views/SSHKeyHelper.axaml.cs | 59 +++++++ 11 files changed, 679 insertions(+), 5 deletions(-) create mode 100644 src/Models/SSHKeyPair.cs create mode 100644 src/ViewModels/SSHKeyGenerator.cs create mode 100644 src/ViewModels/SSHKeyHelper.cs create mode 100644 src/Views/SSHKeyHelper.axaml create mode 100644 src/Views/SSHKeyHelper.axaml.cs diff --git a/src/App.Commands.cs b/src/App.Commands.cs index 0c1057063..ad92a5755 100644 --- a/src/App.Commands.cs +++ b/src/App.Commands.cs @@ -101,5 +101,15 @@ public static bool IsCheckForUpdateCommandVisible if (OperatingSystem.IsMacOS()) Native.MacOSUtilities.ShowAllApplications(); }); + + public static readonly Command OpenSSHKeyHelperCommand = new Command(async _ => + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) + { + var vm = new ViewModels.SSHKeyHelper(); + var dialog = new Views.SSHKeyHelper() { DataContext = vm }; + await dialog.ShowDialog(owner); + } + }); } } diff --git a/src/App.axaml b/src/App.axaml index 166ef59a3..ddb353283 100644 --- a/src/App.axaml +++ b/src/App.axaml @@ -46,6 +46,7 @@ + diff --git a/src/Models/SSHKeyPair.cs b/src/Models/SSHKeyPair.cs new file mode 100644 index 000000000..85d7e7643 --- /dev/null +++ b/src/Models/SSHKeyPair.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using System.Security.Cryptography; + +namespace SourceGit.Models +{ + public class SSHKeyPair + { + public string Name { get; set; } + public string FullPath { get; set; } + public string PublicKey { get; set; } + public string Fingerprint { get; set; } = "--- (invalid)"; + + public SSHKeyPair(string file) + { + Name = Path.GetFileName(file); + FullPath = file; + PublicKey = File.ReadAllText($"{file}.pub"); + + var parts = PublicKey.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 3) + return; + + try + { + var blob = Convert.FromBase64String(parts[1]); + Fingerprint = "SHA256:" + Convert.ToBase64String(SHA256.HashData(blob)).TrimEnd('='); + } + catch + { + // Ignore errors and keep the fingerprint as invalid + } + } + } +} diff --git a/src/Resources/Icons.axaml b/src/Resources/Icons.axaml index 2109df7cf..f5edac759 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -143,6 +143,7 @@ M996 452 572 28A96 96 0 00504 0H96C43 0 0 43 0 96v408a96 96 0 0028 68l424 424c37 37 98 37 136 0l408-408c37-37 37-98 0-136zM224 320c-53 0-96-43-96-96s43-96 96-96 96 43 96 96-43 96-96 96zm1028 268L844 996c-37 37-98 37-136 0l-1-1L1055 647c34-34 53-79 53-127s-19-93-53-127L663 0h97a96 96 0 0168 28l424 424c37 37 37 98 0 136z M765 118 629 239l-16 137-186 160 54 59 183-168 144 4 136-129 47-43-175-12L827 67zM489 404c-66 0-124 55-124 125s54 121 124 121c66 0 120-55 120-121H489l23-121c-8-4-16-4-23-4zM695 525c0 114-93 207-206 207s-206-94-206-207 93-207 206-207c16 0 27 0 43 4l43-207c-27-4-54-8-85-8-229 0-416 188-416 419s187 419 416 419c225 0 408-180 416-403v-12l-210-4z M144 112h736c18 0 32 14 32 32v736c0 18-14 32-32 32H144c-18 0-32-14-32-32V144c0-18 14-32 32-32zm112 211v72a9 9 0 003 7L386 509 259 615a9 9 0 00-3 7v72a9 9 0 0015 7L493 516a9 9 0 000-14l-222-186a9 9 0 00-15 7zM522 624a10 10 0 00-10 10v60a10 10 0 0010 10h237a10 10 0 0010-10v-60a10 10 0 00-10-10H522z + M929 201c-1-1-1-2-2-3-6-6-15-6-21 0L709 395l-78-78L828 120c1-1 2-2 2-3 4-7 2-16-5-20-112-66-259-51-356 45-77 77-102 185-76 283l-335 335c-3 3-3 9 0 13l194 194c4 4 9 4 13 0l335-335c98 26 206 1 283-76 96-97 112-244 45-356zM825 498c-66 66-166 79-245 38l-10 10-0-0-313 313-91-91 263-263s0 0 0 0l60-60c-41-79-28-179 38-245a211 211 0 01165-61L541 291a37 37 0 000 52l142 142a37 37 0 0052 0l152-152c4 59-16 120-61 164z M170 831 513 489 855 831 960 726 512 278 64 726 170 831zM512 278h448v-128h-896v128h448z M897 673v13c0 51-42 93-93 93h-10c-1 0-2 0-2 0H220c-23 0-42 19-42 42v13c0 23 19 42 42 42h552c14 0 26 12 26 26 0 14-12 26-26 26H220c-51 0-93-42-93-93v-13c0-51 42-93 93-93h20c1-0 2-0 2-0h562c23 0 42-19 42-42v-13c0-11-5-22-13-29-8-7-17-11-28-10H660c-14 0-26-12-26-26 0-14 12-26 26-26h144c24-1 47 7 65 24 18 17 29 42 29 67zM479 98c-112 0-203 91-203 203 0 44 14 85 38 118l132 208c15 24 50 24 66 0l133-209c23-33 37-73 37-117 0-112-91-203-203-203zm0 327c-68 0-122-55-122-122s55-122 122-122 122 55 122 122-55 122-122 122z M912 800a48 48 0 1 1 0 96h-416a48 48 0 1 1 0-96h416z m-704-704A112 112 0 0 1 256 309.184V480h80a48 48 0 0 1 0 96H256v224h81.664a48 48 0 1 1 0 96H256a96 96 0 0 1-96-96V309.248A112 112 0 0 1 208 96z m704 384a48 48 0 1 1 0 96h-416a48 48 0 0 1 0-96h416z m0-320a48 48 0 1 1 0 96h-416a48 48 0 0 1 0-96h416z diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index e9b95f8a1..b1fba0749 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -900,6 +900,21 @@ Go to SSH Private Key: Private SSH key store path + Open SSH Key Helper + Do you really want to delete the following keys? + Fingerprint + Generate SSH Key (ED25519) + Confirm Passphrase: + Email: + Required. + Key Name: + Required. For example: id_github + Passphrase: + GENERATE + Use passphrase for private key + Path + Public Key + SSH Key Helper START Stash Include untracked files diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index 47f533d9a..062cc0646 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -904,6 +904,21 @@ 跳转到提交 SSH密钥 : SSH密钥文件 + 打开SSH密钥工具 + 您确定要删除以下SSH密钥吗? + SHA-256 指纹 + 生成ED25519格式的SSH密钥 + 再次确认口令: + 邮箱: + 必填项 + 密钥文件名: + 必填项,如:id_github + 输入口令: + 生 成 + 为私钥文件设置口令 + 文件路径 + 公钥内容 + SSH密钥工具 开 始 贮藏(stash) 包含未跟踪的文件 diff --git a/src/ViewModels/SSHKeyGenerator.cs b/src/ViewModels/SSHKeyGenerator.cs new file mode 100644 index 000000000..372e3e366 --- /dev/null +++ b/src/ViewModels/SSHKeyGenerator.cs @@ -0,0 +1,140 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.IO; +using System.Text.RegularExpressions; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public partial class SSHKeyGenerator : ObservableValidator + { + [Required(ErrorMessage = "Name is required.")] + [RegularExpression(@"^[a-zA-Z0-9_\-]+$", ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")] + public string Name + { + get => _name; + set => SetProperty(ref _name, value, true); + } + + [Required(ErrorMessage = "Email is required.")] + [EmailAddress(ErrorMessage = "Invalid email address.")] + public string Email + { + get => _email; + set => SetProperty(ref _email, value, true); + } + + public bool UsePassphrase + { + get => _usePassphrase; + set + { + if (SetProperty(ref _usePassphrase, value)) + { + ValidateProperty(_passphrase, nameof(Passphrase)); + ValidateProperty(_confirmedPassphrase, nameof(ConfirmedPassphrase)); + } + } + } + + [CustomValidation(typeof(SSHKeyGenerator), nameof(ValidatePassphrase))] + public string Passphrase + { + get => _passphrase; + set + { + if (SetProperty(ref _passphrase, value, true)) + ValidateProperty(_confirmedPassphrase, nameof(ConfirmedPassphrase)); + } + } + + [CustomValidation(typeof(SSHKeyGenerator), nameof(ValidateConfirmedPassphrase))] + public string ConfirmedPassphrase + { + get => _confirmedPassphrase; + set => SetProperty(ref _confirmedPassphrase, value, true); + } + + public string ErrorMessage + { + get => _errorMessage; + set => SetProperty(ref _errorMessage, value); + } + + public SSHKeyGenerator(string baseDir) + { + _baseDir = baseDir; + } + + public static ValidationResult ValidatePassphrase(string password, ValidationContext context) + { + var instance = (SSHKeyGenerator)context.ObjectInstance; + if (!instance.UsePassphrase) + return ValidationResult.Success; + + if (string.IsNullOrEmpty(password)) + return new ValidationResult("Passphrase is required!"); + + if (!REG_PSWD_FORMAT().IsMatch(password)) + return new ValidationResult("Passphrase can only contain letters, numbers, and special characters: _ + - = @ # $ % ! &"); + + return ValidationResult.Success; + } + + public static ValidationResult ValidateConfirmedPassphrase(string confirmedPassword, ValidationContext context) + { + var instance = (SSHKeyGenerator)context.ObjectInstance; + if (!instance.UsePassphrase) + return ValidationResult.Success; + + if (confirmedPassword != instance.Passphrase) + return new ValidationResult("Passphrase and confirmation do not match!"); + + return ValidationResult.Success; + } + + public bool Run() + { + ErrorMessage = string.Empty; + + ValidateAllProperties(); + if (HasErrors) + return false; + + var passphrase = _usePassphrase ? _passphrase : string.Empty; + var keyFile = Path.Combine(_baseDir, _name); + var start = new ProcessStartInfo(); + start.FileName = "ssh-keygen"; + start.Arguments = $"-q -t ed25519 -N {passphrase.Quoted()} -C {_email.Quoted()} -f {keyFile.Quoted()}"; + start.UseShellExecute = false; + start.CreateNoWindow = true; + + try + { + var proc = Process.Start(start); + proc.WaitForExit(); + proc.Close(); + } + catch (Exception e) + { + ErrorMessage = $"Failed to generate SSH key: {e.Message}"; + return false; + } + + return true; + } + + [GeneratedRegex(@"^[0-9a-zA-Z_\-\@\#\$\%\!\&\+\=]+$")] + private static partial Regex REG_PSWD_FORMAT(); + + private string _baseDir; + private string _name; + private string _email; + private bool _usePassphrase; + private string _passphrase; + private string _confirmedPassphrase; + private string _errorMessage; + } +} diff --git a/src/ViewModels/SSHKeyHelper.cs b/src/ViewModels/SSHKeyHelper.cs new file mode 100644 index 000000000..b7fee9202 --- /dev/null +++ b/src/ViewModels/SSHKeyHelper.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +using Avalonia.Collections; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class SSHKeyHelper : ObservableObject + { + public AvaloniaList Keys + { + get; + } + + public Models.SSHKeyPair SelectedKey + { + get => _selectedKey; + set => SetProperty(ref _selectedKey, value); + } + + public SSHKeyGenerator Generator + { + get => _generator; + private set => SetProperty(ref _generator, value); + } + + public SSHKeyHelper() + { + _baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh"); + Keys = new AvaloniaList(); + + Task.Run(() => + { + var sshDir = new DirectoryInfo(_baseDir); + var keys = new List(); + + if (sshDir.Exists) + { + var files = sshDir.GetFiles("*.pub"); + foreach (var file in files) + { + var privateKeyPath = file.FullName.Substring(0, file.FullName.Length - 4); + if (File.Exists(privateKeyPath)) + keys.Add(new(privateKeyPath)); + } + + keys.Sort((l, r) => l.Name.CompareTo(r.Name)); + } + + if (keys.Count > 0) + { + Dispatcher.UIThread.Post(() => + { + Keys.AddRange(keys); + SelectedKey = keys[0]; + }); + } + }); + } + + public void OpenGenerator() + { + Generator = new SSHKeyGenerator(_baseDir); + } + + public void CloseGenerator() + { + Generator = null; + } + + public void Generate() + { + var succ = _generator.Run(); + if (!succ) + return; + + var keyFile = Path.Combine(_baseDir, $"{_generator.Name}"); + if (File.Exists(keyFile)) + { + var added = new Models.SSHKeyPair(keyFile); + Keys.Add(added); + SelectedKey = added; + } + + Generator = null; + } + + public void DeleteSelected() + { + var key = SelectedKey; + if (key == null) + return; + + try + { + if (File.Exists(key.FullPath)) + File.Delete(key.FullPath); + if (File.Exists($"{key.FullPath}.pub")) + File.Delete($"{key.FullPath}.pub"); + + var idx = Keys.IndexOf(key); + if (idx >= 0) + { + Keys.RemoveAt(idx); + + if (Keys.Count == 0) + SelectedKey = null; + else if (idx > Keys.Count - 1) + SelectedKey = Keys[Keys.Count - 1]; + else + SelectedKey = Keys[idx]; + } + } + catch + { + // Ignore any errors during deletion + } + } + + private string _baseDir = null; + private Models.SSHKeyPair _selectedKey = null; + private SSHKeyGenerator _generator = null; + } +} diff --git a/src/Views/Launcher.axaml b/src/Views/Launcher.axaml index 290dde939..00aa09a22 100644 --- a/src/Views/Launcher.axaml +++ b/src/Views/Launcher.axaml @@ -42,6 +42,16 @@ + + + + + + + + + + @@ -55,11 +65,6 @@ - - - - - diff --git a/src/Views/SSHKeyHelper.axaml b/src/Views/SSHKeyHelper.axaml new file mode 100644 index 000000000..90d9139fb --- /dev/null +++ b/src/Views/SSHKeyHelper.axaml @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +