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 ### ![en_US](https://img.shields.io/badge/en__US-%E2%88%9A-brightgreen) -### ![de__DE](https://img.shields.io/badge/de__DE-99.51%25-yellow) +### ![de__DE](https://img.shields.io/badge/de__DE-97.70%25-yellow)
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
-### ![el__GR](https://img.shields.io/badge/el__GR-99.02%25-yellow) +### ![el__GR](https://img.shields.io/badge/el__GR-97.22%25-yellow)
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
-### ![es__ES](https://img.shields.io/badge/es__ES-%E2%88%9A-brightgreen) +### ![es__ES](https://img.shields.io/badge/es__ES-98.18%25-yellow) -### ![fr__FR](https://img.shields.io/badge/fr__FR-95.12%25-yellow) +
+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 + +
+ +### ![fr__FR](https://img.shields.io/badge/fr__FR-93.40%25-yellow)
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
-### ![he__IL](https://img.shields.io/badge/he__IL-95.12%25-yellow) +### ![he__IL](https://img.shields.io/badge/he__IL-93.40%25-yellow)
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
-### ![id__ID](https://img.shields.io/badge/id__ID-99.41%25-yellow) +### ![id__ID](https://img.shields.io/badge/id__ID-97.61%25-yellow)
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
-### ![it__IT](https://img.shields.io/badge/it__IT-88.68%25-yellow) +### ![it__IT](https://img.shields.io/badge/it__IT-87.08%25-yellow)
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
-### ![ja__JP](https://img.shields.io/badge/ja__JP-99.02%25-yellow) +### ![ja__JP](https://img.shields.io/badge/ja__JP-97.22%25-yellow)
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
-### ![ko__KR](https://img.shields.io/badge/ko__KR-96.59%25-yellow) +### ![ko__KR](https://img.shields.io/badge/ko__KR-94.83%25-yellow)
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
-### ![pt__BR](https://img.shields.io/badge/pt__BR-62.54%25-red) +### ![pt__BR](https://img.shields.io/badge/pt__BR-61.44%25-red)
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
-### ![ru__RU](https://img.shields.io/badge/ru__RU-%E2%88%9A-brightgreen) +### ![ru__RU](https://img.shields.io/badge/ru__RU-98.18%25-yellow) + +
+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 + +
-### ![ta__IN](https://img.shields.io/badge/ta__IN-64.20%25-red) +### ![ta__IN](https://img.shields.io/badge/ta__IN-63.06%25-red)
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
-### ![uk__UA](https://img.shields.io/badge/uk__UA-64.98%25-red) +### ![uk__UA](https://img.shields.io/badge/uk__UA-63.83%25-red)
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 ### ![zh__CN](https://img.shields.io/badge/zh__CN-%E2%88%9A-brightgreen) -### ![zh__TW](https://img.shields.io/badge/zh__TW-%E2%88%9A-brightgreen) \ No newline at end of file +### ![zh__TW](https://img.shields.io/badge/zh__TW-98.56%25-yellow) + +
+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 @@ - + - - - - - - - diff --git a/src/Views/LauncherPage.axaml.cs b/src/Views/LauncherPage.axaml.cs index 7a9506412..88270a5ad 100644 --- a/src/Views/LauncherPage.axaml.cs +++ b/src/Views/LauncherPage.axaml.cs @@ -59,6 +59,14 @@ private void OnPopupCancel(object _, RoutedEventArgs e) e.Handled = true; } + private void OnPopupTerminate(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.LauncherPage page) + page.TerminatePopup(); + + e.Handled = true; + } + private void OnMaskClicked(object sender, PointerPressedEventArgs e) { OnPopupCancel(sender, e); diff --git a/src/Views/Preferences.axaml.cs b/src/Views/Preferences.axaml.cs index 99ec73a5f..773a0f3e7 100644 --- a/src/Views/Preferences.axaml.cs +++ b/src/Views/Preferences.axaml.cs @@ -341,7 +341,11 @@ private async void SelectShellOrTerminal(object _, RoutedEventArgs e) { options = new FilePickerOpenOptions() { - FileTypeFilter = [new FilePickerFileType(shell.Name) { Patterns = [shell.Exec] }], + FileTypeFilter = [new FilePickerFileType(shell.Name) + { + Patterns = shell.Exec.Split('|', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + }], AllowMultiple = false, }; } diff --git a/src/Views/Push.axaml b/src/Views/Push.axaml index 4f013bf83..2e070972b 100644 --- a/src/Views/Push.axaml +++ b/src/Views/Push.axaml @@ -19,7 +19,7 @@ Text="{DynamicResource Text.Push.Title}"/> - + + + diff --git a/src/Views/Push.axaml.cs b/src/Views/Push.axaml.cs index fd7f6747f..29742b562 100644 --- a/src/Views/Push.axaml.cs +++ b/src/Views/Push.axaml.cs @@ -20,8 +20,10 @@ private async void OnPushToNewBranch(object sender, RoutedEventArgs e) if (launcher == null) return; - var dialog = new PushToNewBranch(); - dialog.SetRemote(push.SelectedRemote.Name); + var dialog = new PushToNewBranch() + { + DataContext = new ViewModels.PushToNewBranch(push.SelectedRemote.Name) + }; var name = await dialog.ShowDialog(launcher); if (!string.IsNullOrEmpty(name)) diff --git a/src/Views/PushToNewBranch.axaml b/src/Views/PushToNewBranch.axaml index 0c4eef922..7b145e812 100644 --- a/src/Views/PushToNewBranch.axaml +++ b/src/Views/PushToNewBranch.axaml @@ -3,12 +3,14 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:v="using:SourceGit.Views" + xmlns:vm="using:SourceGit.ViewModels" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.PushToNewBranch" x:Name="ThisControl" + x:DataType="vm:PushToNewBranch" Icon="/App.ico" Title="{DynamicResource Text.PushToNewBranch}" - SizeToContent="WidthAndHeight" + Width="400" SizeToContent="Height" CanResize="False" WindowStartupLocation="CenterOwner"> @@ -39,17 +41,18 @@ - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +