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/TRANSLATION.md b/TRANSLATION.md
index 2907f16c7..50575133d 100644
--- a/TRANSLATION.md
+++ b/TRANSLATION.md
@@ -6,7 +6,7 @@ This document shows the translation status of each locale file in the repository
### 
-### 
+### 
Missing keys in de_DE.axaml
@@ -15,11 +15,30 @@ This document shows the translation status of each locale file in the repository
- Text.File.Binary
- Text.File.HexViewer
- Text.File.HexViewer.GotoAddress
+- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent
+- Text.OpenAppDataDir.Cache
+- Text.OpenAppDataDir.Config
- Text.Preferences.AI.ReasoningEffortLevel
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
+- Text.Terminate
-### 
+### 
Missing keys in el_GR.axaml
@@ -28,18 +47,62 @@ This document shows the translation status of each locale file in the repository
- Text.File.Binary
- Text.File.HexViewer
- 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
- Text.Repository.FilterCommits.Summary
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
+- Text.Terminate
- Text.WorkingCopy.FilterChanges
-### 
+### 
-### 
+
+Missing keys in es_ES.axaml
+
+- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent
+- Text.OpenAppDataDir.Cache
+- Text.OpenAppDataDir.Config
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
+- Text.Terminate
+
+
+
+### 
Missing keys in fr_FR.axaml
@@ -75,11 +138,14 @@ This document shows the translation status of each locale file in the repository
- Text.GitFlow.FinishWithRebase
- Text.GitFlow.StartAt
- Text.GitFlow.StartName
+- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent
- Text.Launcher.NewVersion
- Text.Merge.Test
- 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
@@ -89,15 +155,31 @@ 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.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
- Text.TagCM.Checkout
- Text.TagCM.Merge
+- Text.Terminate
- Text.UpdateSubmodules.Recursive
- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder
- Text.WorkingCopy.FilterChanges
-### 
+### 
Missing keys in he_IL.axaml
@@ -133,11 +215,14 @@ This document shows the translation status of each locale file in the repository
- Text.GitFlow.FinishWithRebase
- Text.GitFlow.StartAt
- Text.GitFlow.StartName
+- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent
- Text.Launcher.NewVersion
- Text.Merge.Test
- 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
@@ -147,15 +232,31 @@ 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.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
- Text.TagCM.Checkout
- Text.TagCM.Merge
+- Text.Terminate
- Text.UpdateSubmodules.Recursive
- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder
- Text.WorkingCopy.FilterChanges
-### 
+### 
Missing keys in id_ID.axaml
@@ -164,12 +265,31 @@ This document shows the translation status of each locale file in the repository
- Text.File.Binary
- Text.File.HexViewer
- Text.File.HexViewer.GotoAddress
+- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent
+- Text.OpenAppDataDir.Cache
+- Text.OpenAppDataDir.Config
- Text.Preferences.AI.ReasoningEffortLevel
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
+- Text.Terminate
- Text.WorkingCopy.FilterChanges
-### 
+### 
Missing keys in it_IT.axaml
@@ -238,6 +358,7 @@ This document shows the translation status of each locale file in the repository
- Text.Histories.HighlightsInGraph.CurrentBranchOnly
- Text.Histories.HighlightsInGraph.CurrentBranchAndSelectedCommits
- Text.Histories.HighlightsInGraph.SelectedCommitsOnly
+- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent
- Text.Histories.ShowColumns
- Text.HistoriesDetailsStandalone
- Text.HistoriesDetailsStandalone.CommitDetail
@@ -255,6 +376,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
@@ -278,12 +401,28 @@ This document shows the translation status of each locale file in the repository
- Text.Repository.FilterCommits.Summary
- Text.SelfUpdate.CurrentVersion
- Text.SelfUpdate.ReleaseDate
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
- Text.StashCM.ApplyFileChanges
- Text.StashCM.Branch
- Text.SubmoduleRevisionCompare
- Text.SubmoduleRevisionCompare.OpenDetails
- Text.TagCM.Checkout
- Text.TagCM.Merge
+- Text.Terminate
- Text.UpdateSubmodules.Recursive
- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder
- Text.WorkingCopy.FilterChanges
@@ -293,7 +432,7 @@ This document shows the translation status of each locale file in the repository
-### 
+### 
Missing keys in ja_JP.axaml
@@ -302,16 +441,35 @@ This document shows the translation status of each locale file in the repository
- Text.File.Binary
- Text.File.HexViewer
- 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
- Text.Repository.FilterCommits.Summary
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
+- Text.Terminate
- Text.WorkingCopy.FilterChanges
-### 
+### 
Missing keys in ko_KR.axaml
@@ -341,20 +499,39 @@ This document shows the translation status of each locale file in the repository
- Text.GitFlow.FinishWithRebase
- Text.GitFlow.StartAt
- 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
- Text.Repository.FilterCommits.Summary
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
- Text.TagCM.Checkout
- Text.TagCM.Merge
+- Text.Terminate
- Text.UpdateSubmodules.Recursive
- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder
- Text.WorkingCopy.FilterChanges
-### 
+### 
Missing keys in pt_BR.axaml
@@ -532,6 +709,7 @@ This document shows the translation status of each locale file in the repository
- Text.Histories.HighlightsInGraph.CurrentBranchOnly
- Text.Histories.HighlightsInGraph.CurrentBranchAndSelectedCommits
- Text.Histories.HighlightsInGraph.SelectedCommitsOnly
+- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent
- Text.Histories.ShowColumns
- Text.HistoriesDetailsStandalone
- Text.HistoriesDetailsStandalone.CommitDetail
@@ -591,6 +769,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
@@ -682,6 +862,21 @@ This document shows the translation status of each locale file in the repository
- Text.SetUpstream.Unset
- Text.SetUpstream.Upstream
- Text.SHALinkCM.NavigateTo
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
- Text.Stash.Mode
- Text.StashCM.ApplyFileChanges
- Text.StashCM.Branch
@@ -716,6 +911,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 +942,34 @@ This document shows the translation status of each locale file in the repository
-### 
+### 
+
+
+Missing keys in ru_RU.axaml
+
+- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent
+- Text.OpenAppDataDir.Cache
+- Text.OpenAppDataDir.Config
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
+- Text.Terminate
+
+
-### 
+### 
Missing keys in ta_IN.axaml
@@ -941,6 +1162,7 @@ This document shows the translation status of each locale file in the repository
- Text.Histories.HighlightsInGraph.CurrentBranchOnly
- Text.Histories.HighlightsInGraph.CurrentBranchAndSelectedCommits
- Text.Histories.HighlightsInGraph.SelectedCommitsOnly
+- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent
- Text.Histories.ShowColumns
- Text.HistoriesDetailsStandalone
- Text.HistoriesDetailsStandalone.CommitDetail
@@ -990,6 +1212,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
@@ -1062,6 +1286,21 @@ This document shows the translation status of each locale file in the repository
- Text.SetSubmoduleBranch.Current
- Text.SetSubmoduleBranch.New
- Text.SetSubmoduleBranch.New.Tip
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
- Text.Stash.Mode
- Text.StashCM.ApplyFileChanges
- Text.StashCM.Branch
@@ -1095,6 +1334,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 +1363,7 @@ This document shows the translation status of each locale file in the repository
-### 
+### 
Missing keys in uk_UA.axaml
@@ -1312,6 +1552,7 @@ This document shows the translation status of each locale file in the repository
- Text.Histories.HighlightsInGraph.CurrentBranchOnly
- Text.Histories.HighlightsInGraph.CurrentBranchAndSelectedCommits
- Text.Histories.HighlightsInGraph.SelectedCommitsOnly
+- Text.Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent
- Text.Histories.ShowColumns
- Text.HistoriesDetailsStandalone
- Text.HistoriesDetailsStandalone.CommitDetail
@@ -1361,6 +1602,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
@@ -1433,6 +1676,21 @@ This document shows the translation status of each locale file in the repository
- Text.SetSubmoduleBranch.Current
- Text.SetSubmoduleBranch.New
- Text.SetSubmoduleBranch.New.Tip
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
- Text.Stash.Mode
- Text.StashCM.ApplyFileChanges
- Text.StashCM.Branch
@@ -1466,6 +1724,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
@@ -1492,4 +1751,25 @@ This document shows the translation status of each locale file in the repository
### 
-### 
\ No newline at end of file
+### 
+
+
+Missing keys in zh_TW.axaml
+
+- Text.SSHKeyHelper
+- Text.SSHKeyHelper.ConfirmDeletion
+- Text.SSHKeyHelper.Fingerprint
+- Text.SSHKeyHelper.Generator
+- Text.SSHKeyHelper.Generator.ConfirmPassphrase
+- Text.SSHKeyHelper.Generator.Email
+- Text.SSHKeyHelper.Generator.Email.Placeholder
+- Text.SSHKeyHelper.Generator.Name
+- Text.SSHKeyHelper.Generator.Name.Placeholder
+- Text.SSHKeyHelper.Generator.Passphrase
+- Text.SSHKeyHelper.Generator.Start
+- Text.SSHKeyHelper.Generator.UsePassphrase
+- Text.SSHKeyHelper.Path
+- Text.SSHKeyHelper.PublicKey
+- Text.SSHKeyHelper.Title
+
+
\ No newline at end of file
diff --git a/build/scripts/package.osx-app.sh b/build/scripts/package.osx-app.sh
index 8f3ddc77c..3613a01f6 100755
--- a/build/scripts/package.osx-app.sh
+++ b/build/scripts/package.osx-app.sh
@@ -13,5 +13,6 @@ cp resources/app/App.icns SourceGit.app/Contents/Resources/App.icns
sed "s/SOURCE_GIT_VERSION/$VERSION/g" resources/app/App.plist > SourceGit.app/Contents/Info.plist
rm -rf SourceGit.app/Contents/MacOS/SourceGit.dsym
rm -f SourceGit.app/Contents/MacOS/*.pdb
+clang ../tools/setsid-macos/setsid.c -o SourceGit.app/Contents/MacOS/setsid -mmacosx-version-min=13.0
zip "sourcegit_$VERSION.$RUNTIME.zip" -r SourceGit.app
diff --git a/src/App.Commands.cs b/src/App.Commands.cs
index 06c0afbf1..ad92a5755 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 _ =>
@@ -96,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..2663e44ad 100644
--- a/src/App.axaml
+++ b/src/App.axaml
@@ -45,6 +45,7 @@
+
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/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();
diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs
index 3ac426cfe..ba68bb90d 100644
--- a/src/Commands/Command.cs
+++ b/src/Commands/Command.cs
@@ -62,7 +62,7 @@ public async Task ExecAsync()
lock (capturedLock)
{
if (captured is { Process: { HasExited: false } })
- captured.Process.Kill(true);
+ Native.OS.TerminateProcess(captured.Process);
}
});
}
@@ -158,8 +158,36 @@ protected async Task ReadToEndAsync()
protected ProcessStartInfo CreateGitStartInfo(bool redirect)
{
+ var useSetSid = CancellationToken.CanBeCanceled && Native.OS.SupportSetSid();
+ 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 ? Native.OS.GetSetSidExecutable() : 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/Commands/Push.cs b/src/Commands/Push.cs
index 44394dc41..2a9cb8922 100644
--- a/src/Commands/Push.cs
+++ b/src/Commands/Push.cs
@@ -5,7 +5,7 @@ namespace SourceGit.Commands
{
public class Push : Command
{
- public Push(string repo, string local, string remote, string remoteBranch, bool withTags, bool checkSubmodules, bool track, bool force)
+ public Push(string repo, string local, string remote, string remoteBranch, bool withTags, bool checkSubmodules, bool track, bool force, bool noVerify)
{
_remote = remote;
@@ -22,6 +22,8 @@ public Push(string repo, string local, string remote, string remoteBranch, bool
builder.Append("-u ");
if (force)
builder.Append("--force-with-lease ");
+ if (noVerify)
+ builder.Append("--no-verify ");
builder.Append(remote).Append(' ').Append(local).Append(':').Append(remoteBranch);
Args = builder.ToString();
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/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/CommitGraph.cs b/src/Models/CommitGraph.cs
index 8557f271d..916a79b1b 100644
--- a/src/Models/CommitGraph.cs
+++ b/src/Models/CommitGraph.cs
@@ -14,6 +14,7 @@ public enum CommitGraphHighlighting
CurrentBranchOnly,
SelectedCommitsOnly,
CurrentBranchAndSelectedCommits,
+ SelectedCommitsOnlyFirstParent,
}
public class CommitGraph
@@ -70,7 +71,7 @@ public class Dot
public List Links { get; } = [];
public List Dots { get; } = [];
- public static CommitGraph Generate(List commits, bool recalculateMergeState, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits)
+ public static CommitGraph Generate(List commits, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits)
{
const double unitWidth = 12;
const double halfWidth = 6;
@@ -82,36 +83,19 @@ public static CommitGraph Generate(List commits, bool recalculateMergeSt
var ended = new List();
var offsetY = -halfHeight;
var colorPicker = new ColorPicker();
- var merged = new HashSet();
+ var defHighlighting = highlighting == CommitGraphHighlighting.All;
foreach (var commit in commits)
{
PathHelper major = null;
- // Update merge state of this commit.
- if (recalculateMergeState)
- {
- if (commit.IsMerged)
- {
- merged.Remove(commit.SHA);
- foreach (var p in commit.Parents)
- merged.Add(p);
- }
- else if (merged.Remove(commit.SHA))
- {
- commit.IsMerged = true;
- foreach (var p in commit.Parents)
- merged.Add(p);
- }
- }
-
// Update current y offset
offsetY += unitHeight;
// 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))
@@ -160,35 +144,34 @@ public static CommitGraph Generate(List commits, bool recalculateMergeSt
// Calculate highlighted state
if (!isHighlighted)
{
- if (highlighting == CommitGraphHighlighting.All)
- {
- isHighlighted = true;
- }
- else if (highlighting == CommitGraphHighlighting.CurrentBranchOnly)
- {
- isHighlighted = commit.IsMerged;
- }
- else if (highlighting == CommitGraphHighlighting.SelectedCommitsOnly)
- {
- isHighlighted = highlightExtraCommits.Remove(commit.SHA);
- if (isHighlighted)
- {
- foreach (var p in commit.Parents)
- highlightExtraCommits.Add(p);
- }
- }
- else
+ switch (highlighting)
{
- if (commit.IsMerged)
- {
- isHighlighted = true;
- }
- else if (highlightExtraCommits.Remove(commit.SHA))
- {
- isHighlighted = true;
- foreach (var p in commit.Parents)
- highlightExtraCommits.Add(p);
- }
+ 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;
@@ -227,6 +210,9 @@ public static CommitGraph Generate(List commits, bool recalculateMergeSt
// Deal with other parents (the first parent has been processed)
if (!firstParentOnlyEnabled)
{
+ if (highlighting == CommitGraphHighlighting.SelectedCommitsOnlyFirstParent)
+ isHighlighted = false;
+
for (int j = 1; j < commit.Parents.Count; j++)
{
var parentHash = commit.Parents[j];
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/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/IpcChannel.cs b/src/Models/IpcChannel.cs
index 86c8167ae..1a169d2ac 100644
--- a/src/Models/IpcChannel.cs
+++ b/src/Models/IpcChannel.cs
@@ -10,16 +10,54 @@ namespace SourceGit.Models
{
public class IpcChannel : IDisposable
{
- public bool IsFirstInstance { get; }
+ public bool IsFirstInstance { get; private set; }
public event Action MessageReceived;
public IpcChannel()
{
- try
+ IsFirstInstance = false;
+
+ var lockFile = GetLockFilePath();
+ _lockFilePath = lockFile.Path;
+
+ if (OperatingSystem.IsLinux() && lockFile.NeedChangePermissions)
+ {
+ // On Linux, if the lock file is created in the XDG_RUNTIME_DIR, we need to set the permissions to 700 (rwx------) to ensure that only the current user can access it.
+ try
+ {
+ _singletonLock = new FileStream(_lockFilePath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None);
+ File.SetUnixFileMode(_lockFilePath, File.GetUnixFileMode(_lockFilePath) | UnixFileMode.StickyBit);
+ IsFirstInstance = true;
+ }
+ catch
+ {
+ try
+ {
+ _singletonLock = new FileStream(_lockFilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
+ IsFirstInstance = true;
+ }
+ catch
+ {
+ // Just ignore the exception and assume that another instance is running.
+ }
+ }
+ }
+ else
+ {
+ try
+ {
+ _singletonLock = File.Open(_lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
+ IsFirstInstance = true;
+ }
+ catch
+ {
+ // Just ignore the exception and assume that another instance is running.
+ }
+ }
+
+ if (IsFirstInstance)
{
- _singletonLock = File.Open(Path.Combine(Native.OS.DataDir, "process.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
- IsFirstInstance = true;
_server = new NamedPipeServerStream(
GetPipeName(),
PipeDirection.In,
@@ -29,10 +67,6 @@ public IpcChannel()
_cancellationTokenSource = new CancellationTokenSource();
Task.Run(StartServer);
}
- catch
- {
- IsFirstInstance = false;
- }
}
public void SendToFirstInstance(string cmd)
@@ -67,16 +101,42 @@ public void Dispose()
{
_cancellationTokenSource?.Cancel();
_singletonLock?.Dispose();
+
+ if (IsFirstInstance && File.Exists(_lockFilePath))
+ File.Delete(_lockFilePath);
}
- private static string GetPipeName()
+ private LockFile GetLockFilePath()
+ {
+ // On Windows and macOS, we can use the cache directory for the lock file.
+ if (!OperatingSystem.IsLinux())
+ return new LockFile(Path.Combine(Native.OS.BasicDirectories.CacheDir, "process.lock"), false);
+
+ // On Linux, we should first check if the app is running in 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 new LockFile(Path.Combine(portableDir, "process.lock"), false);
+ }
+
+ // If not in portable mode, we should use the XDG_RUNTIME_DIR environment variable for the lock file.
+ var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR");
+ if (string.IsNullOrEmpty(runtimeDir) || !Directory.Exists(runtimeDir))
+ return new LockFile(Path.Combine(Native.OS.BasicDirectories.CacheDir, "process.lock"), false);
+
+ return new LockFile(Path.Combine(runtimeDir, "sourcegit.instance.lock"), true);
+ }
+
+ private string GetPipeName()
{
// SourceGit does not support multiple instances on macOS, so we can use a fixed pipe name for macOS.
if (OperatingSystem.IsMacOS())
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}";
@@ -108,6 +168,9 @@ private async void StartServer()
}
}
+ private record LockFile(string Path, bool NeedChangePermissions);
+
+ private string _lockFilePath = null;
private FileStream _singletonLock = null;
private NamedPipeServerStream _server = null;
private CancellationTokenSource _cancellationTokenSource = null;
diff --git a/src/Models/RefName.cs b/src/Models/RefName.cs
new file mode 100644
index 000000000..42ad6d785
--- /dev/null
+++ b/src/Models/RefName.cs
@@ -0,0 +1,84 @@
+using System;
+
+namespace SourceGit.Models
+{
+ ///
+ /// Validates branch and tag names using the same rules that git itself enforces,
+ /// mirroring `git check-ref-format --allow-onelevel`. Rules are taken from the git-check-ref-format documentation.
+ /// Rule 2 (a refname must contain at least one slash) is intentionally waived, matching `--allow-onelevel`.
+ ///
+ public static class RefName
+ {
+ public static bool IsValidBranchName(string name)
+ {
+ if (string.Equals(name, "HEAD", StringComparison.Ordinal))
+ return false;
+
+ return IsValidRefName(name);
+ }
+
+ public static bool IsValidTagName(string name) => IsValidRefName(name);
+
+ private static bool IsValidRefName(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ return false;
+
+ // Anything starting with '-' is treated as a CLI option.
+ if (name.StartsWith('-'))
+ return false;
+
+ // Rule 9: cannot be the single character '@'.
+ if (name.Equals("@", StringComparison.Ordinal))
+ return false;
+
+ // Rule 6: cannot begin or end with '/', or contain consecutive slashes.
+ if (name[0] == '/' || name[^1] == '/' || name.Contains("//", StringComparison.Ordinal))
+ return false;
+
+ // Rule 7: cannot end with a dot.
+ if (name[^1] == '.')
+ return false;
+
+ // Rule 3: cannot contain two consecutive dots.
+ if (name.Contains("..", StringComparison.Ordinal))
+ return false;
+
+ // Rule 8: cannot contain the sequence '@{'.
+ if (name.Contains("@{", StringComparison.Ordinal))
+ return false;
+
+ // Rules 4, 5 & 10: no control chars, DEL, space, or ~ ^ : ? * [ \.
+ foreach (var ch in name)
+ {
+ if (ch is < ' ' or '\x7f')
+ return false;
+
+ switch (ch)
+ {
+ case ' ':
+ case '~':
+ case '^':
+ case ':':
+ case '?':
+ case '*':
+ case '[':
+ case '\\':
+ return false;
+ }
+ }
+
+ // Rule 1: no slash-separated component may begin with a dot or end with ".lock".
+ foreach (var component in name.Split('/'))
+ {
+ if (component[0] == '.')
+ return false;
+
+ if (component.EndsWith(".lock", StringComparison.Ordinal))
+ return false;
+ }
+
+ return 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/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/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/Native/Linux.cs b/src/Native/Linux.cs
index a28d88ebe..3d09f8266 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 });
@@ -35,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) && 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))
+ {
+ 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
+ }
+ }
+
+ return dirs;
}
public string FindGitExecutable()
@@ -138,6 +177,39 @@ public void OpenWithDefaultEditor(string file)
}
}
+ public bool SupportSetSid()
+ {
+ return true;
+ }
+
+ public string GetSetSidExecutable()
+ {
+ return "setsid";
+ }
+
+ public void TerminateProcess(Process proc)
+ {
+ if (kill(-proc.Id, 15) != 0)
+ {
+ // If the process already exited, we can just ignore the error.
+ if (Marshal.GetLastPInvokeError() == 3 /* ESRCH */)
+ return;
+
+ // Actually, this will not be called since the process is
+ // spawned by us (EPERM will not happen), and SIGTERM (15)
+ // is a valid signal (EINVAL will not happen).
+ // See https://www.man7.org/linux/man-pages/man2/kill.2.html
+ try
+ {
+ proc.Kill(true);
+ }
+ catch
+ {
+ // Ignore any errors when trying to kill the process
+ }
+ }
+ }
+
private string FindExecutable(string filename)
{
var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
@@ -152,5 +224,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 54a2a8a72..fa4a1ae51 100644
--- a/src/Native/MacOS.cs
+++ b/src/Native/MacOS.cs
@@ -15,6 +15,14 @@ namespace SourceGit.Native
[SupportedOSPlatform("macOS")]
internal class MacOS : OS.IBackend
{
+ [DllImport("/usr/lib/libSystem.B.dylib", SetLastError = true)]
+ private static extern int kill(int pid, int sig);
+
+ public MacOS()
+ {
+ _setsidExecutable = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "setsid");
+ }
+
public void SetupApp(AppBuilder builder)
{
builder.With(new MacOSPlatformOptions()
@@ -29,7 +37,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 +55,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()
@@ -113,6 +129,47 @@ public void OpenWithDefaultEditor(string file)
{
Process.Start("open", file.Quoted());
}
+
+ public bool SupportSetSid()
+ {
+ return File.Exists(_setsidExecutable);
+ }
+
+ public string GetSetSidExecutable()
+ {
+ return _setsidExecutable;
+ }
+
+ public void TerminateProcess(Process proc)
+ {
+ if (!SupportSetSid())
+ {
+ proc.Kill(true);
+ return;
+ }
+
+ if (kill(-proc.Id, 15) != 0)
+ {
+ // If the process already exited, we can just ignore the error.
+ if (Marshal.GetLastPInvokeError() == 3 /* ESRCH */)
+ return;
+
+ // Actually, this will not be called since the process is
+ // spawned by us (EPERM will not happen), and SIGTERM (15)
+ // is a valid signal (EINVAL will not happen).
+ // See https://www.man7.org/linux/man-pages/man2/kill.2.html
+ try
+ {
+ proc.Kill(true);
+ }
+ catch
+ {
+ // Ignore any errors when trying to kill the process
+ }
+ }
+ }
+
+ private string _setsidExecutable = null;
}
[SupportedOSPlatform("macOS")]
diff --git a/src/Native/OS.cs b/src/Native/OS.cs
index ceaa69c84..741fcf844 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();
@@ -28,13 +34,17 @@ public interface IBackend
void OpenInFileManager(string path);
void OpenBrowser(string url);
void OpenWithDefaultEditor(string file);
+
+ bool SupportSetSid();
+ string GetSetSidExecutable();
+ void TerminateProcess(Process proc);
}
- public static string DataDir
+ public static Directories BasicDirectories
{
get;
private set;
- } = string.Empty;
+ } = new();
public static string GitExecutable
{
@@ -133,11 +143,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)
@@ -160,7 +168,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);
@@ -297,6 +305,21 @@ public static string GetRelativePathToHome(string path)
return path;
}
+ public static bool SupportSetSid()
+ {
+ return _backend.SupportSetSid();
+ }
+
+ public static string GetSetSidExecutable()
+ {
+ return _backend.GetSetSidExecutable();
+ }
+
+ public static void TerminateProcess(Process proc)
+ {
+ _backend.TerminateProcess(proc);
+ }
+
private static void UpdateGitVersion()
{
if (string.IsNullOrEmpty(_gitExecutable) || !File.Exists(_gitExecutable))
diff --git a/src/Native/Windows.cs b/src/Native/Windows.cs
index 2ec98ca40..fde6cb25a 100644
--- a/src/Native/Windows.cs
+++ b/src/Native/Windows.cs
@@ -29,6 +29,18 @@ internal class Windows : OS.IBackend
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int SHOpenFolderAndSelectItems(IntPtr pidlFolder, int cild, IntPtr apidl, int dwFlags);
+ [DllImport("kernel32.dll")]
+ private static extern bool SetConsoleCtrlHandler(IntPtr handler, bool add);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool AttachConsole(int dwProcessId);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, int dwProcessGroupId);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool FreeConsole();
+
public void SetupApp(AppBuilder builder)
{
// Do nothing for now.
@@ -42,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);
- return Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "SourceGit");
+ dirs.ConfigDir = appDataDir;
+ dirs.CacheDir = appDataDir;
+ }
+
+ return dirs;
}
public string FindGitExecutable()
@@ -196,6 +222,41 @@ public void OpenWithDefaultEditor(string file)
Process.Start(start);
}
+ public bool SupportSetSid()
+ {
+ return false;
+ }
+
+ public string GetSetSidExecutable()
+ {
+ return null;
+ }
+
+ public void TerminateProcess(Process proc)
+ {
+ var pid = proc.Id;
+ if (AttachConsole(pid))
+ {
+ SetConsoleCtrlHandler(IntPtr.Zero, true);
+ try
+ {
+ if (!GenerateConsoleCtrlEvent(0, 0))
+ proc.Kill(true);
+
+ proc.WaitForExit(2000);
+ }
+ finally
+ {
+ FreeConsole();
+ SetConsoleCtrlHandler(IntPtr.Zero, false);
+ }
+ }
+ else
+ {
+ proc.Kill(true);
+ }
+ }
+
#region HELPER_METHODS
private List GenerateVSProjectLaunchOptions(string path)
{
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/de_DE.axaml b/src/Resources/Locales/de_DE.axaml
index eeb9a43f7..1cc59d419 100644
--- a/src/Resources/Locales/de_DE.axaml
+++ b/src/Resources/Locales/de_DE.axaml
@@ -741,6 +741,7 @@ $1, $2, … Werte der Eingabe-Steuerelemente
Erzwinge Push
Lokaler Branch:
NEU
+ pre-push Hook überspringen
Remote:
Revision:
Revision zu Remote-Branch pushen
diff --git a/src/Resources/Locales/el_GR.axaml b/src/Resources/Locales/el_GR.axaml
index 2b40a9811..a39ce1094 100644
--- a/src/Resources/Locales/el_GR.axaml
+++ b/src/Resources/Locales/el_GR.axaml
@@ -740,6 +740,7 @@
Εξαναγκασμένο push
Τοπικός κλάδος:
ΝΕΟ
+ Παράκαμψη του pre-push hook
Απομακρυσμένο:
Αναθεώρηση:
Push αναθεώρησης σε απομακρυσμένο
diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml
index cb491133c..b1fba0749 100644
--- a/src/Resources/Locales/en_US.axaml
+++ b/src/Resources/Locales/en_US.axaml
@@ -518,6 +518,7 @@
Current Branch Only
Current Branch & Selected Commits
Selected Commits Only
+ Selected Commits (First-parent Only)
SELECTED {0} COMMITS
SHOW COLUMNS
Hold 'Ctrl' or 'Shift' to select multiple commits.
@@ -626,6 +627,8 @@
Open
Default Editor (System)
Open Data Storage Directory
+ Cache
+ Config
Open File
Open in External Merge Tool
Open Local Repository
@@ -742,6 +745,7 @@
Force push
Local Branch:
NEW
+ Bypass the pre-push hook
Remote:
Revision:
Push Revision To Remote
@@ -896,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
@@ -962,6 +981,7 @@
Delete selected {0} tags...
Merge ${0}$ into ${1}$...
Push ${0}$...
+ TERMINATE
Update Submodules
All submodules
Initialize as needed
diff --git a/src/Resources/Locales/es_ES.axaml b/src/Resources/Locales/es_ES.axaml
index 900c2db42..bf8e861e9 100644
--- a/src/Resources/Locales/es_ES.axaml
+++ b/src/Resources/Locales/es_ES.axaml
@@ -746,6 +746,7 @@
Forzar push
Rama Local:
NUEVO
+ Saltar el hook de pre-push
Remoto:
Revisión:
Push Revisión al Remoto
diff --git a/src/Resources/Locales/fr_FR.axaml b/src/Resources/Locales/fr_FR.axaml
index 1fcf148d9..d2d7c73c4 100644
--- a/src/Resources/Locales/fr_FR.axaml
+++ b/src/Resources/Locales/fr_FR.axaml
@@ -708,6 +708,7 @@
Poussage forcé
Branche locale :
NOUVEAU
+ Contourner le hook de pre-push
Dépôt distant :
Révision :
Pousser la Révision vers le Dépôt Distant
diff --git a/src/Resources/Locales/he_IL.axaml b/src/Resources/Locales/he_IL.axaml
index 988942439..98fbe616d 100644
--- a/src/Resources/Locales/he_IL.axaml
+++ b/src/Resources/Locales/he_IL.axaml
@@ -708,6 +708,7 @@
Force push
Branch מקומי:
חדש
+ דלג על pre-push hook
Remote:
Revision:
Push של Revision ל־Remote
diff --git a/src/Resources/Locales/id_ID.axaml b/src/Resources/Locales/id_ID.axaml
index 31c1bc55c..09f001013 100644
--- a/src/Resources/Locales/id_ID.axaml
+++ b/src/Resources/Locales/id_ID.axaml
@@ -741,6 +741,7 @@
Paksa push
Branch Lokal:
BARU
+ Lewati hook pre-push
Remote:
Revisi:
Push Revisi ke Remote
diff --git a/src/Resources/Locales/it_IT.axaml b/src/Resources/Locales/it_IT.axaml
index 59fba4f54..dd16b7150 100644
--- a/src/Resources/Locales/it_IT.axaml
+++ b/src/Resources/Locales/it_IT.axaml
@@ -653,6 +653,7 @@ ${pure_files:N} Come ${files:N}, ma senza cartelle
Forza l'invio
Branch Locale:
NUOVO
+ Ignora l'hook di pre-push
Remoto:
Revisione:
Invia Revisione Al Remoto
diff --git a/src/Resources/Locales/ja_JP.axaml b/src/Resources/Locales/ja_JP.axaml
index ef1389569..4f8ebbb31 100644
--- a/src/Resources/Locales/ja_JP.axaml
+++ b/src/Resources/Locales/ja_JP.axaml
@@ -740,6 +740,7 @@
強制的にプッシュ
ローカルブランチ:
新規
+ プッシュ前のフックを実行しない
リモート:
リビジョン:
リビジョンをリモートにプッシュ
diff --git a/src/Resources/Locales/ko_KR.axaml b/src/Resources/Locales/ko_KR.axaml
index cecfefb98..dda2e690b 100644
--- a/src/Resources/Locales/ko_KR.axaml
+++ b/src/Resources/Locales/ko_KR.axaml
@@ -712,6 +712,7 @@
강제 푸시
로컬 브랜치:
신규
+ pre-push 훅 건너뛰기
원격:
리비전:
리비전을 원격에 푸시
diff --git a/src/Resources/Locales/pt_BR.axaml b/src/Resources/Locales/pt_BR.axaml
index c4904c975..9fc78cdbb 100644
--- a/src/Resources/Locales/pt_BR.axaml
+++ b/src/Resources/Locales/pt_BR.axaml
@@ -464,6 +464,7 @@
Certifica de que submodules foram enviadas
Forçar push
Branch Local:
+ Ignorar o hook de pre-push
Remoto:
Empurrar Alterações para o Remoto
Branch Remoto:
diff --git a/src/Resources/Locales/ru_RU.axaml b/src/Resources/Locales/ru_RU.axaml
index 06fedff0d..c828a1373 100644
--- a/src/Resources/Locales/ru_RU.axaml
+++ b/src/Resources/Locales/ru_RU.axaml
@@ -746,6 +746,7 @@
Принудительно выложить
Локальная ветка:
НОВЫЙ
+ Обходной зацеп предварительного выкладывания
Внешний репозиторий:
Ревизия:
Выложить ревизию на удалёную
diff --git a/src/Resources/Locales/ta_IN.axaml b/src/Resources/Locales/ta_IN.axaml
index 9606d3f54..f33ac284b 100644
--- a/src/Resources/Locales/ta_IN.axaml
+++ b/src/Resources/Locales/ta_IN.axaml
@@ -463,6 +463,7 @@
துணைத் தொகுதிகள் தள்ளப்பட்டது என்பதை உறுதிசெய்
கட்டாயமாக தள்ளு
உள்ளக கிளை:
+ pre-push hook-ஐத் தவிர்
தொலை:
மாற்றங்களை தொலைக்கு தள்ளு
தொலை கிளை:
diff --git a/src/Resources/Locales/uk_UA.axaml b/src/Resources/Locales/uk_UA.axaml
index 47288eb9c..0749003f4 100644
--- a/src/Resources/Locales/uk_UA.axaml
+++ b/src/Resources/Locales/uk_UA.axaml
@@ -467,6 +467,7 @@
Переконатися, що підмодулі надіслано
Примусовий push
Локальна гілка:
+ Обхід pre-push hook
Віддалене сховище:
Надіслати зміни на віддалене сховище
Віддалена гілка:
diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml
index b1504d1b8..062cc0646 100644
--- a/src/Resources/Locales/zh_CN.axaml
+++ b/src/Resources/Locales/zh_CN.axaml
@@ -522,6 +522,7 @@
仅当前分支
当前分支和选中的提交
仅选中的提交
+ 仅选中的提交 (First-parent 模式)
已选中 {0} 项提交
显示列
可以按住 Ctrl 或 Shift 键选择多个提交
@@ -630,6 +631,8 @@
打开
系统默认编辑器
浏览应用数据目录
+ 缓存数据
+ 偏好设置数据
打开文件
使用外部对比工具查看
打开本地仓库
@@ -746,6 +749,7 @@
启用强制推送
本地分支 :
新建
+ 绕过 pre-push 钩子
远程仓库 :
修订 :
推送指定修订到远程仓库
@@ -900,6 +904,21 @@
跳转到提交
SSH密钥 :
SSH密钥文件
+ 打开SSH密钥工具
+ 您确定要删除以下SSH密钥吗?
+ SHA-256 指纹
+ 生成ED25519格式的SSH密钥
+ 再次确认口令:
+ 邮箱:
+ 必填项
+ 密钥文件名:
+ 必填项,如:id_github
+ 输入口令:
+ 生 成
+ 为私钥文件设置口令
+ 文件路径
+ 公钥内容
+ SSH密钥工具
开 始
贮藏(stash)
包含未跟踪的文件
@@ -966,6 +985,7 @@
删除选中 {0} 个标签...
合并 ${0}$ 到 ${1}$...
推送 ${0}$...
+ 终止运行
更新子模块
更新所有子模块
如未初始化子模块,先初始化
diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml
index 4b0bf0dc9..20343afae 100644
--- a/src/Resources/Locales/zh_TW.axaml
+++ b/src/Resources/Locales/zh_TW.axaml
@@ -522,6 +522,7 @@
僅目前分支
目前分支和選取的提交
僅選取的提交
+ 僅選取的提交 (First-parent 模式)
已選取 {0} 項提交
顯示欄位
可以按住 Ctrl 或 Shift 鍵選擇多個提交
@@ -630,6 +631,8 @@
開啟
系統預設編輯器
瀏覽程式資料目錄
+ 快取資料
+ 偏好設定資料
開啟檔案
使用外部比對工具檢視
開啟本機存放庫
@@ -746,6 +749,7 @@
啟用強制推送
本機分支:
新增
+ 繞過 pre-push hook
遠端存放庫:
修訂:
推送修訂到遠端存放庫
@@ -966,6 +970,7 @@
刪除所選的 {0} 個標籤...
合併 ${0}$ 到 ${1}$...
推送 ${0}$...
+ 終止執行
更新子模組
更新所有子模組
如果子模組尚未初始化,則將其初始化
diff --git a/src/ViewModels/CheckoutBranchFromStash.cs b/src/ViewModels/CheckoutBranchFromStash.cs
index d62f4f2df..a87561805 100644
--- a/src/ViewModels/CheckoutBranchFromStash.cs
+++ b/src/ViewModels/CheckoutBranchFromStash.cs
@@ -12,7 +12,6 @@ public Models.Stash Target
}
[Required(ErrorMessage = "Branch name is required!")]
- [RegularExpression(@"^[\w\-/\.#\+]+$", ErrorMessage = "Bad branch name format!")]
[CustomValidation(typeof(CheckoutBranchFromStash), nameof(ValidateBranchName))]
public string BranchName
{
@@ -30,6 +29,9 @@ public static ValidationResult ValidateBranchName(string name, ValidationContext
{
if (ctx.ObjectInstance is CheckoutBranchFromStash caller)
{
+ if (!Models.RefName.IsValidBranchName(name))
+ return new ValidationResult("Bad branch name format!");
+
foreach (var b in caller._repo.Branches)
{
if (b.FriendlyName.Equals(name, StringComparison.Ordinal))
diff --git a/src/ViewModels/Clone.cs b/src/ViewModels/Clone.cs
index e62937cbc..dc7cf4833 100644
--- a/src/ViewModels/Clone.cs
+++ b/src/ViewModels/Clone.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
+using System.Threading;
using System.Threading.Tasks;
namespace SourceGit.ViewModels
@@ -78,6 +79,7 @@ public bool InitAndUpdateSubmodules
public Clone(string pageId)
{
_pageId = pageId;
+ CanTerminate = true;
Groups = new List();
Groups.Add(new RepositoryNode { Name = "No Group (Uncategorized)", Id = string.Empty });
@@ -111,10 +113,14 @@ public override async Task Sure()
var log = new CommandLog("Clone");
Use(log);
+ _cancellation = new CancellationTokenSource();
+ var token = _cancellation.Token;
+
var succ = await new Commands.Clone(_pageId, _parentFolder, _remote, _local, _useSSH ? _sshKey : "", _extraArgs)
+ .WithCancellation(token)
.Use(log)
.ExecAsync();
- if (!succ)
+ if (!succ || token.IsCancellationRequested)
return false;
var path = _parentFolder;
@@ -146,11 +152,12 @@ public override async Task Sure()
.SetAsync("remote.origin.sshkey", _sshKey);
}
- if (InitAndUpdateSubmodules)
+ if (InitAndUpdateSubmodules && !token.IsCancellationRequested)
{
var submodules = await new Commands.QueryUpdatableSubmodules(path, true).GetResultAsync();
if (submodules.Count > 0)
await new Commands.Submodule(path)
+ .WithCancellation(token)
.Use(log)
.UpdateAsync(submodules, true, true, false);
}
@@ -175,9 +182,17 @@ public override async Task Sure()
Welcome.Instance.Refresh();
launcher.OpenRepositoryInTab(node, page);
+
+ _cancellation = null;
return true;
}
+ public override void Terminate()
+ {
+ // Just fire cancel event and UI will auto wait the `Sure` complete
+ var _ = _cancellation?.CancelAsync();
+ }
+
private void CollectGroups(List outs, List collections)
{
foreach (var node in collections)
@@ -199,5 +214,6 @@ private void CollectGroups(List outs, List colle
private string _extraArgs = string.Empty;
private RepositoryNode _selectedGroup = null;
private int _bookmark = 0;
+ private CancellationTokenSource _cancellation = null;
}
}
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/CreateBranch.cs b/src/ViewModels/CreateBranch.cs
index f180c94e9..f49c03e65 100644
--- a/src/ViewModels/CreateBranch.cs
+++ b/src/ViewModels/CreateBranch.cs
@@ -7,7 +7,6 @@ namespace SourceGit.ViewModels
public class CreateBranch : Popup
{
[Required(ErrorMessage = "Branch name is required!")]
- [RegularExpression(@"^[\w\-/\.#\+]+$", ErrorMessage = "Bad branch name format!")]
[CustomValidation(typeof(CreateBranch), nameof(ValidateBranchName))]
public string Name
{
@@ -115,6 +114,9 @@ public static ValidationResult ValidateBranchName(string name, ValidationContext
{
if (ctx.ObjectInstance is CreateBranch creator)
{
+ if (!Models.RefName.IsValidBranchName(name))
+ return new ValidationResult("Bad branch name format!");
+
if (!creator._allowOverwrite)
{
foreach (var b in creator._repo.Branches)
diff --git a/src/ViewModels/CreateTag.cs b/src/ViewModels/CreateTag.cs
index ff729aab9..6c15b8b89 100644
--- a/src/ViewModels/CreateTag.cs
+++ b/src/ViewModels/CreateTag.cs
@@ -13,7 +13,6 @@ public object BasedOn
}
[Required(ErrorMessage = "Tag name is required!")]
- [RegularExpression(@"^(?!\.)(?!/)(?!.*\.$)(?!.*/$)(?!.*\.\.)[\w\-\+\./]+$", ErrorMessage = "Bad tag name format!")]
[CustomValidation(typeof(CreateTag), nameof(ValidateTagName))]
public string TagName
{
@@ -74,6 +73,9 @@ public static ValidationResult ValidateTagName(string name, ValidationContext ct
{
if (ctx.ObjectInstance is CreateTag creator)
{
+ if (!Models.RefName.IsValidTagName(name))
+ return new ValidationResult("Bad tag name format!");
+
var found = creator._repo.Tags.Find(x => x.Name == name);
if (found != null)
return new ValidationResult("A tag with same name already exists!");
diff --git a/src/ViewModels/Fetch.cs b/src/ViewModels/Fetch.cs
index 3236a6421..b167e73b9 100644
--- a/src/ViewModels/Fetch.cs
+++ b/src/ViewModels/Fetch.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
namespace SourceGit.ViewModels
@@ -48,6 +49,7 @@ public Fetch(Repository repo, Models.Remote preferredRemote = null)
_repo = repo;
IsFetchAllRemoteVisible = repo.Remotes.Count > 1 && preferredRemote == null;
_fetchAllRemotes = IsFetchAllRemoteVisible && _repo.UIStates.FetchAllRemotes;
+ CanTerminate = true;
if (preferredRemote != null)
{
@@ -77,23 +79,33 @@ public override async Task Sure()
var log = _repo.CreateLog("Fetch");
Use(log);
+ _cancellation = new CancellationTokenSource();
+ var token = _cancellation.Token;
+
if (FetchAllRemotes)
{
foreach (var remote in _repo.Remotes)
+ {
await new Commands.Fetch(_repo.FullPath, remote.Name, notags, force)
+ .WithCancellation(token)
.Use(log)
.RunAsync();
+
+ if (token.IsCancellationRequested)
+ break;
+ }
}
else
{
await new Commands.Fetch(_repo.FullPath, SelectedRemote.Name, notags, force)
+ .WithCancellation(token)
.Use(log)
.RunAsync();
}
log.Complete();
- if (navigateToUpstreamHEAD)
+ if (navigateToUpstreamHEAD && !token.IsCancellationRequested)
{
var upstream = _repo.CurrentBranch?.Upstream;
if (!string.IsNullOrEmpty(upstream))
@@ -103,11 +115,21 @@ public override async Task Sure()
}
}
- _repo.MarkFetched();
+ if (!token.IsCancellationRequested)
+ _repo.MarkFetched();
+
+ _cancellation = null;
return true;
}
+ public override void Terminate()
+ {
+ // Just fire cancel event and UI will auto wait the `Sure` complete
+ var _ = _cancellation?.CancelAsync();
+ }
+
private readonly Repository _repo = null;
private bool _fetchAllRemotes = false;
+ private CancellationTokenSource _cancellation = null;
}
}
diff --git a/src/ViewModels/GitFlowStart.cs b/src/ViewModels/GitFlowStart.cs
index 2fe9c0784..68f9f88e4 100644
--- a/src/ViewModels/GitFlowStart.cs
+++ b/src/ViewModels/GitFlowStart.cs
@@ -32,7 +32,6 @@ public string Prefix
}
[Required(ErrorMessage = "Name is required!!!")]
- [RegularExpression(@"^[\w\-/\.#]+$", ErrorMessage = "Bad branch name format!")]
[CustomValidation(typeof(GitFlowStart), nameof(ValidateBranchName))]
public string Name
{
@@ -67,6 +66,9 @@ public static ValidationResult ValidateBranchName(string name, ValidationContext
{
if (ctx.ObjectInstance is GitFlowStart starter)
{
+ if (!Models.RefName.IsValidBranchName($"{starter.Prefix}{name}"))
+ return new ValidationResult("Bad branch name format!");
+
var check = $"{starter.Prefix}{name}";
foreach (var b in starter._repo.Branches)
{
diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs
index 15cf15748..fface0f67 100644
--- a/src/ViewModels/Histories.cs
+++ b/src/ViewModels/Histories.cs
@@ -75,7 +75,7 @@ public List Commits
get => _commits;
set
{
- GenerateGraph(value, true);
+ GenerateGraph(value);
if (SetProperty(ref _commits, value))
PostCommitsChanged();
}
@@ -129,7 +129,14 @@ public Models.Bisect Bisect
public Models.Branch CurrentBranch
{
- get => _repo.CurrentBranch;
+ get => _currentBranch;
+ set => SetProperty(ref _currentBranch, value);
+ }
+
+ public bool HasSingleRemote
+ {
+ get => _hasSingleRemote;
+ set => SetProperty(ref _hasSingleRemote, value);
}
public AvaloniaList IssueTrackers
@@ -195,11 +202,6 @@ public Histories(Repository repo)
_commitDetailSharedData = new CommitDetailSharedData();
}
- public void NotifyCurrentBranchChanged()
- {
- OnPropertyChanged(nameof(CurrentBranch));
- }
-
public Models.BisectState UpdateBisectInfo()
{
var test = Path.Combine(_repo.GitDir, "BISECT_START");
@@ -510,7 +512,7 @@ private void PostSelectedCommitsChanged()
GenerateGraph(_commits);
}
- private void GenerateGraph(List commits, bool commitsChanged = false)
+ private void GenerateGraph(List commits)
{
var firstParentOnly = _repo.UIStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly);
var highlighting = _repo.UIStates.GraphHighlighting;
@@ -522,10 +524,12 @@ private void GenerateGraph(List commits, bool commitsChanged = fa
extraHeads.Add(c.SHA);
}
- Graph = Models.CommitGraph.Generate(commits, commitsChanged, firstParentOnly, highlighting, extraHeads);
+ Graph = Models.CommitGraph.Generate(commits, firstParentOnly, highlighting, extraHeads);
}
private Repository _repo = null;
+ private Models.Branch _currentBranch = null;
+ private bool _hasSingleRemote = false;
private CommitDetailSharedData _commitDetailSharedData = null;
private bool _isLoading = true;
private List _commits = [];
diff --git a/src/ViewModels/InitGitFlow.cs b/src/ViewModels/InitGitFlow.cs
index f00ad01dd..798120b55 100644
--- a/src/ViewModels/InitGitFlow.cs
+++ b/src/ViewModels/InitGitFlow.cs
@@ -12,7 +12,6 @@ public partial class InitGitFlow : Popup
private static partial Regex REG_TAG_PREFIX();
[Required(ErrorMessage = "Production branch name is required!!!")]
- [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")]
[CustomValidation(typeof(InitGitFlow), nameof(ValidateBaseBranch))]
public string Production
{
@@ -21,7 +20,6 @@ public string Production
}
[Required(ErrorMessage = "Development branch name is required!!!")]
- [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")]
[CustomValidation(typeof(InitGitFlow), nameof(ValidateBaseBranch))]
public string Develop
{
@@ -81,10 +79,13 @@ public InitGitFlow(Repository repo)
_production = "main";
}
- public static ValidationResult ValidateBaseBranch(string _, ValidationContext ctx)
+ public static ValidationResult ValidateBaseBranch(string name, ValidationContext ctx)
{
if (ctx.ObjectInstance is InitGitFlow initializer)
{
+ if (!Models.RefName.IsValidBranchName(name))
+ return new ValidationResult("Bad branch name format!");
+
if (initializer._production == initializer._develop)
return new ValidationResult("Develop branch has the same name with production branch!");
}
diff --git a/src/ViewModels/Launcher.cs b/src/ViewModels/Launcher.cs
index 7b17290a7..1cf00a848 100644
--- a/src/ViewModels/Launcher.cs
+++ b/src/ViewModels/Launcher.cs
@@ -373,6 +373,72 @@ public void OpenRepositoryInTab(RepositoryNode node, LauncherPage page)
ActivePage = page;
}
+ public void OpenSubRepository(LauncherPage ownerPage, string fullpath)
+ {
+ var normalizedPath = fullpath.Replace('\\', '/').TrimEnd('/');
+
+ // Check if the sub-repository is already open in any of the tabs
+ foreach (var one in Pages)
+ {
+ if (one.Node.Id.Equals(normalizedPath, StringComparison.Ordinal))
+ {
+ ActivePage = one;
+ return;
+ }
+ }
+
+ // Make sure the target directory exists
+ if (!Directory.Exists(normalizedPath))
+ {
+ ownerPage.Notifications.Add(new Models.Notification
+ {
+ Group = ownerPage.Node.Id,
+ Message = "Repository path does NOT exist. Please check the path.",
+ IsError = true,
+ });
+
+ return;
+ }
+
+ // Check if the target directory is a valid git repository
+ var gitDir = GetRepositoryGitDir(normalizedPath);
+ if (string.IsNullOrEmpty(gitDir))
+ {
+ ownerPage.Notifications.Add(new Models.Notification
+ {
+ Group = ownerPage.Node.Id,
+ Message = "Given path is not a valid git repository!",
+ IsError = true,
+ });
+ return;
+ }
+
+ // Get the owner repository's name and extract the pure owner name (without any prefix)
+ var ownerName = ownerPage.Node.Name;
+ var colonIdx = ownerName.LastIndexOf(':');
+ var pureOwnerName = (colonIdx >= 0 && colonIdx < ownerName.Length - 1) ? ownerName.Substring(colonIdx + 1).Trim() : ownerName;
+
+ // Find the sub-repository node in the preferences or create a new one if it doesn't exist
+ var node = Preferences.Instance.FindNode(normalizedPath) ?? new RepositoryNode
+ {
+ Id = normalizedPath,
+ Name = $"{pureOwnerName}: {Path.GetFileName(normalizedPath)}",
+ Bookmark = 0,
+ IsRepository = true,
+ IsUnmanaged = true
+ };
+ node.LoadMinimalInfo(gitDir);
+
+ var repo = new Repository(false, node.Id, gitDir);
+ repo.Open();
+
+ var page = new LauncherPage(node, repo);
+ var idxOfOwner = Pages.IndexOf(ownerPage);
+ Pages.Insert(idxOfOwner + 1, page);
+ _activeWorkspace.Repositories.Insert(idxOfOwner + 1, normalizedPath);
+ ActivePage = page;
+ }
+
private void DispatchNotification(Models.Notification notification)
{
if (!Dispatcher.UIThread.CheckAccess())
diff --git a/src/ViewModels/LauncherPage.cs b/src/ViewModels/LauncherPage.cs
index db5e0241b..370e5a25c 100644
--- a/src/ViewModels/LauncherPage.cs
+++ b/src/ViewModels/LauncherPage.cs
@@ -114,6 +114,14 @@ public void CancelPopup()
Popup = null;
}
+ public void TerminatePopup()
+ {
+ if (_popup is not { CanTerminate: true, InProgress: true })
+ return;
+
+ _popup.Terminate();
+ }
+
private RepositoryNode _node = null;
private object _data = null;
private Models.DirtyState _dirtyState = Models.DirtyState.None;
diff --git a/src/ViewModels/Popup.cs b/src/ViewModels/Popup.cs
index 9d800c50c..10bc44fe1 100644
--- a/src/ViewModels/Popup.cs
+++ b/src/ViewModels/Popup.cs
@@ -1,4 +1,5 @@
-using System.Diagnostics.CodeAnalysis;
+using System;
+using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -6,6 +7,16 @@ namespace SourceGit.ViewModels
{
public class Popup : ObservableValidator, Models.ICommandLogReceiver
{
+ public bool CanTerminate
+ {
+ get => _canTerminate;
+ protected set
+ {
+ if (OperatingSystem.IsWindows() || Native.OS.SupportSetSid())
+ SetProperty(ref _canTerminate, value);
+ }
+ }
+
public bool InProgress
{
get => _inProgress;
@@ -49,12 +60,17 @@ public virtual Task Sure()
return null;
}
+ public virtual void Terminate()
+ {
+ }
+
protected void Use(CommandLog log)
{
_log = log;
_log.Subscribe(this);
}
+ private bool _canTerminate = false;
private bool _inProgress = false;
private string _progressDescription = string.Empty;
private CommandLog _log = null;
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/ViewModels/Pull.cs b/src/ViewModels/Pull.cs
index 367bca40d..877b86df2 100644
--- a/src/ViewModels/Pull.cs
+++ b/src/ViewModels/Pull.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
+using System.Threading;
using System.Threading.Tasks;
namespace SourceGit.ViewModels
@@ -59,6 +60,7 @@ public Pull(Repository repo, Models.Branch specifiedRemoteBranch)
{
_repo = repo;
Current = repo.CurrentBranch;
+ CanTerminate = true;
DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ?
Models.DealWithLocalChanges.StashAndReapply :
@@ -116,6 +118,9 @@ public override async Task Sure()
var log = _repo.CreateLog("Pull");
Use(log);
+ _cancellation = new CancellationTokenSource();
+ var token = _cancellation.Token;
+
var changes = await new Commands.CountLocalChanges(_repo.FullPath, false).GetResultAsync();
var needPopStash = false;
if (changes > 0)
@@ -131,6 +136,7 @@ public override async Task Sure()
{
log.Complete();
_repo.MarkWorkingCopyDirtyManually();
+ _cancellation = null;
return false;
}
@@ -142,17 +148,25 @@ public override async Task Sure()
}
}
- bool rs = await new Commands.Pull(
- _repo.FullPath,
- _selectedRemote.Name,
- !string.IsNullOrEmpty(Current.Upstream) && Current.Upstream.Equals(_selectedBranch.FullName) ? string.Empty : _selectedBranch.Name,
- UseRebase).Use(log).RunAsync();
- if (rs)
+ bool rs = false;
+ if (!token.IsCancellationRequested)
{
- await _repo.AutoUpdateSubmodulesAsync(log);
+ var target = !string.IsNullOrEmpty(Current.Upstream) && Current.Upstream.Equals(_selectedBranch.FullName)
+ ? string.Empty
+ : _selectedBranch.Name;
+ rs = await new Commands.Pull(
+ _repo.FullPath,
+ _selectedRemote.Name,
+ target,
+ UseRebase).WithCancellation(token).Use(log).RunAsync();
+
+ if (rs)
+ {
+ await _repo.AutoUpdateSubmodulesAsync(log);
- if (needPopStash)
- await new Commands.Stash(_repo.FullPath).Use(log).PopAsync("stash@{0}");
+ if (needPopStash)
+ await new Commands.Stash(_repo.FullPath).Use(log).PopAsync("stash@{0}");
+ }
}
log.Complete();
@@ -163,9 +177,16 @@ public override async Task Sure()
_repo.NavigateToCommit(head, true);
}
+ _cancellation = null;
return rs;
}
+ public override void Terminate()
+ {
+ // Just fire cancel event and UI will auto wait the `Sure` complete
+ var _ = _cancellation?.CancelAsync();
+ }
+
private void PostRemoteSelected()
{
var remoteName = _selectedRemote.Name;
@@ -214,5 +235,6 @@ private void PostRemoteSelected()
private Models.Remote _selectedRemote = null;
private List _remoteBranches = null;
private Models.Branch _selectedBranch = null;
+ private CancellationTokenSource _cancellation = null;
}
}
diff --git a/src/ViewModels/Push.cs b/src/ViewModels/Push.cs
index b4d9029a9..014649b9d 100644
--- a/src/ViewModels/Push.cs
+++ b/src/ViewModels/Push.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
+using System.Threading;
using System.Threading.Tasks;
namespace SourceGit.ViewModels
@@ -97,9 +98,16 @@ public bool ForcePush
set;
}
+ public bool NoVerify
+ {
+ get;
+ set;
+ }
+
public Push(Repository repo, Models.Branch localBranch)
{
_repo = repo;
+ CanTerminate = true;
// Gather all local branches and find current branch.
LocalBranches = new List();
@@ -196,6 +204,9 @@ public override async Task Sure()
var log = _repo.CreateLog("Push");
Use(log);
+ _cancellation = new CancellationTokenSource();
+ var token = _cancellation.Token;
+
var succ = await new Commands.Push(
_repo.FullPath,
_selectedLocalBranch.Name,
@@ -204,12 +215,21 @@ public override async Task Sure()
PushAllTags,
_repo.Submodules.Count > 0 && CheckSubmodules,
_isSetTrackOptionVisible && _tracking,
- ForcePush).Use(log).RunAsync();
+ ForcePush,
+ NoVerify).WithCancellation(token).Use(log).RunAsync();
log.Complete();
+
+ _cancellation = null;
return succ;
}
+ public override void Terminate()
+ {
+ // Just fire cancel event and UI will auto wait the `Sure` complete
+ var _ = _cancellation?.CancelAsync();
+ }
+
private void AutoSelectBranchByRemote()
{
if (_selectedRemote == null || _selectedLocalBranch == null)
@@ -266,5 +286,6 @@ private void AutoSelectBranchByRemote()
private Models.Branch _selectedRemoteBranch = null;
private bool _isSetTrackOptionVisible = false;
private bool _tracking = true;
+ private CancellationTokenSource _cancellation = null;
}
}
diff --git a/src/ViewModels/PushRevision.cs b/src/ViewModels/PushRevision.cs
index 4d49b5272..f0d2e46bc 100644
--- a/src/ViewModels/PushRevision.cs
+++ b/src/ViewModels/PushRevision.cs
@@ -1,4 +1,5 @@
using System;
+using System.Threading;
using System.Threading.Tasks;
namespace SourceGit.ViewModels
@@ -27,6 +28,7 @@ public PushRevision(Repository repo, Models.Commit revision, Models.Branch remot
Revision = revision;
RemoteBranch = remoteBranch;
Force = false;
+ CanTerminate = true;
}
public override async Task Sure()
@@ -37,6 +39,9 @@ public override async Task Sure()
var log = _repo.CreateLog("Push Revision");
Use(log);
+ _cancellation = new CancellationTokenSource();
+ var token = _cancellation.Token;
+
var succ = await new Commands.Push(
_repo.FullPath,
Revision.SHA,
@@ -45,12 +50,22 @@ public override async Task Sure()
false,
false,
false,
- Force).Use(log).RunAsync();
+ Force,
+ false).WithCancellation(token).Use(log).RunAsync();
log.Complete();
+
+ _cancellation = null;
return succ;
}
+ public override void Terminate()
+ {
+ // Just fire cancel event and UI will auto wait the `Sure` complete
+ var _ = _cancellation?.CancelAsync();
+ }
+
private readonly Repository _repo;
+ private CancellationTokenSource _cancellation = null;
}
}
diff --git a/src/ViewModels/PushToNewBranch.cs b/src/ViewModels/PushToNewBranch.cs
new file mode 100644
index 000000000..95f55f30c
--- /dev/null
+++ b/src/ViewModels/PushToNewBranch.cs
@@ -0,0 +1,44 @@
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace SourceGit.ViewModels
+{
+ public class PushToNewBranch : ObservableValidator
+ {
+ public string Remote
+ {
+ get;
+ }
+
+ [Required(ErrorMessage = "Branch name is required!")]
+ [CustomValidation(typeof(PushToNewBranch), nameof(ValidateBranchName))]
+ public string BranchName
+ {
+ get => _branchName;
+ set => SetProperty(ref _branchName, value, true);
+ }
+
+ public PushToNewBranch(string remote)
+ {
+ Remote = remote;
+ }
+
+ public static ValidationResult ValidateBranchName(string name, ValidationContext ctx)
+ {
+ if (!Models.RefName.IsValidBranchName(name))
+ return new ValidationResult("Bad branch name format!");
+
+ return ValidationResult.Success;
+ }
+
+ [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode")]
+ public bool Check()
+ {
+ ValidateAllProperties();
+ return !HasErrors;
+ }
+
+ private string _branchName = string.Empty;
+ }
+}
diff --git a/src/ViewModels/RenameBranch.cs b/src/ViewModels/RenameBranch.cs
index dbca651e7..72d1a4115 100644
--- a/src/ViewModels/RenameBranch.cs
+++ b/src/ViewModels/RenameBranch.cs
@@ -12,7 +12,6 @@ public Models.Branch Target
}
[Required(ErrorMessage = "Branch name is required!!!")]
- [RegularExpression(@"^[\w\-/\.#\+]+$", ErrorMessage = "Bad branch name format!")]
[CustomValidation(typeof(RenameBranch), nameof(ValidateBranchName))]
public string Name
{
@@ -31,6 +30,9 @@ public static ValidationResult ValidateBranchName(string name, ValidationContext
{
if (ctx.ObjectInstance is RenameBranch rename)
{
+ if (!Models.RefName.IsValidBranchName(name))
+ return new ValidationResult("Bad branch name format!");
+
foreach (var b in rename._repo.Branches)
{
if (b.IsLocal && b != rename.Target && b.Name.Equals(name, StringComparison.Ordinal))
diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs
index 526a9dbd2..b4b7af7e9 100644
--- a/src/ViewModels/Repository.cs
+++ b/src/ViewModels/Repository.cs
@@ -158,7 +158,14 @@ public string Filter
public List Remotes
{
get => _remotes;
- private set => SetProperty(ref _remotes, value);
+ private set
+ {
+ if (SetProperty(ref _remotes, value))
+ {
+ if (_histories != null)
+ _histories.HasSingleRemote = value != null && value.Count == 1;
+ }
+ }
}
public List Branches
@@ -175,7 +182,9 @@ private set
var oldHead = _currentBranch?.Head;
if (SetProperty(ref _currentBranch, value))
{
- _histories?.NotifyCurrentBranchChanged();
+ if (_histories != null)
+ _histories.CurrentBranch = value;
+
if (value != null && !value.Head.Equals(oldHead, StringComparison.Ordinal) && _workingCopy is { UseAmend: true })
_workingCopy.UseAmend = false;
}
@@ -570,7 +579,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;
@@ -1169,6 +1178,8 @@ public void RefreshBranches()
LocalBranchTrees = builder.Locals;
RemoteBranchTrees = builder.Remotes;
+ ValidateHistoryFilters(true);
+
var localBranchesCount = 0;
foreach (var b in branches)
{
@@ -1214,6 +1225,7 @@ public void RefreshTags()
Tags = tags;
VisibleTags = BuildVisibleTags();
+ ValidateHistoryFilters(false);
});
}, token);
}
@@ -1239,6 +1251,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)
@@ -1551,9 +1576,8 @@ public void OpenSubmodule(string submodule)
if (selfPage == null)
return;
- var root = Path.GetFullPath(Path.Combine(FullPath, submodule));
- var normalizedPath = root.Replace('\\', '/').TrimEnd('/');
- App.GetLauncher().OpenRepositoryInTab(normalizedPath, null);
+ var fullpath = Path.GetFullPath(Path.Combine(FullPath, submodule));
+ App.GetLauncher().OpenSubRepository(selfPage, fullpath);
}
public void AddWorktree()
@@ -1841,6 +1865,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/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/ViewModels/WorkingCopy.cs b/src/ViewModels/WorkingCopy.cs
index 3ab5ceee8..fe8ab16be 100644
--- a/src/ViewModels/WorkingCopy.cs
+++ b/src/ViewModels/WorkingCopy.cs
@@ -231,11 +231,8 @@ public WorkingCopy(Repository repo)
public void SetData(List changes)
{
- do
+ if (!IsChanged(_cached, changes))
{
- if (IsChanged(_cached, changes))
- break;
-
if (_useAmend)
{
var testStaged = GetStagedChanges(_cached);
@@ -269,7 +266,7 @@ public void SetData(List changes)
UpdateInProgressState();
UpdateDetail();
return;
- } while (false);
+ }
var lastSelectedUnstaged = new HashSet();
if (_selectedUnstaged is { Count: > 0 })
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));
diff --git a/src/Views/CommitRefsPresenter.cs b/src/Views/CommitRefsPresenter.cs
index 51aae1359..85e33962a 100644
--- a/src/Views/CommitRefsPresenter.cs
+++ b/src/Views/CommitRefsPresenter.cs
@@ -121,6 +121,15 @@ public bool UseCompactBranchNames
set => SetValue(UseCompactBranchNamesProperty, value);
}
+ public static readonly StyledProperty HasSingleRemoteProperty =
+ AvaloniaProperty.Register(nameof(HasSingleRemote));
+
+ public bool HasSingleRemote
+ {
+ get => GetValue(HasSingleRemoteProperty);
+ set => SetValue(HasSingleRemoteProperty, value);
+ }
+
public static readonly StyledProperty UseGraphColorProperty =
AvaloniaProperty.Register(nameof(UseGraphColor));
@@ -172,6 +181,8 @@ public override void Render(DrawingContext context)
var allowWrap = AllowWrap;
var x = 1.5;
var y = 0.5;
+ var remoteIcon = CommitRefsIconCache.Instance.GetIcon(Models.DecoratorType.RemoteBranchHead);
+ var hasSingleRemote = HasSingleRemote;
context.FillRectangle(Brushes.Transparent, Bounds);
@@ -211,22 +222,31 @@ public override void Render(DrawingContext context)
if (item.Remotes.Count > 0)
{
var rx = x + 20 + item.Label.WidthIncludingTrailingWhitespace + 4;
- foreach (var remote in item.Remotes)
+
+ if (hasSingleRemote)
{
context.DrawLine(new Pen(item.Brush), new Point(rx, y), new Point(rx, y + 16));
- context.DrawText(remote, new Point(rx + 4, y + 8.0 - remote.Height * 0.5));
- rx += remote.WidthIncludingTrailingWhitespace + 9;
+ using (context.PushTransform(Matrix.CreateTranslation(rx + 4, y + 4)))
+ context.DrawGeometry(fg, null, remoteIcon);
+ }
+ else
+ {
+ foreach (var remote in item.Remotes)
+ {
+ context.DrawLine(new Pen(item.Brush), new Point(rx, y), new Point(rx, y + 16));
+ using (context.PushTransform(Matrix.CreateTranslation(rx + 4, y + 4)))
+ context.DrawGeometry(fg, null, remoteIcon);
+ context.DrawText(remote, new Point(rx + 16, y + 8.0 - remote.Height * 0.5));
+ rx += remote.WidthIncludingTrailingWhitespace + 22;
+ }
}
}
context.DrawRectangle(null, new Pen(item.Brush), entireRect);
var icon = CommitRefsIconCache.Instance.GetIcon(item.Decorator.Type);
- if (icon != null)
- {
- using (context.PushTransform(Matrix.CreateTranslation(x + 3, y + 3)))
- context.DrawGeometry(fg, null, icon);
- }
+ using (context.PushTransform(Matrix.CreateTranslation(x + 3, y + 3)))
+ context.DrawGeometry(fg, null, icon);
x += item.Width + 4;
}
@@ -241,6 +261,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
change.Property == ForegroundProperty ||
change.Property == UseGraphColorProperty ||
change.Property == UseCompactBranchNamesProperty ||
+ change.Property == HasSingleRemoteProperty ||
change.Property == BackgroundProperty ||
change.Property == ShowTagsProperty)
InvalidateMeasure();
@@ -268,6 +289,7 @@ protected override Size MeasureOverride(Size availableSize)
}
var useCompactBranchNames = UseCompactBranchNames;
+ var hasSingleRemote = HasSingleRemote;
var typeface = new Typeface(FontFamily);
var typefaceHead = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold);
var typefaceRemote = new Typeface(FontFamily, FontStyle.Italic, FontWeight.Bold);
@@ -337,7 +359,7 @@ protected override Size MeasureOverride(Size availableSize)
if (decorator.Name.Equals(name, StringComparison.Ordinal))
{
var remote = new FormattedText(
- $"+{test.Name.Substring(0, idxOfSlash)}",
+ test.Name.Substring(0, idxOfSlash),
CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
typefaceRemote,
@@ -345,7 +367,12 @@ protected override Size MeasureOverride(Size availableSize)
fg);
item.Remotes.Add(remote);
- item.Width += remote.Width + 9;
+
+ if (hasSingleRemote)
+ item.Width += 18;
+ else
+ item.Width += remote.WidthIncludingTrailingWhitespace + 22;
+
skippedIdx.Add(j);
}
}
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 @@
-
+
-
-
-
-
-
-
-
+ UseCompactBranchNames="{Binding Source={x:Static vm:Preferences.Instance}, Path=UseCompactBranchNamesInGraph}"
+ HasSingleRemote="{Binding $parent[v:Histories].HasSingleRemote}"/>
SetAndRaise(BisectProperty, ref _bisect, value);
}
+ public static readonly DirectProperty HasSingleRemoteProperty =
+ AvaloniaProperty.RegisterDirect(
+ nameof(HasSingleRemote),
+ static o => o.HasSingleRemote,
+ static (o, v) => o.HasSingleRemote = v);
+
+ public bool HasSingleRemote
+ {
+ get => _hasSingleRemote;
+ set => SetAndRaise(HasSingleRemoteProperty, ref _hasSingleRemote, value);
+ }
+
public static readonly DirectProperty> IssueTrackersProperty =
AvaloniaProperty.RegisterDirect>(
nameof(IssueTrackers),
@@ -1734,9 +1746,9 @@ private async Task InteractiveRebaseWithPrefillActionAsync(ViewModels.Repository
await this.ShowDialogAsync(new ViewModels.InteractiveRebase(repo, on, prefill));
}
-
private Models.Branch _currentBranch = null;
private Models.Bisect _bisect = null;
+ private bool _hasSingleRemote = false;
private AvaloniaList _issueTrackers = null;
private bool _isScrollToTopVisible = false;
private bool _isDetailsPanelExpanded = true;
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/Launcher.axaml b/src/Views/Launcher.axaml
index e3cc1eb89..00aa09a22 100644
--- a/src/Views/Launcher.axaml
+++ b/src/Views/Launcher.axaml
@@ -42,15 +42,28 @@
-
-
+
-
+
-
+
-
-
+
diff --git a/src/Views/Repository.axaml.cs b/src/Views/Repository.axaml.cs
index f5bac41d8..ecee53c52 100644
--- a/src/Views/Repository.axaml.cs
+++ b/src/Views/Repository.axaml.cs
@@ -487,6 +487,16 @@ private void OnOpenAdvancedHistoriesOption(object sender, RoutedEventArgs e)
ev.Handled = true;
};
+ var selectedCommitsOnlyFirstParent = new MenuItem();
+ selectedCommitsOnlyFirstParent.Header = App.Text("Histories.HighlightsInGraph.SelectedCommitsOnlyFirstParent");
+ if (histories.GraphHighlighting == Models.CommitGraphHighlighting.SelectedCommitsOnlyFirstParent)
+ selectedCommitsOnlyFirstParent.Icon = this.CreateMenuIcon("Icons.Check");
+ selectedCommitsOnlyFirstParent.Click += (_, ev) =>
+ {
+ histories.GraphHighlighting = Models.CommitGraphHighlighting.SelectedCommitsOnlyFirstParent;
+ ev.Handled = true;
+ };
+
var currentBranchAndSelectedCommits = new MenuItem();
currentBranchAndSelectedCommits.Header = App.Text("Histories.HighlightsInGraph.CurrentBranchAndSelectedCommits");
if (histories.GraphHighlighting == Models.CommitGraphHighlighting.CurrentBranchAndSelectedCommits)
@@ -516,6 +526,7 @@ private void OnOpenAdvancedHistoriesOption(object sender, RoutedEventArgs e)
menu.Items.Add(all);
menu.Items.Add(currentBranchOnly);
menu.Items.Add(selectedCommitsOnly);
+ menu.Items.Add(selectedCommitsOnlyFirstParent);
menu.Items.Add(currentBranchAndSelectedCommits);
menu.Open(button);
}
diff --git a/src/Views/RevisionFileContentViewer.axaml b/src/Views/RevisionFileContentViewer.axaml
index ba2812661..fee9d4eea 100644
--- a/src/Views/RevisionFileContentViewer.axaml
+++ b/src/Views/RevisionFileContentViewer.axaml
@@ -28,7 +28,6 @@
diff --git a/src/Views/RevisionFileContentViewer.axaml.cs b/src/Views/RevisionFileContentViewer.axaml.cs
index 262286d85..fa2ecfec4 100644
--- a/src/Views/RevisionFileContentViewer.axaml.cs
+++ b/src/Views/RevisionFileContentViewer.axaml.cs
@@ -24,15 +24,6 @@ public int TabWidth
set => SetValue(TabWidthProperty, value);
}
- public static readonly StyledProperty UseSyntaxHighlightingProperty =
- AvaloniaProperty.Register(nameof(UseSyntaxHighlighting));
-
- public bool UseSyntaxHighlighting
- {
- get => GetValue(UseSyntaxHighlightingProperty);
- set => SetValue(UseSyntaxHighlightingProperty, value);
- }
-
protected override Type StyleKeyOverride => typeof(TextEditor);
public RevisionTextFileView() : base(new TextArea(), new TextDocument())
@@ -57,7 +48,10 @@ protected override void OnLoaded(RoutedEventArgs e)
base.OnLoaded(e);
TextArea.TextView.ContextRequested += OnTextViewContextRequested;
- UpdateTextMate();
+
+ _textMate ??= Models.TextMateHelper.CreateForEditor(this);
+ if (DataContext is Models.RevisionTextFile file)
+ Models.TextMateHelper.SetGrammarByFileName(_textMate, file.FileName);
}
protected override void OnUnloaded(RoutedEventArgs e)
@@ -97,8 +91,6 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
if (change.Property == TabWidthProperty)
Options.IndentationSize = TabWidth;
- else if (change.Property == UseSyntaxHighlightingProperty)
- UpdateTextMate();
else if (change.Property.Name == nameof(ActualThemeVariant) && change.NewValue != null)
Models.TextMateHelper.SetThemeByApp(_textMate);
}
@@ -134,25 +126,6 @@ private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs
e.Handled = true;
}
- private void UpdateTextMate()
- {
- if (UseSyntaxHighlighting)
- {
- _textMate ??= Models.TextMateHelper.CreateForEditor(this);
-
- if (DataContext is Models.RevisionTextFile file)
- Models.TextMateHelper.SetGrammarByFileName(_textMate, file.FileName);
- }
- else if (_textMate != null)
- {
- _textMate.Dispose();
- _textMate = null;
- GC.Collect();
-
- TextArea.TextView.Redraw();
- }
- }
-
private TextMate.Installation _textMate = null;
}
diff --git a/src/Views/RevisionFileTreeView.axaml b/src/Views/RevisionFileTreeView.axaml
index 025ba4d79..4d4c9d034 100644
--- a/src/Views/RevisionFileTreeView.axaml
+++ b/src/Views/RevisionFileTreeView.axaml
@@ -8,7 +8,8 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="SourceGit.Views.RevisionFileTreeView"
x:Name="ThisControl">
- 0)
+ {
+ var lastNode = Rows[^1];
+ RowsList.SelectedItem = lastNode;
+ RowsList.ScrollIntoView(lastNode);
+ }
+
GC.Collect();
}
diff --git a/src/Views/RevisionFiles.axaml b/src/Views/RevisionFiles.axaml
index edfd73359..9ff113c57 100644
--- a/src/Views/RevisionFiles.axaml
+++ b/src/Views/RevisionFiles.axaml
@@ -127,30 +127,14 @@
Height="26"
BorderBrush="{DynamicResource Brush.Border2}" BorderThickness="0,0,0,1"
IsVisible="{Binding ViewRevisionFilePath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
-
+
-
-
-
-
-
-
-
-
-