diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ad62efb6..d4117364 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -32,7 +32,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: 8.0.x
+ dotnet-version: 9.0.x
- name: Configure arm64 packages
if: ${{ matrix.runtime == 'linux-arm64' }}
run: |
diff --git a/README.md b/README.md
index 5604c2c0..70f8feee 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,7 @@ For **Linux** users:
* `xdg-open` must be installed to support open native file manager.
* Make sure [git-credential-manager](https://github.com/git-ecosystem/git-credential-manager/releases) is installed on your linux.
* Maybe you need to set environment variable `AVALONIA_SCREEN_SCALE_FACTORS`. See https://github.com/AvaloniaUI/Avalonia/wiki/Configuring-X11-per-monitor-DPI.
+* If you can NOT type accented characters, such as `ê`, `ó`, try to set the environment variable `AVALONIA_IM_MODULE` to `none`.
## OpenAI
diff --git a/SourceGit.sln b/SourceGit.sln
index 9799a09e..9c5fcdb1 100644
--- a/SourceGit.sln
+++ b/SourceGit.sln
@@ -13,6 +13,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{F45A
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{67B6D05F-A000-40BA-ADB4-C9065F880D7B}"
ProjectSection(SolutionItems) = preProject
+ .github\workflows\build.yml = .github\workflows\build.yml
.github\workflows\ci.yml = .github\workflows\ci.yml
.github\workflows\package.yml = .github\workflows\package.yml
.github\workflows\release.yml = .github\workflows\release.yml
diff --git a/TRANSLATION.md b/TRANSLATION.md
index 8a5b61f5..8c6f5124 100644
--- a/TRANSLATION.md
+++ b/TRANSLATION.md
@@ -1,126 +1,60 @@
-### de_DE.axaml: 100.00%
+### de_DE.axaml: 99.86%
Missing Keys
-
+- Text.Repository.FilterCommits
-### es_ES.axaml: 99.57%
+### es_ES.axaml: 98.01%
Missing Keys
+- Text.CommitDetail.Info.Children
+- Text.Fetch.Force
- Text.Preference.Appearance.FontSize
- Text.Preference.Appearance.FontSize.Default
- Text.Preference.Appearance.FontSize.Editor
+- Text.Preference.General.ShowChildren
+- Text.Repository.FilterCommits
+- Text.Repository.FilterCommits.Default
+- Text.Repository.FilterCommits.Exclude
+- Text.Repository.FilterCommits.Include
+- Text.Repository.HistoriesOrder
+- Text.Repository.HistoriesOrder.ByDate
+- Text.Repository.HistoriesOrder.Topo
+- Text.SHALinkCM.NavigateTo
-### fr_FR.axaml: 86.31%
+### fr_FR.axaml: 97.44%
Missing Keys
-- Text.About.Chart
-- Text.AIAssistant
-- Text.AIAssistant.Tip
-- Text.BranchCM.FetchInto
-- Text.ChangeCM.GenerateCommitMessage
- Text.CherryPick.AppendSourceToMessage
-- Text.CherryPick.Mainline
- Text.CherryPick.Mainline.Tips
- Text.CommitCM.CherryPickMultiple
-- Text.CommitCM.CustomAction
-- Text.CommitCM.SquashCommitsSinceThis
-- Text.CommitDetail.Info.WebLinks
-- Text.Configure.CustomAction
-- Text.Configure.CustomAction.Arguments
-- Text.Configure.CustomAction.Arguments.Tip
-- Text.Configure.CustomAction.Executable
-- Text.Configure.CustomAction.Name
-- Text.Configure.CustomAction.Scope
-- Text.Configure.CustomAction.Scope.Commit
-- Text.Configure.CustomAction.Scope.Repository
-- Text.Configure.Git.DefaultRemote
-- Text.Configure.Git.EnablePruneOnFetch
-- Text.Configure.Git.EnableSignOff
-- Text.Configure.IssueTracker.AddSampleGitLabIssue
-- Text.Configure.IssueTracker.AddSampleGitLabMergeRequest
-- Text.Configure.OpenAI
-- Text.Configure.OpenAI.Prefered
-- Text.Configure.OpenAI.Prefered.Tip
-- Text.ConfigureWorkspace
-- Text.ConfigureWorkspace.Color
-- Text.ConfigureWorkspace.Restore
-- Text.ConventionalCommit
-- Text.ConventionalCommit.BreakingChanges
-- Text.ConventionalCommit.ClosedIssue
-- Text.ConventionalCommit.Detail
-- Text.ConventionalCommit.Scope
-- Text.ConventionalCommit.ShortDescription
-- Text.ConventionalCommit.Type
-- Text.Diff.IgnoreWhitespace
-- Text.Diff.SaveAsPatch
-- Text.Diff.VisualLines.All
-- Text.Discard.IncludeIgnored
-- Text.ExecuteCustomAction
-- Text.ExecuteCustomAction.Name
-- Text.FileHistory.FileChange
-- Text.GitLFS.Locks.OnlyMine
-- Text.Histories.Header.AuthorTime
-- Text.Histories.Tips
-- Text.Histories.Tips.MacOS
-- Text.Histories.Tips.Prefix
-- Text.Hotkeys.Repo.CommitWithAutoStage
-- Text.Hotkeys.Repo.CreateBranchOnCommit
-- Text.Hotkeys.Repo.DiscardSelected
-- Text.Hotkeys.Repo.Fetch
-- Text.Hotkeys.Repo.Pull
-- Text.Hotkeys.Repo.Push
-- Text.IssueLinkCM.OpenInBrowser
-- Text.IssueLinkCM.CopyLink
-- Text.MoveRepositoryNode
-- Text.MoveRepositoryNode.Target
-- Text.Preference.AI
-- Text.Preference.AI.AnalyzeDiffPrompt
-- Text.Preference.AI.ApiKey
-- Text.Preference.AI.GenerateSubjectPrompt
-- Text.Preference.AI.Model
-- Text.Preference.AI.Name
-- Text.Preference.AI.Server
+- Text.Fetch.Force
- Text.Preference.Appearance.FontSize
- Text.Preference.Appearance.FontSize.Default
- Text.Preference.Appearance.FontSize.Editor
-- Text.Preference.General.ShowAuthorTime
-- Text.Preference.Integration
-- Text.Preference.Shell
-- Text.Preference.Shell.Type
-- Text.Preference.Shell.Path
-- Text.Repository.AutoFetching
+- Text.Preference.General.ShowChildren
- Text.Repository.CustomActions
-- Text.Repository.CustomActions.Empty
-- Text.Repository.EnableReflog
-- Text.Repository.Search.InCurrentBranch
+- Text.Repository.FilterCommits
+- Text.Repository.FilterCommits.Default
+- Text.Repository.FilterCommits.Exclude
+- Text.Repository.FilterCommits.Include
+- Text.Repository.HistoriesOrder
+- Text.Repository.HistoriesOrder.ByDate
+- Text.Repository.HistoriesOrder.Topo
- Text.ScanRepositories
-- Text.ScanRepositories.RootDir
-- Text.Squash.Into
-- Text.Stash.KeepIndex
-- Text.Stash.OnlyStagedChanges
-- Text.Stash.TipForSelectedFiles
-- Text.Statistics.Overview
-- Text.TagCM.CopyMessage
-- Text.Welcome.Move
-- Text.Welcome.ScanDefaultCloneDir
-- Text.WorkingCopy.CommitTip
-- Text.WorkingCopy.CommitWithAutoStage
-- Text.WorkingCopy.ConfirmCommitWithoutFiles
-- Text.Workspace
-- Text.Workspace.Configure
+- Text.SHALinkCM.NavigateTo
@@ -140,9 +74,11 @@
Missing Keys
-- Text.Preference.Appearance.FontSize
-- Text.Preference.Appearance.FontSize.Default
-- Text.Preference.Appearance.FontSize.Editor
+- Text.CommitDetail.Info.Children
+- Text.Fetch.Force
+- Text.Preference.General.ShowChildren
+- Text.Repository.FilterCommits
+- Text.SHALinkCM.NavigateTo
diff --git a/VERSION b/VERSION
index 833d11c8..081fd762 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-8.38
\ No newline at end of file
+8.40
\ No newline at end of file
diff --git a/global.json b/global.json
index b5b37b60..a27a2b82 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "8.0.0",
+ "version": "9.0.0",
"rollForward": "latestMajor",
"allowPrerelease": false
}
diff --git a/src/App.axaml.cs b/src/App.axaml.cs
index dfec763b..0615724a 100644
--- a/src/App.axaml.cs
+++ b/src/App.axaml.cs
@@ -478,17 +478,20 @@ namespace SourceGit
if (args.Length <= 1 || !args[0].Equals("--rebase-message-editor", StringComparison.Ordinal))
return false;
+ exitCode = 0;
+
var file = args[1];
var filename = Path.GetFileName(file);
if (!filename.Equals("COMMIT_EDITMSG", StringComparison.OrdinalIgnoreCase))
return true;
- var jobsFile = Path.Combine(Path.GetDirectoryName(file)!, "sourcegit_rebase_jobs.json");
+ var gitDir = Path.GetDirectoryName(file)!;
+ var jobsFile = Path.Combine(gitDir, "sourcegit_rebase_jobs.json");
if (!File.Exists(jobsFile))
return true;
var collection = JsonSerializer.Deserialize(File.ReadAllText(jobsFile), JsonCodeGen.Default.InteractiveRebaseJobCollection);
- var doneFile = Path.Combine(Path.GetDirectoryName(file)!, "rebase-merge", "done");
+ var doneFile = Path.Combine(gitDir, "rebase-merge", "done");
if (!File.Exists(doneFile))
return true;
@@ -499,7 +502,6 @@ namespace SourceGit
var job = collection.Jobs[done.Length - 1];
File.WriteAllText(file, job.Message);
- exitCode = 0;
return true;
}
diff --git a/src/Commands/Diff.cs b/src/Commands/Diff.cs
index cb9e6a18..dea15592 100644
--- a/src/Commands/Diff.cs
+++ b/src/Commands/Diff.cs
@@ -28,9 +28,9 @@ namespace SourceGit.Commands
Context = repo;
if (ignoreWhitespace)
- Args = $"diff --patch --ignore-cr-at-eol --ignore-all-space --unified={unified} {opt}";
+ Args = $"diff --no-ext-diff --patch --ignore-cr-at-eol --ignore-all-space --unified={unified} {opt}";
else
- Args = $"diff --patch --ignore-cr-at-eol --unified={unified} {opt}";
+ Args = $"diff --no-ext-diff --patch --ignore-cr-at-eol --unified={unified} {opt}";
}
public Models.DiffResult Result()
diff --git a/src/Commands/Fetch.cs b/src/Commands/Fetch.cs
index 834cd7fc..1c3e78cb 100644
--- a/src/Commands/Fetch.cs
+++ b/src/Commands/Fetch.cs
@@ -4,7 +4,7 @@ namespace SourceGit.Commands
{
public class Fetch : Command
{
- public Fetch(string repo, string remote, bool noTags, bool prune, Action outputHandler)
+ public Fetch(string repo, string remote, bool noTags, bool prune, bool force, Action outputHandler)
{
_outputHandler = outputHandler;
WorkingDirectory = repo;
@@ -18,6 +18,9 @@ namespace SourceGit.Commands
else
Args += "--tags ";
+ if (force)
+ Args += "--force ";
+
if (prune)
Args += "--prune ";
diff --git a/src/Commands/FormatPatch.cs b/src/Commands/FormatPatch.cs
index 2c7359c0..b3ec2e4a 100644
--- a/src/Commands/FormatPatch.cs
+++ b/src/Commands/FormatPatch.cs
@@ -6,7 +6,7 @@
{
WorkingDirectory = repo;
Context = repo;
- Args = $"format-patch {commit} -1 -o \"{saveTo}\"";
+ Args = $"format-patch {commit} -1 --output=\"{saveTo}\"";
}
}
}
diff --git a/src/Commands/QueryCommitChildren.cs b/src/Commands/QueryCommitChildren.cs
new file mode 100644
index 00000000..bef09abb
--- /dev/null
+++ b/src/Commands/QueryCommitChildren.cs
@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+
+namespace SourceGit.Commands
+{
+ public class QueryCommitChildren : Command
+ {
+ public QueryCommitChildren(string repo, string commit, int max)
+ {
+ WorkingDirectory = repo;
+ Context = repo;
+ _commit = commit;
+ Args = $"rev-list -{max} --parents --branches --remotes ^{commit}";
+ }
+
+ public IEnumerable Result()
+ {
+ Exec();
+ return _lines;
+ }
+
+ protected override void OnReadline(string line)
+ {
+ if (line.Contains(_commit))
+ _lines.Add(line.Substring(0, 40));
+ }
+
+ private string _commit;
+ private List _lines = new List();
+ }
+}
diff --git a/src/Commands/QueryCommits.cs b/src/Commands/QueryCommits.cs
index 5875301e..80497a90 100644
--- a/src/Commands/QueryCommits.cs
+++ b/src/Commands/QueryCommits.cs
@@ -6,11 +6,13 @@ namespace SourceGit.Commands
{
public class QueryCommits : Command
{
- public QueryCommits(string repo, string limits, bool needFindHead = true)
+ public QueryCommits(string repo, bool useTopoOrder, string limits, bool needFindHead = true)
{
+ var order = useTopoOrder ? "--topo-order" : "--date-order";
+
WorkingDirectory = repo;
Context = repo;
- Args = "log --date-order --no-show-signature --decorate=full --pretty=format:%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s " + limits;
+ Args = $"log {order} --no-show-signature --decorate=full --pretty=format:%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s {limits}";
_findFirstMerged = needFindHead;
}
diff --git a/src/Commands/Statistics.cs b/src/Commands/Statistics.cs
index adfcc574..511c43e8 100644
--- a/src/Commands/Statistics.cs
+++ b/src/Commands/Statistics.cs
@@ -4,11 +4,11 @@ namespace SourceGit.Commands
{
public class Statistics : Command
{
- public Statistics(string repo)
+ public Statistics(string repo, int max)
{
WorkingDirectory = repo;
Context = repo;
- Args = $"log --date-order --branches --remotes -40000 --pretty=format:\"%ct$%aN\"";
+ Args = $"log --date-order --branches --remotes -{max} --pretty=format:\"%ct$%aN\"";
}
public Models.Statistics Result()
diff --git a/src/Converters/FilterModeConverters.cs b/src/Converters/FilterModeConverters.cs
new file mode 100644
index 00000000..c486af5e
--- /dev/null
+++ b/src/Converters/FilterModeConverters.cs
@@ -0,0 +1,22 @@
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+
+namespace SourceGit.Converters
+{
+ public static class FilterModeConverters
+ {
+ public static readonly FuncValueConverter ToBorderBrush =
+ new FuncValueConverter(v =>
+ {
+ switch (v)
+ {
+ case Models.FilterMode.Included:
+ return Brushes.Green;
+ case Models.FilterMode.Excluded:
+ return Brushes.Red;
+ default:
+ return Brushes.Transparent;
+ }
+ });
+ }
+}
diff --git a/src/Models/CommitTemplate.cs b/src/Models/CommitTemplate.cs
index b34fa5a5..56e1992c 100644
--- a/src/Models/CommitTemplate.cs
+++ b/src/Models/CommitTemplate.cs
@@ -1,7 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-using System.Text.RegularExpressions;
+using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -9,9 +6,6 @@ namespace SourceGit.Models
{
public partial class CommitTemplate : ObservableObject
{
- [GeneratedRegex(@"\$\{files(\:\d+)?\}")]
- private static partial Regex REG_COMMIT_TEMPLATE_FILES();
-
public string Name
{
get => _name;
@@ -26,55 +20,8 @@ namespace SourceGit.Models
public string Apply(Branch branch, List changes)
{
- var content = _content
- .Replace("${files_num}", $"{changes.Count}")
- .Replace("${branch_name}", branch.Name);
-
- var matches = REG_COMMIT_TEMPLATE_FILES().Matches(content);
- if (matches.Count == 0)
- return content;
-
- var builder = new StringBuilder();
- var last = 0;
- for (int i = 0; i < matches.Count; i++)
- {
- var match = matches[i];
- if (!match.Success)
- continue;
-
- var start = match.Index;
- if (start != last)
- builder.Append(content.Substring(last, start - last));
-
- var countStr = match.Groups[1].Value;
- var paths = new List();
- var more = string.Empty;
- if (countStr is { Length: <= 1 })
- {
- foreach (var c in changes)
- paths.Add(c.Path);
- }
- else
- {
- var count = Math.Min(int.Parse(countStr.Substring(1)), changes.Count);
- for (int j = 0; j < count; j++)
- paths.Add(changes[j].Path);
-
- if (count < changes.Count)
- more = $" and {changes.Count - count} other files";
- }
-
- builder.Append(string.Join(", ", paths));
- if (!string.IsNullOrEmpty(more))
- builder.Append(more);
-
- last = start + match.Length;
- }
-
- if (last != content.Length - 1)
- builder.Append(content.Substring(last));
-
- return builder.ToString();
+ var te = new TemplateEngine();
+ return te.Eval(_content, branch, changes);
}
private string _name = string.Empty;
diff --git a/src/Models/ExternalMerger.cs b/src/Models/ExternalMerger.cs
index b5398835..a09f808c 100644
--- a/src/Models/ExternalMerger.cs
+++ b/src/Models/ExternalMerger.cs
@@ -39,7 +39,7 @@ namespace SourceGit.Models
new ExternalMerger(4, "tortoise_merge", "Tortoise Merge", "TortoiseMerge.exe;TortoiseGitMerge.exe", "-base:\"$BASE\" -theirs:\"$REMOTE\" -mine:\"$LOCAL\" -merged:\"$MERGED\"", "-base:\"$LOCAL\" -theirs:\"$REMOTE\""),
new ExternalMerger(5, "kdiff3", "KDiff3", "kdiff3.exe", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""),
new ExternalMerger(6, "beyond_compare", "Beyond Compare", "BComp.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""),
- new ExternalMerger(7, "win_merge", "WinMerge", "WinMergeU.exe", "-u -e \"$REMOTE\" \"$LOCAL\" \"$MERGED\"", "-u -e \"$LOCAL\" \"$REMOTE\""),
+ new ExternalMerger(7, "win_merge", "WinMerge", "WinMergeU.exe", "\"$MERGED\"", "-u -e \"$LOCAL\" \"$REMOTE\""),
new ExternalMerger(8, "codium", "VSCodium", "VSCodium.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""),
new ExternalMerger(9, "p4merge", "P4Merge", "p4merge.exe", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""),
};
diff --git a/src/Models/Filter.cs b/src/Models/Filter.cs
index 8ffd27c7..8d419800 100644
--- a/src/Models/Filter.cs
+++ b/src/Models/Filter.cs
@@ -25,14 +25,13 @@ namespace SourceGit.Models
get => _pattern;
set => SetProperty(ref _pattern, value);
}
-
- public FilterType Type
+ public FilterType Type
{
get;
set;
} = FilterType.LocalBranch;
- public FilterMode Mode
+ public FilterMode Mode
{
get => _mode;
set => SetProperty(ref _mode, value);
diff --git a/src/Models/OpenAI.cs b/src/Models/OpenAI.cs
index e9c7b5ed..df67ff66 100644
--- a/src/Models/OpenAI.cs
+++ b/src/Models/OpenAI.cs
@@ -150,12 +150,17 @@ namespace SourceGit.Models
public OpenAIChatResponse Chat(string prompt, string question, CancellationToken cancellation)
{
var chat = new OpenAIChatRequest() { Model = Model };
- chat.AddMessage("system", prompt);
+ chat.AddMessage("user", prompt);
chat.AddMessage("user", question);
var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(60) };
if (!string.IsNullOrEmpty(ApiKey))
- client.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
+ {
+ if (Server.Contains("openai.azure.com/", StringComparison.Ordinal))
+ client.DefaultRequestHeaders.Add("api-key", ApiKey);
+ else
+ client.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
+ }
var req = new StringContent(JsonSerializer.Serialize(chat, JsonCodeGen.Default.OpenAIChatRequest), Encoding.UTF8, "application/json");
try
@@ -164,12 +169,15 @@ namespace SourceGit.Models
task.Wait(cancellation);
var rsp = task.Result;
- if (!rsp.IsSuccessStatusCode)
- throw new Exception($"AI service returns error code {rsp.StatusCode}");
-
var reader = rsp.Content.ReadAsStringAsync(cancellation);
reader.Wait(cancellation);
+ var body = reader.Result;
+ if (!rsp.IsSuccessStatusCode)
+ {
+ throw new Exception($"AI service returns error code {rsp.StatusCode}. Body: {body ?? string.Empty}");
+ }
+
return JsonSerializer.Deserialize(reader.Result, JsonCodeGen.Default.OpenAIChatResponse);
}
catch
diff --git a/src/Models/Remote.cs b/src/Models/Remote.cs
index dcf30ddc..2b88c3be 100644
--- a/src/Models/Remote.cs
+++ b/src/Models/Remote.cs
@@ -1,11 +1,12 @@
using System;
+using System.IO;
using System.Text.RegularExpressions;
namespace SourceGit.Models
{
public partial class Remote
{
- [GeneratedRegex(@"^http[s]?://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/[\w\-/~%]+/[\w\-\.%]+(\.git)?$")]
+ [GeneratedRegex(@"^https?://([-a-zA-Z0-9:%._\+~#=]+@)?[-a-zA-Z0-9:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}(:[0-9]{1,5})?\b(/[-a-zA-Z0-9()@:%_\+.~#?&=]*)*(\.git)?$")]
private static partial Regex REG_HTTPS();
[GeneratedRegex(@"^[\w\-]+@[\w\.\-]+(\:[0-9]+)?:[\w\-/~%]+/[\w\-\.%]+(\.git)?$")]
private static partial Regex REG_SSH1();
@@ -49,7 +50,7 @@ namespace SourceGit.Models
return true;
}
- return false;
+ return url.EndsWith(".git", StringComparison.Ordinal) && Directory.Exists(url);
}
public bool TryGetVisitURL(out string url)
diff --git a/src/Models/RepositorySettings.cs b/src/Models/RepositorySettings.cs
index f0156bc2..586f52ec 100644
--- a/src/Models/RepositorySettings.cs
+++ b/src/Models/RepositorySettings.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Text;
diff --git a/src/Models/ResetMode.cs b/src/Models/ResetMode.cs
index 735533c2..827ccaa9 100644
--- a/src/Models/ResetMode.cs
+++ b/src/Models/ResetMode.cs
@@ -6,23 +6,25 @@ namespace SourceGit.Models
{
public static readonly ResetMode[] Supported =
[
- new ResetMode("Soft", "Keep all changes. Stage differences", "--soft", Brushes.Green),
- new ResetMode("Mixed", "Keep all changes. Unstage differences", "--mixed", Brushes.Orange),
- new ResetMode("Merge", "Reset while keeping unmerged changes", "--merge", Brushes.Purple),
- new ResetMode("Keep", "Reset while keeping local modifications", "--keep", Brushes.Purple),
- new ResetMode("Hard", "Discard all changes", "--hard", Brushes.Red),
+ new ResetMode("Soft", "Keep all changes. Stage differences", "--soft", "S", Brushes.Green),
+ new ResetMode("Mixed", "Keep all changes. Unstage differences", "--mixed", "M", Brushes.Orange),
+ new ResetMode("Merge", "Reset while keeping unmerged changes", "--merge", "G", Brushes.Purple),
+ new ResetMode("Keep", "Reset while keeping local modifications", "--keep", "K", Brushes.Purple),
+ new ResetMode("Hard", "Discard all changes", "--hard", "H", Brushes.Red),
];
public string Name { get; set; }
public string Desc { get; set; }
public string Arg { get; set; }
+ public string Key { get; set; }
public IBrush Color { get; set; }
- public ResetMode(string n, string d, string a, IBrush b)
+ public ResetMode(string n, string d, string a, string k, IBrush b)
{
Name = n;
Desc = d;
Arg = a;
+ Key = k;
Color = b;
}
}
diff --git a/src/Models/Statistics.cs b/src/Models/Statistics.cs
index b669eb55..969d3945 100644
--- a/src/Models/Statistics.cs
+++ b/src/Models/Statistics.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using LiveChartsCore;
using LiveChartsCore.Defaults;
@@ -138,7 +139,8 @@ namespace SourceGit.Models
public Statistics()
{
_today = DateTime.Now.ToLocalTime().Date;
- _thisWeekStart = _today.AddSeconds(-(int)_today.DayOfWeek * 3600 * 24);
+ var weekOffset = (7 + (int)_today.DayOfWeek - (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek) % 7;
+ _thisWeekStart = _today.AddDays(-weekOffset);
_thisMonthStart = _today.AddDays(1 - _today.Day);
All = new StatisticsReport(StaticsticsMode.All, DateTime.MinValue);
diff --git a/src/Models/TemplateEngine.cs b/src/Models/TemplateEngine.cs
new file mode 100644
index 00000000..6b5f525d
--- /dev/null
+++ b/src/Models/TemplateEngine.cs
@@ -0,0 +1,410 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace SourceGit.Models
+{
+ public class TemplateEngine
+ {
+ private class Context(Branch branch, IReadOnlyList changes)
+ {
+ public Branch branch = branch;
+ public IReadOnlyList changes = changes;
+ }
+
+ private class Text(string text)
+ {
+ public string text = text;
+ }
+
+ private class Variable(string name)
+ {
+ public string name = name;
+ }
+
+ private class SlicedVariable(string name, int count)
+ {
+ public string name = name;
+ public int count = count;
+ }
+
+ private class RegexVariable(string name, Regex regex, string replacement)
+ {
+ public string name = name;
+ public Regex regex = regex;
+ public string replacement = replacement;
+ }
+
+ private const char ESCAPE = '\\';
+ private const char VARIABLE_ANCHOR = '$';
+ private const char VARIABLE_START = '{';
+ private const char VARIABLE_END = '}';
+ private const char VARIABLE_SLICE = ':';
+ private const char VARIABLE_REGEX = '/';
+ private const char NEWLINE = '\n';
+ private const RegexOptions REGEX_OPTIONS = RegexOptions.Singleline | RegexOptions.IgnoreCase;
+
+ public string Eval(string text, Branch branch, IReadOnlyList changes)
+ {
+ Reset();
+
+ _chars = text.ToCharArray();
+ Parse();
+
+ var context = new Context(branch, changes);
+ var sb = new StringBuilder();
+ sb.EnsureCapacity(text.Length);
+ foreach (var token in _tokens)
+ {
+ switch (token)
+ {
+ case Text text_token:
+ sb.Append(text_token.text);
+ break;
+ case Variable var_token:
+ sb.Append(EvalVariable(context, var_token));
+ break;
+ case SlicedVariable sliced_var:
+ sb.Append(EvalVariable(context, sliced_var));
+ break;
+ case RegexVariable regex_var:
+ sb.Append(EvalVariable(context, regex_var));
+ break;
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private void Reset()
+ {
+ _pos = 0;
+ _chars = [];
+ _tokens.Clear();
+ }
+
+ private char? Next()
+ {
+ var c = Peek();
+ if (c is not null)
+ {
+ _pos++;
+ }
+ return c;
+ }
+
+ private char? Peek()
+ {
+ return (_pos >= _chars.Length) ? null : _chars[_pos];
+ }
+
+ private int? Integer()
+ {
+ var start = _pos;
+ while (Peek() is char c && c >= '0' && c <= '9')
+ {
+ _pos++;
+ }
+ if (start >= _pos)
+ return null;
+
+ var chars = new ReadOnlySpan(_chars, start, _pos - start);
+ return int.Parse(chars);
+ }
+
+ private void Parse()
+ {
+ // text token start
+ var tok = _pos;
+ bool esc = false;
+ while (Next() is char c)
+ {
+ if (esc)
+ {
+ esc = false;
+ continue;
+ }
+ switch (c)
+ {
+ case ESCAPE:
+ // allow to escape only \ and $
+ if (Peek() is char nc && (nc == ESCAPE || nc == VARIABLE_ANCHOR))
+ {
+ esc = true;
+ FlushText(tok, _pos - 1);
+ tok = _pos;
+ }
+ break;
+ case VARIABLE_ANCHOR:
+ // backup the position
+ var bak = _pos;
+ var variable = TryParseVariable();
+ if (variable is null)
+ {
+ // no variable found, rollback
+ _pos = bak;
+ }
+ else
+ {
+ // variable found, flush a text token
+ FlushText(tok, bak - 1);
+ _tokens.Add(variable);
+ tok = _pos;
+ }
+ break;
+ }
+ }
+ // flush text token
+ FlushText(tok, _pos);
+ }
+
+ private void FlushText(int start, int end)
+ {
+ int len = end - start;
+ if (len <= 0)
+ return;
+ var text = new string(_chars, start, len);
+ _tokens.Add(new Text(text));
+ }
+
+ private object TryParseVariable()
+ {
+ if (Next() != VARIABLE_START)
+ return null;
+ int name_start = _pos;
+ while (Next() is char c)
+ {
+ // name character, continue advancing
+ if (IsNameChar(c))
+ continue;
+
+ var name_end = _pos - 1;
+ // not a name character but name is empty, cancel
+ if (name_start >= name_end)
+ return null;
+ var name = new string(_chars, name_start, name_end - name_start);
+
+ return c switch
+ {
+ // variable
+ VARIABLE_END => new Variable(name),
+ // sliced variable
+ VARIABLE_SLICE => TryParseSlicedVariable(name),
+ // regex variable
+ VARIABLE_REGEX => TryParseRegexVariable(name),
+ _ => null,
+ };
+ }
+
+ return null;
+ }
+
+ private object TryParseSlicedVariable(string name)
+ {
+ int? n = Integer();
+ if (n is null)
+ return null;
+ if (Next() != VARIABLE_END)
+ return null;
+
+ return new SlicedVariable(name, (int)n);
+ }
+
+ private object TryParseRegexVariable(string name)
+ {
+ var regex = ParseRegex();
+ if (regex == null)
+ return null;
+ var replacement = ParseReplacement();
+ if (replacement == null)
+ return null;
+
+ return new RegexVariable(name, regex, replacement);
+ }
+
+ private Regex ParseRegex()
+ {
+ var sb = new StringBuilder();
+ var tok = _pos;
+ var esc = false;
+ while (Next() is char c)
+ {
+ if (esc)
+ {
+ esc = false;
+ continue;
+ }
+ switch (c)
+ {
+ case ESCAPE:
+ // allow to escape only / as \ and { used frequently in regexes
+ if (Peek() == VARIABLE_REGEX)
+ {
+ esc = true;
+ sb.Append(_chars, tok, _pos - 1 - tok);
+ tok = _pos;
+ }
+ break;
+ case VARIABLE_REGEX:
+ // goto is fine
+ goto Loop_exit;
+ case NEWLINE:
+ // no newlines allowed
+ return null;
+ }
+ }
+ Loop_exit:
+ sb.Append(_chars, tok, _pos - 1 - tok);
+
+ try
+ {
+ var pattern = sb.ToString();
+ if (pattern.Length == 0)
+ return null;
+ var regex = new Regex(pattern, REGEX_OPTIONS);
+
+ return regex;
+ }
+ catch (RegexParseException)
+ {
+ return null;
+ }
+ }
+
+ private string ParseReplacement()
+ {
+ var sb = new StringBuilder();
+ var tok = _pos;
+ var esc = false;
+ while (Next() is char c)
+ {
+ if (esc)
+ {
+ esc = false;
+ continue;
+ }
+ switch (c)
+ {
+ case ESCAPE:
+ // allow to escape only }
+ if (Peek() == VARIABLE_END)
+ {
+ esc = true;
+ sb.Append(_chars, tok, _pos - 1 - tok);
+ tok = _pos;
+ }
+ break;
+ case VARIABLE_END:
+ // goto is fine
+ goto Loop_exit;
+ case NEWLINE:
+ // no newlines allowed
+ return null;
+ }
+ }
+ Loop_exit:
+ sb.Append(_chars, tok, _pos - 1 - tok);
+
+ var replacement = sb.ToString();
+
+ return replacement;
+ }
+
+ private static bool IsNameChar(char c)
+ {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
+ }
+
+ // (?) notice or log if variable is not found
+ private static string EvalVariable(Context context, string name)
+ {
+ if (!s_variables.TryGetValue(name, out var getter))
+ {
+ return string.Empty;
+ }
+ return getter(context);
+ }
+
+ private static string EvalVariable(Context context, Variable variable)
+ {
+ return EvalVariable(context, variable.name);
+ }
+
+ private static string EvalVariable(Context context, SlicedVariable variable)
+ {
+ if (!s_slicedVariables.TryGetValue(variable.name, out var getter))
+ {
+ return string.Empty;
+ }
+ return getter(context, variable.count);
+ }
+
+ private static string EvalVariable(Context context, RegexVariable variable)
+ {
+ var str = EvalVariable(context, variable.name);
+ if (string.IsNullOrEmpty(str))
+ return str;
+ return variable.regex.Replace(str, variable.replacement);
+ }
+
+ private int _pos = 0;
+ private char[] _chars = [];
+ private readonly List