From b4e2f442583177ce32b8d7aa42e36efb25875be6 Mon Sep 17 00:00:00 2001 From: Jeff Simons Decena Date: Fri, 6 Nov 2026 11:53:55 +1300 Subject: [PATCH 1/4] ux: Add selectable Monokai theme with Sublime-style palette Extend the Appearance theme picker beyond Default/Light/Dark with a custom Avalonia Monokai variant using the classic Sublime color palette, and map the editor TextMate theme accordingly. --- src/App.axaml.cs | 7 +---- src/Converters/StringConverters.cs | 12 +------- src/Models/AppThemes.cs | 49 ++++++++++++++++++++++++++++++ src/Models/TextMateHelper.cs | 19 +++++++----- src/Resources/Themes.axaml | 41 ++++++++++++++++++++++++- src/Views/ChangeStatusIcon.cs | 2 +- src/Views/ImageContainer.cs | 2 +- src/Views/LauncherTabBar.axaml.cs | 2 +- src/Views/Preferences.axaml | 1 + 9 files changed, 106 insertions(+), 29 deletions(-) create mode 100644 src/Models/AppThemes.cs diff --git a/src/App.axaml.cs b/src/App.axaml.cs index 66882b4869..0bc2b5851f 100644 --- a/src/App.axaml.cs +++ b/src/App.axaml.cs @@ -122,12 +122,7 @@ public static void SetTheme(string theme, string themeOverridesFile) if (Current is not App app) return; - if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) - app.RequestedThemeVariant = ThemeVariant.Light; - else if (theme.Equals("Dark", StringComparison.OrdinalIgnoreCase)) - app.RequestedThemeVariant = ThemeVariant.Dark; - else - app.RequestedThemeVariant = ThemeVariant.Default; + app.RequestedThemeVariant = Models.AppThemes.FromName(theme); if (app._themeOverrides != null) { diff --git a/src/Converters/StringConverters.cs b/src/Converters/StringConverters.cs index 08f5961ee3..555b7ee0d3 100644 --- a/src/Converters/StringConverters.cs +++ b/src/Converters/StringConverters.cs @@ -29,17 +29,7 @@ public class ToThemeConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - var theme = (string)value; - if (string.IsNullOrEmpty(theme)) - return ThemeVariant.Default; - - if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) - return ThemeVariant.Light; - - if (theme.Equals("Dark", StringComparison.OrdinalIgnoreCase)) - return ThemeVariant.Dark; - - return ThemeVariant.Default; + return Models.AppThemes.FromName(value as string); } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) diff --git a/src/Models/AppThemes.cs b/src/Models/AppThemes.cs new file mode 100644 index 0000000000..c76c50d0e0 --- /dev/null +++ b/src/Models/AppThemes.cs @@ -0,0 +1,49 @@ +using System; + +using Avalonia.Styling; + +namespace SourceGit.Models +{ + public static class AppThemes + { + public static readonly ThemeVariant Monokai = new("Monokai", ThemeVariant.Dark); + + public static ThemeVariant FromName(string theme) + { + if (string.IsNullOrEmpty(theme)) + return ThemeVariant.Default; + + if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) + return ThemeVariant.Light; + + if (theme.Equals("Dark", StringComparison.OrdinalIgnoreCase)) + return ThemeVariant.Dark; + + if (theme.Equals("Monokai", StringComparison.OrdinalIgnoreCase)) + return Monokai; + + return ThemeVariant.Default; + } + + public static bool IsDark(ThemeVariant variant) + { + while (variant != null) + { + if (variant == ThemeVariant.Dark) + return true; + + if (variant == ThemeVariant.Light) + return false; + + variant = variant.InheritVariant; + } + + return false; + } + + public static bool IsMonokai(ThemeVariant variant) + { + return variant == Monokai; + } + } +} diff --git a/src/Models/TextMateHelper.cs b/src/Models/TextMateHelper.cs index bdddeb1b54..0f95b5fb54 100644 --- a/src/Models/TextMateHelper.cs +++ b/src/Models/TextMateHelper.cs @@ -4,7 +4,6 @@ using Avalonia; using Avalonia.Platform; -using Avalonia.Styling; using AvaloniaEdit; using AvaloniaEdit.TextMate; @@ -127,18 +126,22 @@ public static class TextMateHelper { public static TextMate.Installation CreateForEditor(TextEditor editor) { - return editor.InstallTextMate(Application.Current?.ActualThemeVariant == ThemeVariant.Dark ? - new RegistryOptionsWrapper(ThemeName.DarkPlus) : - new RegistryOptionsWrapper(ThemeName.LightPlus)); + return editor.InstallTextMate(new RegistryOptionsWrapper(GetThemeName())); } public static void SetThemeByApp(TextMate.Installation installation) { if (installation is { RegistryOptions: RegistryOptionsWrapper reg }) - { - var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark; - installation.SetTheme(reg.LoadTheme(isDark ? ThemeName.DarkPlus : ThemeName.LightPlus)); - } + installation.SetTheme(reg.LoadTheme(GetThemeName())); + } + + private static ThemeName GetThemeName() + { + var variant = Application.Current?.ActualThemeVariant; + if (AppThemes.IsMonokai(variant)) + return ThemeName.Monokai; + + return AppThemes.IsDark(variant) ? ThemeName.DarkPlus : ThemeName.LightPlus; } public static void SetGrammarByFileName(TextMate.Installation installation, string filePath) diff --git a/src/Resources/Themes.axaml b/src/Resources/Themes.axaml index 36b97c8984..3646ba9b87 100644 --- a/src/Resources/Themes.axaml +++ b/src/Resources/Themes.axaml @@ -1,5 +1,6 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:m="using:SourceGit.Models"> #FFF4F4F4 @@ -68,6 +69,44 @@ #FFF0F0F0 #FF272727 + + + + #FFF92672 + #FF272822 + #FF75715E + #FF272822 + #FF3E3D32 + #FF3E3D32 + #FF75715E + #FF272822 + #FFAE81FF + #FF272822 + #FFFD971F + #FF272822 + #4066D9EF + #40F92672 + #FF3E3D32 + #FF75715E + #FF3E3D32 + #FF3E3D32 + #FF75715E + #FF75715E + #FFF8F8F2 + #FF75715E + #403E3D32 + #40A6E22E + #40F92672 + #80A6E22E + #80F92672 + #FF66D9EF + #FF66D9EF + #FF3E3D32 + #FFE6DB74 + #FF3E3D32 + diff --git a/src/Views/ChangeStatusIcon.cs b/src/Views/ChangeStatusIcon.cs index ee48cbc895..3ea034fb98 100644 --- a/src/Views/ChangeStatusIcon.cs +++ b/src/Views/ChangeStatusIcon.cs @@ -58,7 +58,7 @@ public override void Render(DrawingContext context) var indicator = INDICATOR[idx]; var color = COLOR[idx]; var hsl = color.ToHsl(); - var color2 = ActualThemeVariant == ThemeVariant.Dark + var color2 = Models.AppThemes.IsDark(ActualThemeVariant) ? new HslColor(hsl.A, hsl.H, hsl.S, hsl.L - 0.1).ToRgb() : new HslColor(hsl.A, hsl.H, hsl.S, hsl.L + 0.1).ToRgb(); diff --git a/src/Views/ImageContainer.cs b/src/Views/ImageContainer.cs index 8e9926d54e..7bdea08b33 100644 --- a/src/Views/ImageContainer.cs +++ b/src/Views/ImageContainer.cs @@ -15,7 +15,7 @@ public override void Render(DrawingContext context) { if (_bgBrush == null) { - var maskBrush = new SolidColorBrush(ActualThemeVariant == ThemeVariant.Dark ? 0xFF404040 : 0xFFBBBBBB); + var maskBrush = new SolidColorBrush(Models.AppThemes.IsDark(ActualThemeVariant) ? 0xFF404040 : 0xFFBBBBBB); var bg = new DrawingGroup() { Children = diff --git a/src/Views/LauncherTabBar.axaml.cs b/src/Views/LauncherTabBar.axaml.cs index 19bb5967ac..87c60d1fa9 100644 --- a/src/Views/LauncherTabBar.axaml.cs +++ b/src/Views/LauncherTabBar.axaml.cs @@ -78,7 +78,7 @@ public override void Render(DrawingContext context) var selectedIdx = LauncherTabsList.SelectedIndex; var count = LauncherTabsList.ItemCount; - var separatorPen = new Pen(new SolidColorBrush(ActualThemeVariant == ThemeVariant.Dark ? Colors.White : Colors.Black, 0.2)); + var separatorPen = new Pen(new SolidColorBrush(Models.AppThemes.IsDark(ActualThemeVariant) ? Colors.White : Colors.Black, 0.2)); var separatorY = (height - 18) * 0.5 + 1; if (!_isScrollButtonVisible && selectedIdx > 0) diff --git a/src/Views/Preferences.axaml b/src/Views/Preferences.axaml index 5a19a248f8..f0477fad83 100644 --- a/src/Views/Preferences.axaml +++ b/src/Views/Preferences.axaml @@ -203,6 +203,7 @@ Default Dark Light + From 189e74b3654a172df5fc6c9a2fc1dfc7e3fd662e Mon Sep 17 00:00:00 2001 From: Jeff Simons Decena Date: Fri, 6 Nov 2026 12:38:52 +1300 Subject: [PATCH 2/4] ux: Open commit file diffs in the main histories pane Show selected change diffs over the commit graph with a close control to restore the graph. Default relative commit times and add week-ago period labels. --- src/Resources/Locales/ca_ES.axaml | 2 + src/Resources/Locales/de_DE.axaml | 2 + src/Resources/Locales/el_GR.axaml | 2 + src/Resources/Locales/en_US.axaml | 2 + src/Resources/Locales/es_ES.axaml | 2 + src/Resources/Locales/fr_FR.axaml | 2 + src/Resources/Locales/he_IL.axaml | 2 + src/Resources/Locales/id_ID.axaml | 2 + src/Resources/Locales/it_IT.axaml | 2 + src/Resources/Locales/ja_JP.axaml | 2 + src/Resources/Locales/ko_KR.axaml | 2 + src/Resources/Locales/pt_BR.axaml | 2 + src/Resources/Locales/ru_RU.axaml | 2 + src/Resources/Locales/ta_IN.axaml | 2 + src/Resources/Locales/uk_UA.axaml | 2 + src/Resources/Locales/zh_CN.axaml | 2 + src/Resources/Locales/zh_TW.axaml | 2 + src/ViewModels/CommitDetail.cs | 89 +++++++++++++++++++++++-- src/ViewModels/Histories.cs | 49 ++++++++++++++ src/ViewModels/Preferences.cs | 2 +- src/ViewModels/RevisionCompare.cs | 69 +++++++++++++++++-- src/Views/ChangeCollectionView.axaml.cs | 14 ++++ src/Views/CommitChanges.axaml | 12 ++-- src/Views/CommitChanges.axaml.cs | 7 ++ src/Views/CommitDetail.axaml | 2 +- src/Views/CommitDetail.axaml.cs | 12 +++- src/Views/CommitTimeTextBlock.cs | 13 +++- src/Views/Histories.axaml | 38 +++++++++++ src/Views/RevisionCompare.axaml | 12 ++-- src/Views/RevisionCompare.axaml.cs | 6 ++ 30 files changed, 334 insertions(+), 25 deletions(-) diff --git a/src/Resources/Locales/ca_ES.axaml b/src/Resources/Locales/ca_ES.axaml index 8d2222280e..3f007f9c1b 100644 --- a/src/Resources/Locales/ca_ES.axaml +++ b/src/Resources/Locales/ca_ES.axaml @@ -664,6 +664,8 @@ L'any passat Fa {0} minuts Fa {0} mesos + Fa 1 setmana + Fa {0} setmanes Fa {0} anys Ahir Preferències diff --git a/src/Resources/Locales/de_DE.axaml b/src/Resources/Locales/de_DE.axaml index 3ba62d7aac..bd57ea1453 100644 --- a/src/Resources/Locales/de_DE.axaml +++ b/src/Resources/Locales/de_DE.axaml @@ -640,6 +640,8 @@ $1, $2, … Werte der Eingabe-Steuerelemente Letztes Jahr Vor {0} Minuten Vor {0} Monaten + Vor 1 Woche + Vor {0} Wochen Vor {0} Jahren Gestern Einstellungen diff --git a/src/Resources/Locales/el_GR.axaml b/src/Resources/Locales/el_GR.axaml index 8b2bc2010a..47e9e7388f 100644 --- a/src/Resources/Locales/el_GR.axaml +++ b/src/Resources/Locales/el_GR.axaml @@ -639,6 +639,8 @@ Πέρυσι {0} λεπτά πριν {0} μήνες πριν + 1 εβδομάδα πριν + {0} εβδομάδες πριν {0} χρόνια πριν Χθες Προτιμήσεις diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index d3ffb203a4..0a64aebf69 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -660,6 +660,8 @@ Last year {0} minutes ago {0} months ago + 1 week ago + {0} weeks ago {0} years ago Yesterday Preferences diff --git a/src/Resources/Locales/es_ES.axaml b/src/Resources/Locales/es_ES.axaml index 96ea1ec582..9ba264b781 100644 --- a/src/Resources/Locales/es_ES.axaml +++ b/src/Resources/Locales/es_ES.axaml @@ -664,6 +664,8 @@ Último año Hace {0} minutos Hace {0} meses + Hace 1 semana + Hace {0} semanas Hace {0} años Ayer Preferencias diff --git a/src/Resources/Locales/fr_FR.axaml b/src/Resources/Locales/fr_FR.axaml index 05b98ea1ec..e092aac703 100644 --- a/src/Resources/Locales/fr_FR.axaml +++ b/src/Resources/Locales/fr_FR.axaml @@ -608,6 +608,8 @@ L'an dernier il y a {0} minutes il y a {0} mois + il y a 1 semaine + il y a {0} semaines il y a {0} ans Hier Préférences diff --git a/src/Resources/Locales/he_IL.axaml b/src/Resources/Locales/he_IL.axaml index 0abcb3b0c6..f442f4d319 100644 --- a/src/Resources/Locales/he_IL.axaml +++ b/src/Resources/Locales/he_IL.axaml @@ -608,6 +608,8 @@ בשנה שעברה לפני {0} דקות לפני {0} חודשים + לפני שבוע + לפני {0} שבועות לפני {0} שנים אתמול העדפות diff --git a/src/Resources/Locales/id_ID.axaml b/src/Resources/Locales/id_ID.axaml index 5e7e5e57f9..d6970a450e 100644 --- a/src/Resources/Locales/id_ID.axaml +++ b/src/Resources/Locales/id_ID.axaml @@ -640,6 +640,8 @@ Tahun lalu {0} menit lalu {0} bulan lalu + 1 minggu lalu + {0} minggu lalu {0} tahun lalu Kemarin Preferensi diff --git a/src/Resources/Locales/it_IT.axaml b/src/Resources/Locales/it_IT.axaml index 630746c5d8..1505b8f024 100644 --- a/src/Resources/Locales/it_IT.axaml +++ b/src/Resources/Locales/it_IT.axaml @@ -561,6 +561,8 @@ ${pure_files:N} Come ${files:N}, ma senza cartelle L'anno scorso {0} minuti fa {0} mesi fa + 1 settimana fa + {0} settimane fa {0} anni fa Ieri Preferenze diff --git a/src/Resources/Locales/ja_JP.axaml b/src/Resources/Locales/ja_JP.axaml index 023fc65dfe..1a4edbf422 100644 --- a/src/Resources/Locales/ja_JP.axaml +++ b/src/Resources/Locales/ja_JP.axaml @@ -654,6 +654,8 @@ 昨年 {0} 分前 {0} ヶ月前 + 1 週間前 + {0} 週間前 {0} 年前 昨日 設定 diff --git a/src/Resources/Locales/ko_KR.axaml b/src/Resources/Locales/ko_KR.axaml index 219cddf685..5e9c08e78d 100644 --- a/src/Resources/Locales/ko_KR.axaml +++ b/src/Resources/Locales/ko_KR.axaml @@ -611,6 +611,8 @@ 작년 {0}분 전 {0}개월 전 + 1주 전 + {0}주 전 {0}년 전 어제 환경설정 diff --git a/src/Resources/Locales/pt_BR.axaml b/src/Resources/Locales/pt_BR.axaml index 5084f0180b..da9d1430d5 100644 --- a/src/Resources/Locales/pt_BR.axaml +++ b/src/Resources/Locales/pt_BR.axaml @@ -391,6 +391,8 @@ Ano passado {0} minutos atrás {0} meses atrás + 1 semana atrás + {0} semanas atrás {0} anos atrás Ontem Preferências diff --git a/src/Resources/Locales/ru_RU.axaml b/src/Resources/Locales/ru_RU.axaml index 5aef219dbd..4958a03ef9 100644 --- a/src/Resources/Locales/ru_RU.axaml +++ b/src/Resources/Locales/ru_RU.axaml @@ -664,6 +664,8 @@ В прошлом году {0} минут назад {0} месяцев назад + 1 неделю назад + {0} недель назад {0} лет назад Вчера Параметры diff --git a/src/Resources/Locales/ta_IN.axaml b/src/Resources/Locales/ta_IN.axaml index 3473ff1cab..a7a3ac60ac 100644 --- a/src/Resources/Locales/ta_IN.axaml +++ b/src/Resources/Locales/ta_IN.axaml @@ -386,6 +386,8 @@ கடந்த ஆண்டு {0} நிமையங்களுக்கு முன்பு {0} திங்களுக்கு முன்பு + 1 வாரம் முன்பு + {0} வாரங்களுக்கு முன்பு {0} ஆண்டுகளுக்கு முன்பு நேற்று விருப்பத்தேர்வுகள் diff --git a/src/Resources/Locales/uk_UA.axaml b/src/Resources/Locales/uk_UA.axaml index 5f44193e39..1803408166 100644 --- a/src/Resources/Locales/uk_UA.axaml +++ b/src/Resources/Locales/uk_UA.axaml @@ -390,6 +390,8 @@ Минулого року {0} хвилин тому {0} місяців тому + 1 тиждень тому + {0} тижнів тому {0} років тому Вчора Налаштування diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index 90eb61af95..2e623335d2 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -664,6 +664,8 @@ 一年前 {0}分钟前 {0}个月前 + 1周前 + {0}周前 {0}年前 昨天 偏好设置 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index d59bd7d7c3..7fd18421b9 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -664,6 +664,8 @@ 一年前 {0} 分鐘前 {0} 個月前 + 1 週前 + {0} 週前 {0} 年前 昨天 偏好設定 diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs index 636aad6654..d76ede7072 100644 --- a/src/ViewModels/CommitDetail.cs +++ b/src/ViewModels/CommitDetail.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Avalonia; +using Avalonia.Controls; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -43,7 +44,17 @@ public int ActiveTabIndex OnPropertyChanged(nameof(ActiveTabIndex)); if (DiffContext == null) - UpdateDetails(); + { + _suppressMainDiff = true; + try + { + UpdateDetails(); + } + finally + { + _suppressMainDiff = false; + } + } } } } @@ -107,6 +118,40 @@ public DiffContext DiffContext private set => SetProperty(ref _diffContext, value); } + public bool IsInlineDiffVisible + { + get => _isInlineDiffVisible; + set + { + if (SetProperty(ref _isInlineDiffVisible, value)) + { + OnPropertyChanged(nameof(ChangesListColumnWidth)); + OnPropertyChanged(nameof(DiffSplitterColumnWidth)); + OnPropertyChanged(nameof(DiffPanelColumnWidth)); + } + } + } + + public GridLength ChangesListColumnWidth + { + get => _isInlineDiffVisible + ? Preferences.Instance.Layout.CommitDetailChangesLeftWidth + : new GridLength(1, GridUnitType.Star); + set + { + if (_isInlineDiffVisible) + Preferences.Instance.Layout.CommitDetailChangesLeftWidth = value; + } + } + + public GridLength DiffSplitterColumnWidth => _isInlineDiffVisible + ? new GridLength(4) + : new GridLength(0); + + public GridLength DiffPanelColumnWidth => _isInlineDiffVisible + ? new GridLength(1, GridUnitType.Star) + : new GridLength(0); + public string SearchChangeFilter { get => _searchChangeFilter; @@ -530,21 +575,53 @@ private void Refresh() Changes = changes; VisibleChanges = visible; - if (visible.Count == 0) - ChangeSelection = new(null); - else - ChangeSelection = new(VisibleChanges.GetRange(0, 1)); + _suppressMainDiff = true; + try + { + if (visible.Count == 0) + ChangeSelection = new(null); + else + ChangeSelection = new(VisibleChanges.GetRange(0, 1)); + } + finally + { + _suppressMainDiff = false; + } }); } }, token); } + public void PresentMainDiff() + { + if (_suppressMainDiff || DiffContext == null || _repo?.Histories == null) + return; + + _repo.Histories.OpenMainDiff(DiffContext); + } + + public void OpenMainDiffForChange(Models.Change change) + { + if (change == null || _commit == null || _repo?.Histories == null) + return; + + _repo.Histories.OpenMainDiff(new DiffContext(_repo.FullPath, new Models.DiffOption(_commit, change), _diffContext)); + } + private void UpdateDetails() { if (ActiveTabIndex == 1 && _changeSelection is { Count: 1, HasFolder: false }) DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_commit, _changeSelection.Changes[0]), _diffContext); else DiffContext = null; + + if (_suppressMainDiff || _repo?.Histories == null) + return; + + if (DiffContext != null) + _repo.Histories.OpenMainDiff(DiffContext); + else + _repo.Histories.CloseMainDiff(); } private async Task ParseInlinesInMessageAsync(string message) @@ -738,6 +815,8 @@ private async Task SetViewingCommitAsync(Models.Object file) private ChangeSelection _changeSelection = new(null); private string _searchChangeFilter = string.Empty; private DiffContext _diffContext = null; + private bool _isInlineDiffVisible = true; + private bool _suppressMainDiff = false; private string _viewRevisionFilePath = string.Empty; private object _viewRevisionFileContent = null; private CancellationTokenSource _cancellationSource = null; diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 128b53ff9d..27ee39607e 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -215,6 +215,22 @@ public bool IsOpenAsStandaloneVisible get => DetailContext is CommitDetail or RevisionCompare; } + public DiffContext MainDiffContext + { + get => _mainDiffContext; + private set + { + if (SetProperty(ref _mainDiffContext, value)) + IsMainDiffVisible = value != null; + } + } + + public bool IsMainDiffVisible + { + get => _isMainDiffVisible; + private set => SetProperty(ref _isMainDiffVisible, value); + } + public bool IsCollapseDetails { get => _isCollapseDetails; @@ -228,6 +244,27 @@ public bool IsCollapseDetails } } + public void OpenMainDiff(DiffContext context) + { + if (context == null) + { + CloseMainDiff(); + return; + } + + MainDiffContext = context; + SetInlineDiffVisible(false); + } + + public void CloseMainDiff() + { + if (_mainDiffContext == null) + return; + + MainDiffContext = null; + SetInlineDiffVisible(true); + } + public Histories(Repository repo) { _repo = repo; @@ -524,6 +561,8 @@ private void PostSelectedCommitsChanged() if (_ignoreSelectionChange) return; + CloseMainDiff(); + if (_selectedCommits.Count == 0) { _searchCommitContext.Selected = null; @@ -562,6 +601,14 @@ private void PostSelectedCommitsChanged() GenerateGraph(_commits); } + private void SetInlineDiffVisible(bool visible) + { + if (_detailContext is CommitDetail detail) + detail.IsInlineDiffVisible = visible; + else if (_detailContext is RevisionCompare compare) + compare.IsInlineDiffVisible = visible; + } + private void GenerateGraph(List commits) { var firstParentOnly = _repo.UIStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly); @@ -587,6 +634,8 @@ private void GenerateGraph(List commits) private List _selectedCommits = []; private Models.Bisect _bisect = null; private object _detailContext = Models.Null.Instance; + private DiffContext _mainDiffContext = null; + private bool _isMainDiffVisible = false; private bool _ignoreSelectionChange = false; private bool _isSearchingCommits = false; diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 4803ec4296..acfdf84a3b 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -831,7 +831,7 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private bool _showTagsInGraph = true; private bool _useTwoColumnsLayoutInHistories = false; - private bool _displayTimeAsPeriodInHistories = false; + private bool _displayTimeAsPeriodInHistories = true; private bool _useSideBySideDiff = false; private bool _ignoreWhitespaceChangesInDiff = false; private bool _ignoreCRAtEOLInDiff = true; diff --git a/src/ViewModels/RevisionCompare.cs b/src/ViewModels/RevisionCompare.cs index 5f0e2fc1f3..8d2a85b347 100644 --- a/src/ViewModels/RevisionCompare.cs +++ b/src/ViewModels/RevisionCompare.cs @@ -3,6 +3,7 @@ using System.IO; using System.Threading.Tasks; +using Avalonia.Controls; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -76,10 +77,26 @@ public ChangeSelection ChangeSelection DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), value.Changes[0]), _diffContext); else DiffContext = null; + + if (_suppressMainDiff || _repo?.Histories == null) + return; + + if (DiffContext != null) + _repo.Histories.OpenMainDiff(DiffContext); + else + _repo.Histories.CloseMainDiff(); } } } + public void PresentMainDiff() + { + if (_suppressMainDiff || DiffContext == null || _repo?.Histories == null) + return; + + _repo.Histories.OpenMainDiff(DiffContext); + } + public string SearchFilter { get => _searchFilter; @@ -96,6 +113,40 @@ public DiffContext DiffContext private set => SetProperty(ref _diffContext, value); } + public bool IsInlineDiffVisible + { + get => _isInlineDiffVisible; + set + { + if (SetProperty(ref _isInlineDiffVisible, value)) + { + OnPropertyChanged(nameof(ChangesListColumnWidth)); + OnPropertyChanged(nameof(DiffSplitterColumnWidth)); + OnPropertyChanged(nameof(DiffPanelColumnWidth)); + } + } + } + + public GridLength ChangesListColumnWidth + { + get => _isInlineDiffVisible + ? Preferences.Instance.Layout.CommitDetailChangesLeftWidth + : new GridLength(1, GridUnitType.Star); + set + { + if (_isInlineDiffVisible) + Preferences.Instance.Layout.CommitDetailChangesLeftWidth = value; + } + } + + public GridLength DiffSplitterColumnWidth => _isInlineDiffVisible + ? new GridLength(4) + : new GridLength(0); + + public GridLength DiffPanelColumnWidth => _isInlineDiffVisible + ? new GridLength(1, GridUnitType.Star) + : new GridLength(0); + public RevisionCompare(Repository repo, Models.Commit startPoint, Models.Commit endPoint) { _repo = repo; @@ -366,10 +417,18 @@ private void Refresh() VisibleChanges = visible; IsLoading = false; - if (VisibleChanges.Count > 0) - ChangeSelection = new(VisibleChanges.GetRange(0, 1)); - else - ChangeSelection = new(null); + _suppressMainDiff = true; + try + { + if (VisibleChanges.Count > 0) + ChangeSelection = new(VisibleChanges.GetRange(0, 1)); + else + ChangeSelection = new(null); + } + finally + { + _suppressMainDiff = false; + } }); }); } @@ -394,5 +453,7 @@ private string GetDesc(object obj) private ChangeSelection _changeSelection = new(null); private string _searchFilter = string.Empty; private DiffContext _diffContext = null; + private bool _isInlineDiffVisible = true; + private bool _suppressMainDiff = false; } } diff --git a/src/Views/ChangeCollectionView.axaml.cs b/src/Views/ChangeCollectionView.axaml.cs index 81b7030d61..216ec1bfaf 100644 --- a/src/Views/ChangeCollectionView.axaml.cs +++ b/src/Views/ChangeCollectionView.axaml.cs @@ -163,6 +163,15 @@ public event EventHandler ChangeDoubleTapped remove { RemoveHandler(ChangeDoubleTappedEvent, value); } } + public static readonly RoutedEvent ChangeActivatedEvent = + RoutedEvent.Register(nameof(ChangeActivated), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler ChangeActivated + { + add { AddHandler(ChangeActivatedEvent, value); } + remove { RemoveHandler(ChangeActivatedEvent, value); } + } + public ChangeCollectionView() { InitializeComponent(); @@ -346,6 +355,11 @@ private void OnRowSelectionChanged(object sender, SelectionChangedEventArgs _) Selection = selection; _disableSelectionChangingEvent = false; + + // Raise even when selection is unchanged so re-clicking the auto-selected + // first file still opens the main-pane diff. + if (selection is { Count: 1, HasFolder: false }) + RaiseEvent(new RoutedEventArgs(ChangeActivatedEvent)); } private void MakeTreeRows(List rows, List nodes) diff --git a/src/Views/CommitChanges.axaml b/src/Views/CommitChanges.axaml index d14b14eca4..fb44aacd41 100644 --- a/src/Views/CommitChanges.axaml +++ b/src/Views/CommitChanges.axaml @@ -9,9 +9,9 @@ x:DataType="vm:CommitDetail"> - - - + + + @@ -59,6 +59,7 @@ Changes="{Binding VisibleChanges, Mode=OneWay}" Selection="{Binding ChangeSelection, Mode=TwoWay}" ContextRequested="OnChangeContextRequested" + ChangeActivated="OnChangeActivated" KeyDown="OnChangeCollectionViewKeyDown"/> @@ -79,9 +80,10 @@ MinWidth="1" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Transparent" - Focusable="False"/> + Focusable="False" + IsVisible="{Binding IsInlineDiffVisible}"/> - + diff --git a/src/Views/CommitChanges.axaml.cs b/src/Views/CommitChanges.axaml.cs index 5ff13d5938..94d540477b 100644 --- a/src/Views/CommitChanges.axaml.cs +++ b/src/Views/CommitChanges.axaml.cs @@ -4,6 +4,7 @@ using System.Text; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Platform.Storage; using Avalonia.VisualTree; @@ -16,6 +17,12 @@ public CommitChanges() InitializeComponent(); } + private void OnChangeActivated(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail vm) + vm.PresentMainDiff(); + } + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) { e.Handled = true; diff --git a/src/Views/CommitDetail.axaml b/src/Views/CommitDetail.axaml index b0fbf5393c..4edbfc7adf 100644 --- a/src/Views/CommitDetail.axaml +++ b/src/Views/CommitDetail.axaml @@ -56,7 +56,7 @@ - + diff --git a/src/Views/CommitDetail.axaml.cs b/src/Views/CommitDetail.axaml.cs index dd175eadd8..d70f49fa89 100644 --- a/src/Views/CommitDetail.axaml.cs +++ b/src/Views/CommitDetail.axaml.cs @@ -339,12 +339,22 @@ private async void OnCommitListKeyDown(object sender, KeyEventArgs e) } } + private void OnChangeTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail detail && sender is Grid { DataContext: Models.Change change }) + { + detail.OpenMainDiffForChange(change); + e.Handled = true; + } + } + private void OnChangeDoubleTapped(object sender, TappedEventArgs e) { if (DataContext is ViewModels.CommitDetail detail && sender is Grid { DataContext: Models.Change change }) { - detail.ChangeSelection = new(new List { change }); detail.ActiveTabIndex = 1; + detail.ChangeSelection = new(new List { change }); + detail.PresentMainDiff(); } e.Handled = true; diff --git a/src/Views/CommitTimeTextBlock.cs b/src/Views/CommitTimeTextBlock.cs index 66c4090ab3..2c9a39be08 100644 --- a/src/Views/CommitTimeTextBlock.cs +++ b/src/Views/CommitTimeTextBlock.cs @@ -148,12 +148,19 @@ private string GetDisplayText() if (localTime >= lastDay) return App.Text("Period.Yesterday"); - if ((localTime.Year == now.Year && localTime.Month == now.Month) || span.TotalDays < 28) + var diffDay = (int)(now.Date - localTime.Date).TotalDays; + if (diffDay < 7) + return App.Text("Period.DaysAgo", diffDay); + + if (diffDay < 28) { - var diffDay = now.Date - localTime.Date; - return App.Text("Period.DaysAgo", (int)diffDay.TotalDays); + var weeks = diffDay / 7; + return weeks == 1 ? App.Text("Period.WeekAgo") : App.Text("Period.WeeksAgo", weeks); } + if (localTime.Year == now.Year && localTime.Month == now.Month) + return App.Text("Period.DaysAgo", diffDay); + var lastMonth = now.AddMonths(-1).Date; if (localTime.Year == lastMonth.Year && localTime.Month == lastMonth.Month) return App.Text("Period.LastMonth"); diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index a3d24529fd..f562b23c76 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -299,6 +299,44 @@ + + + + + + + + + + + + + + + + + + + - - - + + + @@ -114,6 +114,7 @@ Changes="{Binding VisibleChanges, Mode=OneWay}" Selection="{Binding ChangeSelection, Mode=TwoWay}" ContextRequested="OnChangeContextRequested" + ChangeActivated="OnChangeActivated" KeyDown="OnChangeCollectionViewKeyDown"/> @@ -139,9 +140,10 @@ MinWidth="1" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Transparent" - Focusable="False"/> + Focusable="False" + IsVisible="{Binding IsInlineDiffVisible}"/> - + diff --git a/src/Views/RevisionCompare.axaml.cs b/src/Views/RevisionCompare.axaml.cs index 1b2a1c32fb..446c9c8875 100644 --- a/src/Views/RevisionCompare.axaml.cs +++ b/src/Views/RevisionCompare.axaml.cs @@ -16,6 +16,12 @@ public RevisionCompare() InitializeComponent(); } + private void OnChangeActivated(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.RevisionCompare vm) + vm.PresentMainDiff(); + } + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) { if (DataContext is ViewModels.RevisionCompare { ChangeSelection: { Count: > 0 } selection } vm) From a28953dd6eb2d02bebc18ca6b0eaa278675ced14 Mon Sep 17 00:00:00 2001 From: Jeff Simons Decena Date: Fri, 6 Nov 2026 12:57:35 +1300 Subject: [PATCH 3/4] ux: Restyle branch pills and add collapsible sidebar rail Draw solid GitKraken-style ref pills with trailing local/remote icons outside the graph lanes. Add a histories sidebar collapse toggle that keeps an icon rail with count badges and a clear divider beside the commit graph. --- src/Resources/Icons.axaml | 1 + src/ViewModels/LayoutInfo.cs | 7 ++ src/Views/CommitRefsPresenter.cs | 117 +++++++++++++++---------- src/Views/Histories.axaml | 40 +++++---- src/Views/Histories.axaml.cs | 2 +- src/Views/LauncherPage.axaml | 7 ++ src/Views/Repository.axaml | 145 +++++++++++++++++++++++++++++-- src/Views/Repository.axaml.cs | 55 +++++++++++- 8 files changed, 308 insertions(+), 66 deletions(-) diff --git a/src/Resources/Icons.axaml b/src/Resources/Icons.axaml index a410aef45f..2e4490f10e 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -91,6 +91,7 @@ M0 33h1024v160H0zM0 432h1024v160H0zM0 831h1024v160H0z M512 0C233 0 7 223 0 500C6 258 190 64 416 64c230 0 416 200 416 448c0 53 43 96 96 96s96-43 96-96c0-283-229-512-512-512zm0 1023c279 0 505-223 512-500c-6 242-190 436-416 436c-230 0-416-200-416-448c0-53-43-96-96-96s-96 43-96 96c0 283 229 512 512 512z M976 0h-928A48 48 0 000 48v652a48 48 0 0048 48h416V928H200a48 48 0 000 96h624a48 48 0 000-96H560v-180h416a48 48 0 0048-48V48A48 48 0 00976 0zM928 652H96V96h832v556z + M976 0h-928A48 48 0 000 48v652a48 48 0 0048 48h416V928H200a48 48 0 000 96h624a48 48 0 000-96H560v-180h416a48 48 0 0048-48V48A48 48 0 00976 0zM928 652H96V96h832v556z M832 464h-68V240a128 128 0 00-128-128h-248a128 128 0 00-128 128v224H192c-18 0-32 14-32 32v384c0 18 14 32 32 32h640c18 0 32-14 32-32v-384c0-18-14-32-32-32zm-292 237v53a8 8 0 01-8 8h-40a8 8 0 01-8-8v-53a48 48 0 1156 0zm152-237H332V240a56 56 0 0156-56h248a56 56 0 0156 56v224z M908 366h-25V248a18 18 0 00-0-2 20 20 0 00-5-13L681 7 681 7a19 19 0 00-4-3c-0-0-1-1-1-1a29 29 0 00-4-2L671 1a24 24 0 00-5-1H181a40 40 0 00-40 40v326h-25c-32 0-57 26-57 57v298c0 32 26 57 57 57h25v204c0 22 18 40 40 40H843a40 40 0 0040-40v-204h25c32 0 57-26 57-57V424a57 57 0 00-57-57zM181 40h465v205c0 11 9 20 20 20h177v101H181V40zm413 527c0 89-54 143-134 143-81 0-128-61-128-138 0-82 52-143 132-143 84 0 129 63 129 138zm-440 139V433h62v220h108v52h-170zm690 267H181v-193H843l0 193zm18-280a305 305 0 01-91 15c-50 0-86-12-111-37-25-23-39-59-38-99 0-90 66-142 155-142 35 0 62 7 76 13l-13 49c-15-6-33-12-63-12-51 0-90 29-90 88 0 56 35 89 86 89 14 0 25-2 30-4v-57h-42v-48h101v143zM397 570c0 53 25 91 66 91 42 0 65-40 65-92 0-49-23-91-66-91-42 0-66 40-66 93z M192 192m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0ZM192 512m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0ZM192 832m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0ZM864 160H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32zM864 480H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32zM864 800H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32z diff --git a/src/ViewModels/LayoutInfo.cs b/src/ViewModels/LayoutInfo.cs index 43b58f5013..71137559c7 100644 --- a/src/ViewModels/LayoutInfo.cs +++ b/src/ViewModels/LayoutInfo.cs @@ -53,6 +53,12 @@ public bool IsSidebarCollapsedInLocalChanges set => SetProperty(ref _isSidebarCollapsedInLocalChanges, value); } + public bool IsSidebarCollapsedInHistories + { + get => _isSidebarCollapsedInHistories; + set => SetProperty(ref _isSidebarCollapsedInHistories, value); + } + public GridLength WorkingCopyLeftWidth { get => _workingCopyLeftWidth; @@ -82,5 +88,6 @@ public GridLength CommitDetailFilesLeftWidth private GridLength _commitDetailChangesLeftWidth = new GridLength(256, GridUnitType.Pixel); private GridLength _commitDetailFilesLeftWidth = new GridLength(256, GridUnitType.Pixel); private bool _isSidebarCollapsedInLocalChanges = false; + private bool _isSidebarCollapsedInHistories = false; } } diff --git a/src/Views/CommitRefsPresenter.cs b/src/Views/CommitRefsPresenter.cs index 85e33962a1..e48b74a335 100644 --- a/src/Views/CommitRefsPresenter.cs +++ b/src/Views/CommitRefsPresenter.cs @@ -23,24 +23,39 @@ public static CommitRefsIconCache Instance public CommitRefsIconCache() { _head = LoadIcon("Icons.Head"); - _branch = LoadIcon("Icons.Branch"); + _monitor = LoadIcon("Icons.Monitor"); _remote = LoadIcon("Icons.Remote"); _tag = LoadIcon("Icons.Tag"); } - public Geometry GetIcon(Models.DecoratorType type) + public Geometry GetLeadingIcon(Models.DecoratorType type) { return type switch { Models.DecoratorType.CurrentBranchHead => _head, Models.DecoratorType.CurrentCommitHead => _head, - Models.DecoratorType.LocalBranchHead => _branch, + _ => null, + }; + } + + public Geometry GetTrailingIcon(Models.DecoratorType type) + { + return type switch + { + Models.DecoratorType.CurrentBranchHead => _monitor, + Models.DecoratorType.CurrentCommitHead => _monitor, + Models.DecoratorType.LocalBranchHead => _monitor, Models.DecoratorType.RemoteBranchHead => _remote, Models.DecoratorType.Tag => _tag, _ => null, }; } + public Geometry GetIcon(Models.DecoratorType type) + { + return GetTrailingIcon(type) ?? GetLeadingIcon(type); + } + private Geometry LoadIcon(string resourceKey) { var geo = App.Current.FindResource(resourceKey) as StreamGeometry; @@ -59,13 +74,18 @@ private Geometry LoadIcon(string resourceKey) private static CommitRefsIconCache _instance = null; private Geometry _head = null; - private Geometry _branch = null; + private Geometry _monitor = null; private Geometry _remote = null; private Geometry _tag = null; } public class CommitRefsPresenter : Control { + private const double PILL_HEIGHT = 16.0; + private const double PILL_PAD = 6.0; + private const double ICON_SIZE = 10.0; + private const double ICON_GAP = 4.0; + public class RenderItem { public Models.Decorator Decorator { get; set; } = null; @@ -175,13 +195,12 @@ public override void Render(DrawingContext context) if (_items.Count == 0) return; - var useGraphColor = UseGraphColor; - var fg = Foreground; - var bg = Background; + var fg = Brushes.White; var allowWrap = AllowWrap; var x = 1.5; var y = 0.5; - var remoteIcon = CommitRefsIconCache.Instance.GetIcon(Models.DecoratorType.RemoteBranchHead); + var icons = CommitRefsIconCache.Instance; + var remoteIcon = icons.GetTrailingIcon(Models.DecoratorType.RemoteBranchHead); var hasSingleRemote = HasSingleRemote; context.FillRectangle(Brushes.Transparent, Bounds); @@ -194,60 +213,62 @@ public override void Render(DrawingContext context) y += 20.0; } - var entireRect = new RoundedRect(new Rect(x, y, item.Width, 16), new CornerRadius(4)); + var entireRect = new RoundedRect(new Rect(x, y, item.Width, PILL_HEIGHT), new CornerRadius(4)); + + // Solid pill fill (slightly stronger for HEAD). if (item.IsHead) { - if (useGraphColor) - { - if (bg != null) - context.DrawRectangle(bg, null, entireRect); - - using (context.PushOpacity(.6)) - context.DrawRectangle(item.Brush, null, entireRect); - } + context.DrawRectangle(item.Brush, null, entireRect); } else { - if (bg != null) - context.DrawRectangle(bg, null, entireRect); + using (context.PushOpacity(0.9)) + context.DrawRectangle(item.Brush, null, entireRect); + } - var labelRect = new RoundedRect(new Rect(x + 16, y, item.Width - 16, 16), new CornerRadius(4, 0, 0, 4)); - using (context.PushOpacity(.2)) - context.DrawRectangle(item.Brush, null, labelRect); + var contentX = x + PILL_PAD; + var iconY = y + (PILL_HEIGHT - ICON_SIZE) * 0.5; + + var leading = icons.GetLeadingIcon(item.Decorator.Type); + if (leading != null) + { + using (context.PushTransform(Matrix.CreateTranslation(contentX, iconY))) + context.DrawGeometry(fg, null, leading); + contentX += ICON_SIZE + ICON_GAP; } - context.DrawLine(new Pen(item.Brush), new Point(x + 16, y), new Point(x + 16, y + 16)); - context.DrawText(item.Label, new Point(x + 20, y + 8.0 - item.Label.Height * 0.5)); + context.DrawText(item.Label, new Point(contentX, y + PILL_HEIGHT * 0.5 - item.Label.Height * 0.5)); + contentX += item.Label.WidthIncludingTrailingWhitespace + ICON_GAP; - if (item.Remotes.Count > 0) + var trailing = icons.GetTrailingIcon(item.Decorator.Type); + if (trailing != null) { - var rx = x + 20 + item.Label.WidthIncludingTrailingWhitespace + 4; + using (context.PushTransform(Matrix.CreateTranslation(contentX, iconY))) + context.DrawGeometry(fg, null, trailing); + contentX += ICON_SIZE; + } + if (item.Remotes.Count > 0) + { + contentX += ICON_GAP; if (hasSingleRemote) { - context.DrawLine(new Pen(item.Brush), new Point(rx, y), new Point(rx, y + 16)); - using (context.PushTransform(Matrix.CreateTranslation(rx + 4, y + 4))) + using (context.PushTransform(Matrix.CreateTranslation(contentX, iconY))) 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))) + using (context.PushTransform(Matrix.CreateTranslation(contentX, iconY))) context.DrawGeometry(fg, null, remoteIcon); - context.DrawText(remote, new Point(rx + 16, y + 8.0 - remote.Height * 0.5)); - rx += remote.WidthIncludingTrailingWhitespace + 22; + contentX += ICON_SIZE + 2; + context.DrawText(remote, new Point(contentX, y + PILL_HEIGHT * 0.5 - remote.Height * 0.5)); + contentX += remote.WidthIncludingTrailingWhitespace + ICON_GAP; } } } - context.DrawRectangle(null, new Pen(item.Brush), entireRect); - - var icon = CommitRefsIconCache.Instance.GetIcon(item.Decorator.Type); - using (context.PushTransform(Matrix.CreateTranslation(x + 3, y + 3))) - context.DrawGeometry(fg, null, icon); - x += item.Width + 4; } } @@ -293,14 +314,15 @@ protected override Size MeasureOverride(Size availableSize) var typeface = new Typeface(FontFamily); var typefaceHead = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold); var typefaceRemote = new Typeface(FontFamily, FontStyle.Italic, FontWeight.Bold); - var fg = Foreground; + var fg = Brushes.White; var normalBG = UseGraphColor ? Models.CommitGraph.Pens[commit.Color].Brush : Brushes.Gray; var labelSize = FontSize; - var requiredHeight = 16.0; + var requiredHeight = PILL_HEIGHT; var x = 0.0; var allowWrap = AllowWrap; var showTags = ShowTags; var skippedIdx = new HashSet(); + var icons = CommitRefsIconCache.Instance; for (var i = 0; i < count; i++) { @@ -340,7 +362,12 @@ protected override Size MeasureOverride(Size availableSize) fg); } - item.Width = item.Label.Width + 24; + // [pad] [optional leading] [label] [gap] [trailing] [pad] (+ compact remotes) + var width = PILL_PAD * 2 + item.Label.WidthIncludingTrailingWhitespace; + if (icons.GetLeadingIcon(decorator.Type) != null) + width += ICON_SIZE + ICON_GAP; + if (icons.GetTrailingIcon(decorator.Type) != null) + width += ICON_GAP + ICON_SIZE; var findRemotes = useCompactBranchNames && (decorator.Type == Models.DecoratorType.CurrentBranchHead || decorator.Type == Models.DecoratorType.LocalBranchHead); if (findRemotes) @@ -369,15 +396,17 @@ protected override Size MeasureOverride(Size availableSize) item.Remotes.Add(remote); if (hasSingleRemote) - item.Width += 18; + width += ICON_GAP + ICON_SIZE; else - item.Width += remote.WidthIncludingTrailingWhitespace + 22; + width += ICON_GAP + ICON_SIZE + 2 + remote.WidthIncludingTrailingWhitespace; skippedIdx.Add(j); } } } + item.Width = width; + x += item.Width + 4; if (allowWrap) { @@ -392,7 +421,7 @@ protected override Size MeasureOverride(Size availableSize) double requiredWidth = 0; if (_items.Count > 0) { - if (allowWrap && requiredHeight > 16.0) + if (allowWrap && requiredHeight > PILL_HEIGHT) requiredWidth = double.IsInfinity(availableSize.Width) ? x + 2 : availableSize.Width; else requiredWidth = x + 2; diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index f562b23c76..24754b4d24 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -112,8 +112,22 @@ - - + + + + + + + + - - - - + + @@ -253,6 +258,11 @@ Margin="4,0,0,0" VerticalAlignment="Center" Spacing="6"> + + @@ -276,7 +286,7 @@ = rowHeight; - var clipWidth = dataGrid.Columns[0].ActualWidth - 4; + var clipWidth = dataGrid.Columns[0].ActualWidth - 4 - 184; // 184 = branch/tag pill gutter (should match CommitGraph left margin) var lastLayout = CommitGraph.Layout; if (lastLayout == null || Math.Abs(lastLayout.StartY - startY) > 0.01 || diff --git a/src/Views/LauncherPage.axaml b/src/Views/LauncherPage.axaml index 08107bfa90..fea135227e 100644 --- a/src/Views/LauncherPage.axaml +++ b/src/Views/LauncherPage.axaml @@ -60,6 +60,13 @@ + + + + + + + diff --git a/src/Views/Repository.axaml b/src/Views/Repository.axaml index 7005773478..1047fd11bd 100644 --- a/src/Views/Repository.axaml +++ b/src/Views/Repository.axaml @@ -258,7 +258,131 @@ - + + + + + + + + + + + + + + + + + @@ -580,7 +705,14 @@ DataContext="{Binding Histories.SearchCommitContext, Mode=OneWay}" IsVisible="{Binding #ThisControl.IsCommitSearchPanelVisible, Mode=OneWay}"/> - + + + + - - - + + + + + + diff --git a/src/Views/Repository.axaml.cs b/src/Views/Repository.axaml.cs index 93d77396ec..424aceebbb 100644 --- a/src/Views/Repository.axaml.cs +++ b/src/Views/Repository.axaml.cs @@ -42,6 +42,22 @@ public bool IsNormalSidebarVisible } } + public static readonly DirectProperty IsSidebarCollapsedProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsSidebarCollapsed), + static o => o.IsSidebarCollapsed, + static (o, v) => o.IsSidebarCollapsed = v); + + public bool IsSidebarCollapsed + { + get => _isSidebarCollapsed; + set + { + if (SetAndRaise(IsSidebarCollapsedProperty, ref _isSidebarCollapsed, value)) + CalculateSidebarWidth(); + } + } + public static readonly DirectProperty SidebarMinWidthProperty = AvaloniaProperty.RegisterDirect( nameof(SidebarMinWidth), @@ -70,7 +86,7 @@ public GridLength SidebarWidth var layout = ViewModels.Preferences.Instance.Layout; if (_isCommitSearchPanelVisible) layout.RepositorySearchCommitWidth = value.Value; - else if (_isNormalSidebarVisible) + else if (_isNormalSidebarVisible && !_isSidebarCollapsed) layout.RepositorySidebarWidth = value.Value; } } @@ -518,6 +534,11 @@ private void CalculateSidebarWidth() SidebarMinWidth = 200; SidebarWidth = new GridLength(layout.RepositorySearchCommitWidth, GridUnitType.Pixel); } + else if (_isNormalSidebarVisible && _isSidebarCollapsed) + { + SidebarMinWidth = COLLAPSED_SIDEBAR_WIDTH; + SidebarWidth = new GridLength(COLLAPSED_SIDEBAR_WIDTH, GridUnitType.Pixel); + } else if (_isNormalSidebarVisible) { SidebarMinWidth = 200; @@ -530,7 +551,39 @@ private void CalculateSidebarWidth() } } + private void OnSidebarRailSectionClicked(object sender, RoutedEventArgs e) + { + if (sender is not Control { Tag: string section } || DataContext is not ViewModels.Repository repo) + return; + + ViewModels.Preferences.Instance.Layout.IsSidebarCollapsedInHistories = false; + + switch (section) + { + case "local": + repo.IsLocalBranchGroupExpanded = true; + break; + case "remote": + repo.IsRemoteGroupExpanded = true; + break; + case "tags": + repo.IsTagGroupExpanded = true; + break; + case "submodules": + repo.IsSubmoduleGroupExpanded = true; + break; + case "worktrees": + repo.IsWorktreeGroupExpanded = true; + break; + } + + e.Handled = true; + } + + private const double COLLAPSED_SIDEBAR_WIDTH = 52; + private bool _isNormalSidebarVisible = true; + private bool _isSidebarCollapsed = false; private bool _isCommitSearchPanelVisible = false; private double _sidebarMinWidth = 200; private GridLength _sidebarWidth; From 582b1d46e68edb6b7eeb4dafdfc49f827b7ce8cd Mon Sep 17 00:00:00 2001 From: Jeff Simons Decena Date: Fri, 6 Nov 2026 19:59:32 +1300 Subject: [PATCH 4/4] ux: Show uncommitted changes as a WIP tip on the commit graph Add a GitKraken-style local-changes row at the tip of the histories timeline when there are dirty files. Selecting it opens a changes list in the details panel; clicking a file opens the diff over the graph with the same closable main-diff overlay used for committed files. --- src/Converters/BoolConverters.cs | 3 + src/Models/Commit.cs | 6 ++ src/Models/CommitGraph.cs | 5 +- src/ViewModels/Histories.cs | 30 ++++++ src/ViewModels/LocalChangesDetail.cs | 146 ++++++++++++++++++++++++++ src/ViewModels/Repository.cs | 64 ++++++++++- src/Views/CommitGraph.cs | 7 ++ src/Views/CommitSubjectPresenter.cs | 17 ++- src/Views/Histories.axaml | 9 +- src/Views/Histories.axaml.cs | 13 +++ src/Views/LocalChangesDetail.axaml | 80 ++++++++++++++ src/Views/LocalChangesDetail.axaml.cs | 76 ++++++++++++++ 12 files changed, 450 insertions(+), 6 deletions(-) create mode 100644 src/ViewModels/LocalChangesDetail.cs create mode 100644 src/Views/LocalChangesDetail.axaml create mode 100644 src/Views/LocalChangesDetail.axaml.cs diff --git a/src/Converters/BoolConverters.cs b/src/Converters/BoolConverters.cs index 5c5dd9047e..0e0b4b1ab6 100644 --- a/src/Converters/BoolConverters.cs +++ b/src/Converters/BoolConverters.cs @@ -13,6 +13,9 @@ public static class BoolConverters public static readonly FuncValueConverter IsMergedToOpacity = new(x => x ? 1 : 0.65); + public static readonly FuncValueConverter IsItalicToFontStyle = + new(x => x ? FontStyle.Italic : FontStyle.Normal); + public static readonly FuncValueConverter IsWarningToBrush = new(x => x ? Brushes.DarkGoldenrod : Application.Current?.FindResource("Brush.FG1") as IBrush); } diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index de299ed6b7..dc97e7a252 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -15,6 +15,11 @@ public enum CommitSearchMethod public class Commit : ObservableObject { + /// + /// Pseudo SHA used by the "local changes" (WIP) row shown at the top of the histories graph. + /// + public const string UncommittedSHA = "0000000000000000000000000000000000000000"; + public string SHA { get; set; } = string.Empty; public User Author { get; set; } = User.Invalid; public ulong AuthorTime { get; set; } = 0; @@ -34,6 +39,7 @@ public bool IsHighlightedInGraph set => SetProperty(ref _isHighlightedInGraph, value); } + public bool IsUncommitted => SHA.Equals(UncommittedSHA, StringComparison.Ordinal); public bool IsCommitterVisible => !Author.Equals(Committer) || AuthorTime != CommitterTime; public bool IsCurrentHead => Decorators.Find(x => x.Type is DecoratorType.CurrentBranchHead or DecoratorType.CurrentCommitHead) != null; public bool HasDecorators => Decorators.Count > 0; diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 916a79b1bb..4d0b4654ba 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -57,6 +57,7 @@ public enum DotType Default, Head, Merge, + Uncommitted, } public class Dot @@ -199,7 +200,9 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna var position = new Point(major?.LastX ?? offsetX, offsetY); var dotColor = major?.Path.Color ?? 0; var anchor = new Dot() { Center = position, Color = dotColor, IsHighlighted = isHighlighted }; - if (commit.IsCurrentHead) + if (commit.IsUncommitted) + anchor.Type = DotType.Uncommitted; + else if (commit.IsCurrentHead) anchor.Type = DotType.Head; else if (commit.Parents.Count > 1) anchor.Type = DotType.Merge; diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 27ee39607e..134fc14538 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -561,6 +561,18 @@ private void PostSelectedCommitsChanged() if (_ignoreSelectionChange) return; + if (_selectedCommits.Count == 1 && _selectedCommits[0].IsUncommitted) + { + OpenLocalChanges(); + return; + } + + if (_selectedCommits.Count > 1 && _selectedCommits.Exists(x => x.IsUncommitted)) + { + SelectedCommits = _selectedCommits.FindAll(x => !x.IsUncommitted); + return; + } + CloseMainDiff(); if (_selectedCommits.Count == 0) @@ -601,6 +613,23 @@ private void PostSelectedCommitsChanged() GenerateGraph(_commits); } + /// + /// Shows the uncommitted changes (working copy) in the details panel while keeping the + /// commit graph visible. + /// + public void OpenLocalChanges() + { + CloseMainDiff(); + _searchCommitContext.Selected = null; + + _localChangesDetail ??= new LocalChangesDetail(_repo); + _localChangesDetail.RefreshFromWorkingCopy(); + DetailContext = _localChangesDetail; + + if (_repo.UIStates.GraphHighlighting >= Models.CommitGraphHighlighting.SelectedCommitsOnly) + GenerateGraph(_commits); + } + private void SetInlineDiffVisible(bool visible) { if (_detailContext is CommitDetail detail) @@ -638,6 +667,7 @@ private void GenerateGraph(List commits) private bool _isMainDiffVisible = false; private bool _ignoreSelectionChange = false; + private LocalChangesDetail _localChangesDetail = null; private bool _isSearchingCommits = false; private SearchCommitContext _searchCommitContext = null; diff --git a/src/ViewModels/LocalChangesDetail.cs b/src/ViewModels/LocalChangesDetail.cs new file mode 100644 index 0000000000..569b5a17f0 --- /dev/null +++ b/src/ViewModels/LocalChangesDetail.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + /// + /// Commit-detail-style change list for uncommitted (WIP) files. Diffs open on the timeline + /// graph via , not inline. + /// + public partial class LocalChangesDetail : ObservableObject + { + public Repository Repository => _repo; + + public List Changes + { + get => _changes; + private set => SetProperty(ref _changes, value); + } + + public List VisibleChanges + { + get => _visibleChanges; + private set => SetProperty(ref _visibleChanges, value); + } + + public ChangeSelection ChangeSelection + { + get => _changeSelection; + set + { + if (SetProperty(ref _changeSelection, value)) + UpdateDetails(); + } + } + + public string SearchChangeFilter + { + get => _searchChangeFilter; + set + { + if (SetProperty(ref _searchChangeFilter, value)) + RefreshVisibleChanges(); + } + } + + public LocalChangesDetail(Repository repo) + { + _repo = repo; + _repo.WorkingCopy.PropertyChanged += OnWorkingCopyPropertyChanged; + } + + public void RefreshFromWorkingCopy() + { + var wc = _repo.WorkingCopy; + _unstagedPaths = new HashSet(wc.Unstaged.Select(c => c.Path), StringComparer.Ordinal); + + var all = new List(wc.Staged.Count + wc.Unstaged.Count); + all.AddRange(wc.Staged); + all.AddRange(wc.Unstaged); + Changes = all; + RefreshVisibleChanges(); + } + + public void ClearSearchChangeFilter() + { + SearchChangeFilter = string.Empty; + } + + public string GetAbsPath(string path) + { + return Native.OS.GetAbsPath(_repo.FullPath, path); + } + + public void PresentMainDiff() + { + if (_diffContext == null || _repo.Histories == null) + return; + + _repo.Histories.OpenMainDiff(_diffContext); + } + + private void OnWorkingCopyPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is not (nameof(WorkingCopy.Staged) or nameof(WorkingCopy.Unstaged))) + return; + + if (_repo.Histories?.DetailContext == this) + RefreshFromWorkingCopy(); + } + + private void RefreshVisibleChanges() + { + if (string.IsNullOrEmpty(_searchChangeFilter)) + { + VisibleChanges = _changes; + return; + } + + var filter = _searchChangeFilter; + var visible = new List(); + foreach (var c in _changes) + { + if (c.Path.Contains(filter, StringComparison.OrdinalIgnoreCase) || + c.OriginalPath.Contains(filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); + } + + VisibleChanges = visible; + } + + private void UpdateDetails() + { + if (_changeSelection is not { Count: 1, HasFolder: false }) + { + _diffContext = null; + _repo.Histories?.CloseMainDiff(); + return; + } + + var change = _changeSelection.Changes[0]; + if (change.IsConflicted) + { + _diffContext = null; + _repo.Histories?.CloseMainDiff(); + return; + } + + var isUnstaged = _unstagedPaths.Contains(change.Path); + var context = new DiffContext(_repo.FullPath, new Models.DiffOption(change, isUnstaged), _diffContext); + _diffContext = context; + _repo.Histories?.OpenMainDiff(context); + } + + private readonly Repository _repo; + private List _changes = []; + private List _visibleChanges = []; + private ChangeSelection _changeSelection = new(null); + private string _searchChangeFilter = string.Empty; + private HashSet _unstagedPaths = new(StringComparer.Ordinal); + private DiffContext _diffContext = null; + } +} diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 1ea5951a83..a919859ae5 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -161,6 +161,8 @@ private set if (value != null && !value.Head.Equals(oldHead, StringComparison.Ordinal) && _workingCopy is { UseAmend: true }) _workingCopy.UseAmend = false; + + SyncUncommittedRowInHistories(); } } } @@ -1252,7 +1254,7 @@ public void RefreshCommits() if (_histories != null) { _histories.IsLoading = false; - _histories.Commits = commits; + _histories.Commits = WithUncommittedRow(commits); BisectState = _histories.UpdateBisectInfo(); if (!string.IsNullOrEmpty(_navigateToCommitDelayed)) @@ -1348,6 +1350,7 @@ public void RefreshWorkingCopyChanges() _workingCopy.SetData(changes); LocalChangesCount = changes.Count; + SyncUncommittedRowInHistories(); OnPropertyChanged(nameof(InProgressContext)); GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasLocalChanges, changes.Count == 0); }); @@ -1787,6 +1790,65 @@ private void UpdateBranchTreeFilterMode(List nodes, Dictionary + /// Prepends a GitKraken-style "WIP" pseudo commit to the histories list when there are + /// uncommitted local changes. The row is linked to the current HEAD so it sits at the + /// tip of the commit graph. + /// + private List WithUncommittedRow(List commits) + { + if (IsBare || _localChangesCount <= 0) + return commits; + + var now = (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var row = new Models.Commit + { + SHA = Models.Commit.UncommittedSHA, + Subject = App.Text("WorkingCopy"), + AuthorTime = now, + CommitterTime = now, + IsMerged = true, + }; + + var head = FindUncommittedRowParent(commits); + if (head != null) + row.Parents.Add(head); + + return [row, .. commits]; + } + + private string FindUncommittedRowParent(List commits) + { + var head = _currentBranch?.Head; + if (string.IsNullOrEmpty(head)) + return null; + + return commits.Exists(c => c.SHA.Equals(head, StringComparison.Ordinal)) ? head : null; + } + + private void SyncUncommittedRowInHistories() + { + if (_histories == null || _histories.IsLoading) + return; + + var commits = _histories.Commits; + var hasRow = commits.Count > 0 && commits[0].IsUncommitted; + var needRow = !IsBare && _localChangesCount > 0; + + if (!hasRow && !needRow) + return; + + var core = hasRow ? commits.GetRange(1, commits.Count - 1) : commits; + if (hasRow && needRow) + { + var currentParent = commits[0].Parents.Count > 0 ? commits[0].Parents[0] : null; + if (string.Equals(currentParent, FindUncommittedRowParent(core), StringComparison.Ordinal)) + return; + } + + _histories.Commits = WithUncommittedRow(core); + } + private void UpdateTagFilterMode(Dictionary map) { if (VisibleTags is TagCollectionAsTree tree) diff --git a/src/Views/CommitGraph.cs b/src/Views/CommitGraph.cs index 88a8217c6b..58aed18fd5 100644 --- a/src/Views/CommitGraph.cs +++ b/src/Views/CommitGraph.cs @@ -187,6 +187,13 @@ private void DrawAnchors(DrawingContext context, Models.CommitGraph graph, doubl context.DrawEllipse(dotFill, pen, center, 6, 6); context.DrawEllipse(pen.Brush, null, center, 3, 3); break; + case Models.CommitGraph.DotType.Uncommitted: + { + // GitKraken-style WIP node: hollow dashed circle in the branch color. + var dashed = new Pen(pen.Brush, 1.5, new DashStyle([2, 2], 0)); + context.DrawEllipse(dotFill, dashed, center, 6, 6); + break; + } case Models.CommitGraph.DotType.Merge: context.DrawEllipse(pen.Brush, null, center, 6, 6); context.DrawLine(dotFillPen, new Point(center.X, center.Y - 3), new Point(center.X, center.Y + 3)); diff --git a/src/Views/CommitSubjectPresenter.cs b/src/Views/CommitSubjectPresenter.cs index 6949072746..00c90aec45 100644 --- a/src/Views/CommitSubjectPresenter.cs +++ b/src/Views/CommitSubjectPresenter.cs @@ -50,6 +50,15 @@ public FontWeight FontWeight set => SetValue(FontWeightProperty, value); } + public static readonly StyledProperty FontStyleProperty = + TextBlock.FontStyleProperty.AddOwner(); + + public FontStyle FontStyle + { + get => GetValue(FontStyleProperty); + set => SetValue(FontStyleProperty, value); + } + public static readonly StyledProperty InlineCodeBackgroundProperty = AvaloniaProperty.Register(nameof(InlineCodeBackground), Brushes.Transparent); @@ -189,6 +198,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang change.Property == CodeFontFamilyProperty || change.Property == FontSizeProperty || change.Property == FontWeightProperty || + change.Property == FontStyleProperty || change.Property == ForegroundProperty || change.Property == LinkForegroundProperty) { @@ -322,8 +332,9 @@ private void GenerateFormattedTextElements() var foreground = Foreground; var inlineCodeForeground = InlineCodeForeground; var linkForeground = LinkForeground; - var typeface = new Typeface(fontFamily, FontStyle.Normal, FontWeight); - var codeTypeface = new Typeface(codeFontFamily, FontStyle.Normal, FontWeight); + var fontStyle = FontStyle; + var typeface = new Typeface(fontFamily, fontStyle, FontWeight); + var codeTypeface = new Typeface(codeFontFamily, fontStyle, FontWeight); var pos = 0; var x = 0.0; for (var i = 0; i < _elements.Count; i++) @@ -349,7 +360,7 @@ private void GenerateFormattedTextElements() subject.Substring(elem.Start, elem.Length), CultureInfo.CurrentCulture, FlowDirection.LeftToRight, - new Typeface(fontFamily, FontStyle.Normal, FontWeight.Bold), + new Typeface(fontFamily, fontStyle, FontWeight.Bold), fontSize, foreground); _inlines.Add(new Inline(x, keyword, elem)); diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index 24754b4d24..932f511380 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -151,6 +151,7 @@ LinkForeground="{DynamicResource Brush.Link}" Subject="{Binding Subject}" IssueTrackers="{Binding $parent[v:Histories].IssueTrackers}" + FontStyle="{Binding IsUncommitted, Converter={x:Static c:BoolConverters.IsItalicToFontStyle}}" FontWeight="{Binding IsCurrentHead, Converter={x:Static c:BoolConverters.IsBoldToFontWeight}}" Opacity="{Binding IsMerged, Converter={x:Static c:BoolConverters.IsMergedToOpacity}}"/> @@ -175,6 +176,7 @@ VerticalAlignment="Center" User="{Binding Author}" Opacity="{Binding IsMerged, Converter={x:Static c:BoolConverters.IsMergedToOpacity}}" + IsVisible="{Binding !IsUncommitted}" IsHitTestVisible="False"/> @@ -199,7 +201,8 @@ + Opacity="{Binding IsMerged, Converter={x:Static c:BoolConverters.IsMergedToOpacity}}" + IsVisible="{Binding !IsUncommitted}"/> @@ -402,6 +405,10 @@ + + + + diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs index 1adb6f5a1d..c68ee183a0 100644 --- a/src/Views/Histories.axaml.cs +++ b/src/Views/Histories.axaml.cs @@ -810,6 +810,13 @@ private void OnCommitListContextRequested(object sender, ContextRequestedEventAr commits.Add(c); } + // The WIP (local changes) row has no commit context menu. + if (commits.Exists(x => x.IsUncommitted)) + { + e.Handled = true; + return; + } + if (selected.Count > 1) { var menu = CreateContextMenuForMultipleCommits(repo, commits); @@ -832,6 +839,12 @@ private async void OnCommitListDoubleTapped(object sender, TappedEventArgs e) CommitListContainer.SelectedItems is { Count: 1 } && e.Source is Control { DataContext: Models.Commit c }) { + if (c.IsUncommitted) + { + histories.OpenLocalChanges(); + return; + } + if (histories.Bisect != null) { histories.CheckoutCommitDetached(c); diff --git a/src/Views/LocalChangesDetail.axaml b/src/Views/LocalChangesDetail.axaml new file mode 100644 index 0000000000..2e009f19af --- /dev/null +++ b/src/Views/LocalChangesDetail.axaml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/LocalChangesDetail.axaml.cs b/src/Views/LocalChangesDetail.axaml.cs new file mode 100644 index 0000000000..bc870f0e82 --- /dev/null +++ b/src/Views/LocalChangesDetail.axaml.cs @@ -0,0 +1,76 @@ +using System; +using System.Text; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class LocalChangesDetail : UserControl + { + public static readonly DirectProperty IsDetailsPanelExpandedProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsDetailsPanelExpanded), + static o => o.IsDetailsPanelExpanded, + static (o, v) => o.IsDetailsPanelExpanded = v); + + public bool IsDetailsPanelExpanded + { + get => _isDetailsPanelExpanded; + set => SetAndRaise(IsDetailsPanelExpandedProperty, ref _isDetailsPanelExpanded, value); + } + + public LocalChangesDetail() + { + InitializeComponent(); + } + + private void OnTabHeaderPointerPressed(object sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + e.Handled = true; + } + + private void OnChangeActivated(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.LocalChangesDetail vm) + vm.PresentMainDiff(); + } + + private async void OnChangeCollectionViewKeyDown(object sender, KeyEventArgs e) + { + if (DataContext is not ViewModels.LocalChangesDetail vm) + return; + + if (sender is not ChangeCollectionView { Selection: { Count: > 0 } selection }) + return; + + var cmdKey = OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control; + if (e.Key == Key.C && e.KeyModifiers.HasFlag(cmdKey)) + { + var builder = new StringBuilder(); + var copyAbsPath = e.KeyModifiers.HasFlag(KeyModifiers.Shift); + if (selection.IsSingleFolder) + builder.Append(copyAbsPath ? vm.GetAbsPath(selection.SingleFolderPath) : selection.SingleFolderPath); + else if (selection.Changes.Count == 1) + builder.Append(copyAbsPath ? vm.GetAbsPath(selection.Changes[0].Path) : selection.Changes[0].Path); + else + { + foreach (var c in selection.Changes) + builder.AppendLine(copyAbsPath ? vm.GetAbsPath(c.Path) : c.Path); + } + + await this.CopyTextAsync(builder.ToString()); + e.Handled = true; + } + else if (e.Key == Key.F && e.KeyModifiers == cmdKey) + { + ChangeSearchBox.Focus(); + e.Handled = true; + } + } + + private bool _isDetailsPanelExpanded = true; + } +}