From a9d9a0d3fe283aacfb071dd642de9b5c0223ec45 Mon Sep 17 00:00:00 2001 From: Shawn Koonin Date: Sun, 12 Jul 2026 16:43:02 -0700 Subject: [PATCH 1/5] fix: clicking blank space in a change list clears the selection --- src/Views/ChangeCollectionView.axaml.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Views/ChangeCollectionView.axaml.cs b/src/Views/ChangeCollectionView.axaml.cs index 8cf0a32ce0..c45acfd7c2 100644 --- a/src/Views/ChangeCollectionView.axaml.cs +++ b/src/Views/ChangeCollectionView.axaml.cs @@ -33,6 +33,19 @@ public class ChangeCollectionContainer : ListBoxEx { protected override Type StyleKeyOverride => typeof(ListBox); + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + // Pressing blank space (not a row, not the scrollbar) clears the selection so the + // working-copy diff returns to the whole-changeset overview instead of staying on one file. + var source = e.Source as Visual; + var overRow = source?.FindAncestorOfType(true); + var overScrollBar = source?.FindAncestorOfType(true); + if (overRow == null && overScrollBar == null && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + SelectedItems?.Clear(); + + base.OnPointerPressed(e); + } + protected override void OnKeyDown(KeyEventArgs e) { if (SelectedItems is [ViewModels.ChangeTreeNode node] && e.KeyModifiers == KeyModifiers.None) From 5a61c7d8667ed8c7b8837b3bf84be66209866071 Mon Sep 17 00:00:00 2001 From: Shawn Koonin Date: Sun, 12 Jul 2026 16:43:02 -0700 Subject: [PATCH 2/5] feature: add 'Open Another Repository...' row to the Ctrl+P switcher --- TRANSLATION.md | 45 ++++++++++------ src/Resources/Locales/en_US.axaml | 1 + src/ViewModels/LauncherPagesCommandPalette.cs | 36 ++++++++++--- src/Views/LauncherPagesCommandPalette.axaml | 19 ++++++- .../LauncherPagesCommandPalette.axaml.cs | 53 ++++++++++++++++++- 5 files changed, 127 insertions(+), 27 deletions(-) diff --git a/TRANSLATION.md b/TRANSLATION.md index ea71c2b346..495092dc97 100644 --- a/TRANSLATION.md +++ b/TRANSLATION.md @@ -6,7 +6,7 @@ This document shows the translation status of each locale file in the repository ### ![en_US](https://img.shields.io/badge/en__US-%E2%88%9A-brightgreen) -### ![de__DE](https://img.shields.io/badge/de__DE-89.28%25-yellow) +### ![de__DE](https://img.shields.io/badge/de__DE-89.19%25-yellow)
Missing keys in de_DE.axaml @@ -88,6 +88,7 @@ This document shows the translation status of each locale file in the repository - Text.Init.CommandTip - Text.Init.ErrorMessageTip - Text.InteractiveRebase.NoVerify +- Text.Launcher.OpenOtherRepository - Text.Merge.Test - Text.Merge.Test.NoConflicts - Text.Merge.Test.UnknownError @@ -124,7 +125,7 @@ This document shows the translation status of each locale file in the repository
-### ![el__GR](https://img.shields.io/badge/el__GR-96.30%25-yellow) +### ![el__GR](https://img.shields.io/badge/el__GR-96.20%25-yellow)
Missing keys in el_GR.axaml @@ -162,6 +163,7 @@ This document shows the translation status of each locale file in the repository - Text.GitFlow.FinishWithRebase - Text.GitFlow.StartAt - Text.GitFlow.StartName +- Text.Launcher.OpenOtherRepository - Text.Repository.Commits - Text.TagCM.Checkout - Text.TagCM.Merge @@ -170,7 +172,7 @@ This document shows the translation status of each locale file in the repository
-### ![es__ES](https://img.shields.io/badge/es__ES-98.05%25-yellow) +### ![es__ES](https://img.shields.io/badge/es__ES-97.96%25-yellow)
Missing keys in es_ES.axaml @@ -193,12 +195,13 @@ This document shows the translation status of each locale file in the repository - Text.FileModeChange.Submodule - Text.FileModeChange.Symlink - Text.FileModeChange.Unknown +- Text.Launcher.OpenOtherRepository - Text.Repository.Commits - Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder
-### ![fr__FR](https://img.shields.io/badge/fr__FR-95.22%25-yellow) +### ![fr__FR](https://img.shields.io/badge/fr__FR-95.13%25-yellow)
Missing keys in fr_FR.axaml @@ -238,6 +241,7 @@ This document shows the translation status of each locale file in the repository - Text.GitFlow.FinishWithRebase - Text.GitFlow.StartAt - Text.GitFlow.StartName +- Text.Launcher.OpenOtherRepository - Text.Merge.Test - Text.Merge.Test.NoConflicts - Text.Merge.Test.UnknownError @@ -255,7 +259,7 @@ This document shows the translation status of each locale file in the repository
-### ![he__IL](https://img.shields.io/badge/he__IL-95.22%25-yellow) +### ![he__IL](https://img.shields.io/badge/he__IL-95.13%25-yellow)
Missing keys in he_IL.axaml @@ -295,6 +299,7 @@ This document shows the translation status of each locale file in the repository - Text.GitFlow.FinishWithRebase - Text.GitFlow.StartAt - Text.GitFlow.StartName +- Text.Launcher.OpenOtherRepository - Text.Merge.Test - Text.Merge.Test.NoConflicts - Text.Merge.Test.UnknownError @@ -312,7 +317,7 @@ This document shows the translation status of each locale file in the repository
-### ![id__ID](https://img.shields.io/badge/id__ID-82.26%25-yellow) +### ![id__ID](https://img.shields.io/badge/id__ID-82.18%25-yellow)
Missing keys in id_ID.axaml @@ -425,6 +430,7 @@ This document shows the translation status of each locale file in the repository - Text.Init.ErrorMessageTip - Text.InteractiveRebase.NoVerify - Text.Launcher.Commands +- Text.Launcher.OpenOtherRepository - Text.Launcher.OpenRepository - Text.Merge.Test - Text.Merge.Test.NoConflicts @@ -502,7 +508,7 @@ This document shows the translation status of each locale file in the repository
-### ![it__IT](https://img.shields.io/badge/it__IT-88.79%25-yellow) +### ![it__IT](https://img.shields.io/badge/it__IT-88.70%25-yellow)
Missing keys in it_IT.axaml @@ -587,6 +593,7 @@ This document shows the translation status of each locale file in the repository - Text.Init.CommandTip - Text.Init.ErrorMessageTip - Text.InteractiveRebase.NoVerify +- Text.Launcher.OpenOtherRepository - Text.Merge.Test - Text.Merge.Test.NoConflicts - Text.Merge.Test.UnknownError @@ -625,7 +632,7 @@ This document shows the translation status of each locale file in the repository
-### ![ja__JP](https://img.shields.io/badge/ja__JP-99.22%25-yellow) +### ![ja__JP](https://img.shields.io/badge/ja__JP-99.12%25-yellow)
Missing keys in ja_JP.axaml @@ -637,11 +644,12 @@ This document shows the translation status of each locale file in the repository - Text.Diff.LastFile - Text.Diff.NextFile - Text.Diff.PrevFile +- Text.Launcher.OpenOtherRepository - Text.Repository.Commits
-### ![ko__KR](https://img.shields.io/badge/ko__KR-96.69%25-yellow) +### ![ko__KR](https://img.shields.io/badge/ko__KR-96.59%25-yellow)
Missing keys in ko_KR.axaml @@ -675,6 +683,7 @@ This document shows the translation status of each locale file in the repository - Text.GitFlow.FinishWithRebase - Text.GitFlow.StartAt - Text.GitFlow.StartName +- Text.Launcher.OpenOtherRepository - Text.Repository.Commits - Text.TagCM.Checkout - Text.TagCM.Merge @@ -683,7 +692,7 @@ This document shows the translation status of each locale file in the repository
-### ![pt__BR](https://img.shields.io/badge/pt__BR-62.48%25-red) +### ![pt__BR](https://img.shields.io/badge/pt__BR-62.41%25-red)
Missing keys in pt_BR.axaml @@ -889,6 +898,7 @@ This document shows the translation status of each locale file in the repository - Text.InteractiveRebase.NoVerify - Text.InteractiveRebase.ReorderTip - Text.Launcher.Commands +- Text.Launcher.OpenOtherRepository - Text.Launcher.OpenRepository - Text.Launcher.Pages - Text.Launcher.Workspaces @@ -1076,7 +1086,7 @@ This document shows the translation status of each locale file in the repository
-### ![ru__RU](https://img.shields.io/badge/ru__RU-98.05%25-yellow) +### ![ru__RU](https://img.shields.io/badge/ru__RU-97.96%25-yellow)
Missing keys in ru_RU.axaml @@ -1099,12 +1109,13 @@ This document shows the translation status of each locale file in the repository - Text.FileModeChange.Submodule - Text.FileModeChange.Symlink - Text.FileModeChange.Unknown +- Text.Launcher.OpenOtherRepository - Text.Repository.Commits - Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder
-### ![ta__IN](https://img.shields.io/badge/ta__IN-64.33%25-red) +### ![ta__IN](https://img.shields.io/badge/ta__IN-64.26%25-red)
Missing keys in ta_IN.axaml @@ -1319,6 +1330,7 @@ This document shows the translation status of each locale file in the repository - Text.InteractiveRebase.NoVerify - Text.InteractiveRebase.ReorderTip - Text.Launcher.Commands +- Text.Launcher.OpenOtherRepository - Text.Launcher.OpenRepository - Text.Launcher.Pages - Text.Launcher.Workspaces @@ -1478,7 +1490,7 @@ This document shows the translation status of each locale file in the repository
-### ![uk__UA](https://img.shields.io/badge/uk__UA-65.11%25-red) +### ![uk__UA](https://img.shields.io/badge/uk__UA-65.04%25-red)
Missing keys in uk_UA.axaml @@ -1689,6 +1701,7 @@ This document shows the translation status of each locale file in the repository - Text.InteractiveRebase.NoVerify - Text.InteractiveRebase.ReorderTip - Text.Launcher.Commands +- Text.Launcher.OpenOtherRepository - Text.Launcher.OpenRepository - Text.Launcher.Pages - Text.Launcher.Workspaces @@ -1844,7 +1857,7 @@ This document shows the translation status of each locale file in the repository
-### ![zh__CN](https://img.shields.io/badge/zh__CN-99.12%25-yellow) +### ![zh__CN](https://img.shields.io/badge/zh__CN-99.03%25-yellow)
Missing keys in zh_CN.axaml @@ -1857,11 +1870,12 @@ This document shows the translation status of each locale file in the repository - Text.Diff.LastFile - Text.Diff.NextFile - Text.Diff.PrevFile +- Text.Launcher.OpenOtherRepository - Text.Repository.Commits
-### ![zh__TW](https://img.shields.io/badge/zh__TW-99.12%25-yellow) +### ![zh__TW](https://img.shields.io/badge/zh__TW-99.03%25-yellow)
Missing keys in zh_TW.axaml @@ -1874,6 +1888,7 @@ This document shows the translation status of each locale file in the repository - Text.Diff.LastFile - Text.Diff.NextFile - Text.Diff.PrevFile +- Text.Launcher.OpenOtherRepository - Text.Repository.Commits
\ No newline at end of file diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index be16767c2d..ef68a67095 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -587,6 +587,7 @@ Commands ERROR NOTICE + Open Another Repository... Open Repositories Tabs Workspaces diff --git a/src/ViewModels/LauncherPagesCommandPalette.cs b/src/ViewModels/LauncherPagesCommandPalette.cs index 265aa07040..bffdde74ce 100644 --- a/src/ViewModels/LauncherPagesCommandPalette.cs +++ b/src/ViewModels/LauncherPagesCommandPalette.cs @@ -3,6 +3,9 @@ namespace SourceGit.ViewModels { + // Sentinel row rendered at the end of the repository list; activating it browses for a repo not yet known. + public sealed class OpenOtherRepositoryAction { } + public class LauncherPagesCommandPalette : ICommandPalette { public List VisiblePages @@ -11,7 +14,8 @@ public List VisiblePages private set => SetProperty(ref _visiblePages, value); } - public List VisibleRepos + // Repository nodes plus a trailing OpenOtherRepositoryAction sentinel. + public List VisibleRepos { get => _visibleRepos; private set => SetProperty(ref _visibleRepos, value); @@ -37,7 +41,7 @@ public LauncherPage SelectedPage } } - public RepositoryNode SelectedRepo + public object SelectedRepo { get => _selectedRepo; set @@ -74,8 +78,18 @@ public void OpenOrSwitchTo() if (_selectedPage != null) _launcher.ActivePage = _selectedPage; - else if (_selectedRepo != null) - _launcher.OpenRepositoryInTab(_selectedRepo, null); + else if (_selectedRepo is RepositoryNode repo) + _launcher.OpenRepositoryInTab(repo, null); + } + + public void OpenPath(string path) + { + _opened.Clear(); + _visiblePages.Clear(); + _visibleRepos.Clear(); + Close(); + + _launcher.TryOpenRepositoryFromPath(path); } private void UpdateVisible() @@ -83,8 +97,13 @@ private void UpdateVisible() var pages = new List(); CollectVisiblePages(pages); - var repos = new List(); - CollectVisibleRepository(repos, Preferences.Instance.RepositoryNodes); + var repoNodes = new List(); + CollectVisibleRepository(repoNodes, Preferences.Instance.RepositoryNodes); + + // The browse action always trails the repo list so it stays reachable, even while filtering. + var repos = new List(repoNodes.Count + 1); + repos.AddRange(repoNodes); + repos.Add(_browseAction); var autoSelectPage = _selectedPage; var autoSelectRepo = _selectedRepo; @@ -187,10 +206,11 @@ private void CollectVisibleRepository(List outs, List _opened = new HashSet(); + private readonly OpenOtherRepositoryAction _browseAction = new(); private List _visiblePages = []; - private List _visibleRepos = []; + private List _visibleRepos = []; private string _searchFilter = string.Empty; private LauncherPage _selectedPage = null; - private RepositoryNode _selectedRepo = null; + private object _selectedRepo = null; } } diff --git a/src/Views/LauncherPagesCommandPalette.axaml b/src/Views/LauncherPagesCommandPalette.axaml index 70408dce82..6de5631ef3 100644 --- a/src/Views/LauncherPagesCommandPalette.axaml +++ b/src/Views/LauncherPagesCommandPalette.axaml @@ -152,7 +152,7 @@ - + - + + + + + + + + diff --git a/src/Views/LauncherPagesCommandPalette.axaml.cs b/src/Views/LauncherPagesCommandPalette.axaml.cs index 2ebdb4bdfb..2a70d05e3c 100644 --- a/src/Views/LauncherPagesCommandPalette.axaml.cs +++ b/src/Views/LauncherPagesCommandPalette.axaml.cs @@ -1,6 +1,10 @@ +using System; +using System.IO; + using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Platform.Storage; namespace SourceGit.Views { @@ -26,7 +30,7 @@ protected override void OnKeyDown(KeyEventArgs e) if (e.Key == Key.Enter) { - vm.OpenOrSwitchTo(); + Activate(vm); e.Handled = true; } else if (e.Key == Key.Up) @@ -102,9 +106,54 @@ private void OnItemTapped(object sender, TappedEventArgs e) { if (DataContext is ViewModels.LauncherPagesCommandPalette vm) { - vm.OpenOrSwitchTo(); + Activate(vm); e.Handled = true; } } + + private void Activate(ViewModels.LauncherPagesCommandPalette vm) + { + if (vm.SelectedRepo is ViewModels.OpenOtherRepositoryAction) + BrowseRepository(vm); + else + vm.OpenOrSwitchTo(); + } + + private async void BrowseRepository(ViewModels.LauncherPagesCommandPalette vm) + { + // Resolve the TopLevel before the palette closes; afterwards this control leaves the visual tree. + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel == null) + return; + + var preference = ViewModels.Preferences.Instance; + var workspace = preference.GetActiveWorkspace(); + var initDir = workspace.DefaultCloneDir; + if (string.IsNullOrEmpty(initDir) || !Directory.Exists(initDir)) + initDir = preference.GitDefaultCloneDir; + + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + if (Directory.Exists(initDir)) + { + var folder = await topLevel.StorageProvider.TryGetFolderFromPathAsync(initDir); + options.SuggestedStartLocation = folder; + } + + try + { + var selected = await topLevel.StorageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) + { + var folder = selected[0]; + var path = folder is { Path: { IsAbsoluteUri: true } uri } ? uri.LocalPath : folder?.Path.ToString(); + if (!string.IsNullOrEmpty(path)) + vm.OpenPath(path); + } + } + catch (Exception exception) + { + Models.Notification.Send(null, $"Failed to open repository: {exception.Message}", true); + } + } } } From 9555546d9b07c96a5f894eed39c6ffe1118a9655 Mon Sep 17 00:00:00 2001 From: Shawn Koonin Date: Sun, 12 Jul 2026 16:43:02 -0700 Subject: [PATCH 3/5] feature: stacked multi-file diff in all change views Shared BuildDetail owns the selection dispatch; per-view factories supply only the DiffOption. Filter-driven rebuilds are debounced. --- CHANGELOG.md | 9 +++ src/ViewModels/CommitDetail.cs | 64 +++++++++++++------ src/ViewModels/Compare.cs | 44 +++++++++----- src/ViewModels/Debouncer.cs | 30 +++++++++ src/ViewModels/MultipleDiffContext.cs | 71 ++++++++++++++++++---- src/ViewModels/RevisionCompare.cs | 49 +++++++++------ src/ViewModels/StashesPage.cs | 35 +++++++---- src/ViewModels/SubmoduleRevisionCompare.cs | 44 +++++++++----- src/ViewModels/WorkingCopy.cs | 28 ++++++++- src/Views/CommitChanges.axaml | 6 +- src/Views/Compare.axaml | 6 +- src/Views/RevisionCompare.axaml | 6 +- src/Views/StashesPage.axaml | 6 +- src/Views/SubmoduleRevisionCompare.axaml | 6 +- 14 files changed, 306 insertions(+), 98 deletions(-) create mode 100644 src/ViewModels/Debouncer.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd6e56b75..03b6e7690a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,19 @@ Fork releases of SourceGit-SK. Upstream changes arrive through upstream release ## [Unreleased] +### Added + +- The stacked multi-file diff now covers every change view: commit details (History), compare, revision compare, submodule compare, and stashes all show the whole change set as one scrollable diff when no file is selected, stack a multi-selection, and show a single file when one is selected. +- "Open Another Repository..." entry at the bottom of the repository switcher (Ctrl/Cmd+P) opens a folder picker to open a repo not yet in the list. + ### Changed - Local-changes diff view is now selection-driven: selecting a single file shows just that file, selecting several stacks those files, and the continuous stacked view of all changes appears when nothing is selected. The continuous-diff toggle has been removed. +### Fixed + +- Clicking blank space below a file list clears the selection (returning the diff panel to the whole-changeset view). + ## [2026.15-sk] - 2026-07-11 First fork release, based on upstream [v2026.14](https://github.com/sourcegit-scm/sourcegit/releases/tag/v2026.14). diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs index 5d543b65c2..65c3059b2f 100644 --- a/src/ViewModels/CommitDetail.cs +++ b/src/ViewModels/CommitDetail.cs @@ -41,8 +41,8 @@ public int ActiveTabIndex { _sharedData.ActiveTabIndex = value; - if (value == 1 && DiffContext == null && _selectedChanges is { Count: 1 }) - DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_commit, _selectedChanges[0])); + if (value == 1 && DetailContext == null) + UpdateDetail(); } } } @@ -102,19 +102,14 @@ public List SelectedChanges set { if (SetProperty(ref _selectedChanges, value)) - { - if (ActiveTabIndex != 1 || value is not { Count: 1 }) - DiffContext = null; - else - DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_commit, value[0]), _diffContext); - } + UpdateDetail(); } } - public DiffContext DiffContext + public object DetailContext { - get => _diffContext; - private set => SetProperty(ref _diffContext, value); + get => _detailContext; + private set => SetProperty(ref _detailContext, value); } public string SearchChangeFilter @@ -479,7 +474,7 @@ private void Refresh() { Changes = []; VisibleChanges = []; - SelectedChanges = null; + ResetSelection(); return; } @@ -551,10 +546,8 @@ private void Refresh() Changes = changes; VisibleChanges = visible; - if (visible.Count == 0) - SelectedChanges = null; - else - SelectedChanges = [VisibleChanges[0]]; + // No auto-selected file: an empty selection shows the whole commit as a stacked diff. + ResetSelection(); }); } }, token); @@ -628,6 +621,42 @@ private void RefreshVisibleChanges() VisibleChanges = visible; } + + // The whole-set stack tracks the filtered list, but rebuilding it per keystroke would spawn + // a git diff per visible file; coalesce until typing pauses. + if (_selectedChanges is not { Count: > 0 }) + { + _filterDebounce ??= new Debouncer(TimeSpan.FromMilliseconds(300), () => + { + if (_selectedChanges is not { Count: > 0 }) + UpdateDetail(); + }); + _filterDebounce.Trigger(); + } + } + + private void ResetSelection() + { + // Setting null on an already-null selection raises no change event, so force the rebuild + // that keys the detail to the new commit. + var hadSelection = _selectedChanges is { Count: > 0 }; + SelectedChanges = null; + if (!hadSelection) + UpdateDetail(); + } + + // Detail is lazy until the Changes tab (index 1) is visible. + private void UpdateDetail() + { + if (ActiveTabIndex != 1 || _commit == null) + DetailContext = null; + else + DetailContext = MultipleDiffContext.BuildDetail(_repo.FullPath, MakeDiffOption, _selectedChanges, _visibleChanges, _detailContext); + } + + private Models.DiffOption MakeDiffOption(Models.Change change) + { + return new Models.DiffOption(_commit, change); } private void RefreshRevisionSearchSuggestion() @@ -768,7 +797,8 @@ private async Task SetViewingCommitAsync(Models.Object file) private List _visibleChanges = []; private List _selectedChanges = null; private string _searchChangeFilter = string.Empty; - private DiffContext _diffContext = null; + private object _detailContext = null; + private Debouncer _filterDebounce = null; private string _viewRevisionFilePath = string.Empty; private object _viewRevisionFileContent = null; private CancellationTokenSource _cancellationSource = null; diff --git a/src/ViewModels/Compare.cs b/src/ViewModels/Compare.cs index 9a3f4f4906..a1278c2d0f 100644 --- a/src/ViewModels/Compare.cs +++ b/src/ViewModels/Compare.cs @@ -74,12 +74,7 @@ public List SelectedChanges set { if (SetProperty(ref _selectedChanges, value)) - { - if (value is { Count: 1 }) - DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_based, _to, value[0]), _diffContext); - else - DiffContext = null; - } + UpdateDetail(); } } @@ -93,10 +88,10 @@ public string SearchFilter } } - public DiffContext DiffContext + public object DetailContext { - get => _diffContext; - private set => SetProperty(ref _diffContext, value); + get => _detailContext; + private set => SetProperty(ref _detailContext, value); } public List LeftOnlyCommits @@ -334,10 +329,8 @@ private void UpdateChanges() VisibleChanges = visible; IsLoadingChanges = false; - if (VisibleChanges.Count > 0) - SelectedChanges = [VisibleChanges[0]]; - else - SelectedChanges = []; + // No auto-selected file: an empty selection shows the whole comparison as a stacked diff. + SelectedChanges = []; }); }); } @@ -362,6 +355,28 @@ private void RefreshVisibleChanges() VisibleChanges = visible; } + + // The whole-set stack tracks the filtered list, but rebuilding it per keystroke would spawn + // a git diff per visible file; coalesce until typing pauses. + if (_selectedChanges is not { Count: > 0 }) + { + _filterDebounce ??= new Debouncer(TimeSpan.FromMilliseconds(300), () => + { + if (_selectedChanges is not { Count: > 0 }) + UpdateDetail(); + }); + _filterDebounce.Trigger(); + } + } + + private void UpdateDetail() + { + DetailContext = MultipleDiffContext.BuildDetail(_repo.FullPath, MakeDiffOption, _selectedChanges, _visibleChanges, _detailContext); + } + + private Models.DiffOption MakeDiffOption(Models.Change change) + { + return new Models.DiffOption(_based, _to, change); } private string GetName(object obj) @@ -404,6 +419,7 @@ private string GetSHA(object obj) private List _leftOnlyCommits = []; private List _rightOnlyCommits = []; private string _searchFilter = string.Empty; - private DiffContext _diffContext = null; + private object _detailContext = null; + private Debouncer _filterDebounce = null; } } diff --git a/src/ViewModels/Debouncer.cs b/src/ViewModels/Debouncer.cs new file mode 100644 index 0000000000..95dd3162c6 --- /dev/null +++ b/src/ViewModels/Debouncer.cs @@ -0,0 +1,30 @@ +using System; + +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + // Coalesces rapid triggers (e.g. per-keystroke filter changes) into one action after a quiet + // period, so expensive rebuilds like the stacked diff do not run for every character typed. + // UI-thread only: both Trigger() and the action run on the Avalonia dispatcher. + public class Debouncer + { + public Debouncer(TimeSpan delay, Action action) + { + _timer = new DispatcherTimer { Interval = delay }; + _timer.Tick += (_, _) => + { + _timer.Stop(); + action(); + }; + } + + public void Trigger() + { + _timer.Stop(); + _timer.Start(); + } + + private readonly DispatcherTimer _timer; + } +} diff --git a/src/ViewModels/MultipleDiffContext.cs b/src/ViewModels/MultipleDiffContext.cs index af64511575..7fb76175dd 100644 --- a/src/ViewModels/MultipleDiffContext.cs +++ b/src/ViewModels/MultipleDiffContext.cs @@ -5,6 +5,11 @@ namespace SourceGit.ViewModels { + // Builds the detail view-model (DiffContext, Conflict, ...) for one change in a stacked diff. + // This is the only per-source variation point: each consumer (working copy, commit, compare, + // stash) supplies how a single file's diff is constructed; everything else is shared. + public delegate object FileDiffDetailFactory(Models.Change change, bool isUnstaged, DiffContext previous); + public class FileDiff : ObservableObject { public Models.Change Change @@ -61,9 +66,9 @@ public double MaxBodyHeight private set => SetProperty(ref _maxBodyHeight, value); } - public FileDiff(WorkingCopy owner, Models.Change change, bool isUnstaged, bool autoExpand, FileDiff previous) + public FileDiff(FileDiffDetailFactory createDetail, Models.Change change, bool isUnstaged, bool autoExpand, FileDiff previous) { - _owner = owner; + _createDetail = createDetail; Change = change; IsUnstaged = isUnstaged; @@ -79,19 +84,19 @@ public FileDiff(WorkingCopy owner, Models.Change change, bool isUnstaged, bool a private void CreateDetail(DiffContext previous) { - if (Change.IsConflicted) - { - _detail = new Conflict(_owner.Repository, _owner, Change); - MaxBodyHeight = double.PositiveInfinity; - } - else + var detail = _createDetail(Change, IsUnstaged, previous); + if (detail is DiffContext diff) { - // Ignore the global `UseFullTextDiff` preference so one toggle cannot materialize every stacked file. - var diff = new DiffContext(_owner.Repository.FullPath, new Models.DiffOption(Change, IsUnstaged), previous, true); diff.PropertyChanged += OnDiffPropertyChanged; _detail = diff; UpdateBodyHeight(diff); } + else + { + // Non-text details (conflict editor, ...) manage their own height. + _detail = detail; + MaxBodyHeight = double.PositiveInfinity; + } OnPropertyChanged(nameof(Detail)); } @@ -134,7 +139,7 @@ private void UpdateBodyHeight(DiffContext diff) // Image/binary/submodule diffs have no line count; give them a fixed, readable pane height. internal const double NonTextBodyHeight = 400; - private readonly WorkingCopy _owner; + private readonly FileDiffDetailFactory _createDetail; private bool _isExpanded; private object _detail = null; private bool _isBounded; @@ -185,7 +190,47 @@ public void DecrUnified() } } - public MultipleDiffContext(WorkingCopy owner, List<(Models.Change Change, bool IsUnstaged)> changes, MultipleDiffContext previous = null) + // Shared selection-to-detail dispatch for read-only change sets (commits, compares, stashes): + // one selected file shows its own diff, several show a stacked diff of the selection, and no + // selection shows the whole visible set stacked. Consumers supply only how a change maps to a + // DiffOption. The working copy keeps its own dispatcher (dual staged/unstaged lists, conflicts). + public static object BuildDetail(string repoPath, Func makeOption, List selected, List visible, object previous) + { + // A lone selected file honors the global `UseFullTextDiff` preference; stacked files must + // ignore it so one toggle cannot materialize every file at once. + if (selected is { Count: 1 }) + return new DiffContext(repoPath, makeOption(selected[0]), previous as DiffContext); + + FileDiffDetailFactory factory = (change, _, prev) => new DiffContext(repoPath, makeOption(change), prev, true); + + if (selected is { Count: > 1 }) + return CreateFromSelection(factory, selected, previous); + + if (visible is { Count: > 0 }) + return CreateFromOrdered(factory, visible, previous); + + return null; + } + + // Stack an explicit selection; click order is normalized back to list order. + public static MultipleDiffContext CreateFromSelection(FileDiffDetailFactory createDetail, List changes, object previous) + { + var sorted = new List(changes); + sorted.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + return CreateFromOrdered(createDetail, sorted, previous); + } + + // Stack a list that is already in display order (e.g. the whole visible change set). + public static MultipleDiffContext CreateFromOrdered(FileDiffDetailFactory createDetail, List changes, object previous) + { + var files = new List<(Models.Change, bool)>(changes.Count); + foreach (var c in changes) + files.Add((c, false)); + + return new MultipleDiffContext(createDetail, files, previous as MultipleDiffContext); + } + + public MultipleDiffContext(FileDiffDetailFactory createDetail, List<(Models.Change Change, bool IsUnstaged)> changes, MultipleDiffContext previous = null) { var count = Math.Min(changes.Count, MaxFiles); MoreCount = changes.Count - count; @@ -208,7 +253,7 @@ public MultipleDiffContext(WorkingCopy owner, List<(Models.Change Change, bool I FileDiff prev = null; prevFiles?.TryGetValue(GetKey(change.Path, isUnstaged), out prev); - Files.Add(new FileDiff(owner, change, isUnstaged, autoExpand, prev)); + Files.Add(new FileDiff(createDetail, change, isUnstaged, autoExpand, prev)); hasUnstaged |= isUnstaged; hasStaged |= !isUnstaged; } diff --git a/src/ViewModels/RevisionCompare.cs b/src/ViewModels/RevisionCompare.cs index ef38668cc2..dda6f16950 100644 --- a/src/ViewModels/RevisionCompare.cs +++ b/src/ViewModels/RevisionCompare.cs @@ -71,17 +71,7 @@ public List SelectedChanges set { if (SetProperty(ref _selectedChanges, value)) - { - if (value is { Count: 1 }) - { - var option = new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), value[0]); - DiffContext = new DiffContext(_repo.FullPath, option, _diffContext); - } - else - { - DiffContext = null; - } - } + UpdateDetail(); } } @@ -95,10 +85,10 @@ public string SearchFilter } } - public DiffContext DiffContext + public object DetailContext { - get => _diffContext; - private set => SetProperty(ref _diffContext, value); + get => _detailContext; + private set => SetProperty(ref _detailContext, value); } public RevisionCompare(Repository repo, Models.Commit startPoint, Models.Commit endPoint) @@ -344,6 +334,28 @@ private void RefreshVisible() VisibleChanges = visible; } + + // The whole-set stack tracks the filtered list, but rebuilding it per keystroke would spawn + // a git diff per visible file; coalesce until typing pauses. + if (_selectedChanges is not { Count: > 0 }) + { + _filterDebounce ??= new Debouncer(TimeSpan.FromMilliseconds(300), () => + { + if (_selectedChanges is not { Count: > 0 }) + UpdateDetail(); + }); + _filterDebounce.Trigger(); + } + } + + private void UpdateDetail() + { + DetailContext = MultipleDiffContext.BuildDetail(_repo.FullPath, MakeDiffOption, _selectedChanges, _visibleChanges, _detailContext); + } + + private Models.DiffOption MakeDiffOption(Models.Change change) + { + return new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), change); } private void Refresh() @@ -371,10 +383,8 @@ private void Refresh() VisibleChanges = visible; IsLoading = false; - if (VisibleChanges.Count > 0) - SelectedChanges = [VisibleChanges[0]]; - else - SelectedChanges = []; + // No auto-selected file: an empty selection shows the whole comparison as a stacked diff. + SelectedChanges = []; }); }); } @@ -398,6 +408,7 @@ private string GetDesc(object obj) private List _visibleChanges = null; private List _selectedChanges = null; private string _searchFilter = string.Empty; - private DiffContext _diffContext = null; + private object _detailContext = null; + private Debouncer _filterDebounce = null; } } diff --git a/src/ViewModels/StashesPage.cs b/src/ViewModels/StashesPage.cs index 9f4874dba6..0a5d049aa7 100644 --- a/src/ViewModels/StashesPage.cs +++ b/src/ViewModels/StashesPage.cs @@ -93,7 +93,10 @@ public List Changes private set { if (SetProperty(ref _changes, value)) - SelectedChanges = value is { Count: > 0 } ? [value[0]] : []; + { + // No auto-selected file: an empty selection shows the whole stash as a stacked diff. + SelectedChanges = []; + } } } @@ -103,21 +106,14 @@ public List SelectedChanges set { if (SetProperty(ref _selectedChanges, value)) - { - if (value is not { Count: 1 }) - DiffContext = null; - else if (_untracked.Contains(value[0])) - DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_selectedStash.UntrackedParent, _selectedStash.Parents[2], value[0]), _diffContext); - else - DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_selectedStash.Parents[0], _selectedStash.SHA, value[0]), _diffContext); - } + UpdateDetail(); } } - public DiffContext DiffContext + public object DetailContext { - get => _diffContext; - private set => SetProperty(ref _diffContext, value); + get => _detailContext; + private set => SetProperty(ref _detailContext, value); } public StashesPage(Repository repo) @@ -125,6 +121,19 @@ public StashesPage(Repository repo) _repo = repo; } + private void UpdateDetail() + { + DetailContext = MultipleDiffContext.BuildDetail(_repo.FullPath, MakeDiffOption, _selectedChanges, _changes, _detailContext); + } + + private Models.DiffOption MakeDiffOption(Models.Change change) + { + // Untracked files live on the third parent of the stash commit. + return _untracked.Contains(change) + ? new Models.DiffOption(_selectedStash.UntrackedParent, _selectedStash.Parents[2], change) + : new Models.DiffOption(_selectedStash.Parents[0], _selectedStash.SHA, change); + } + public void ClearSearchFilter() { SearchFilter = string.Empty; @@ -282,6 +291,6 @@ private void RefreshVisible() private List _changes = null; private List _untracked = []; private List _selectedChanges = []; - private DiffContext _diffContext = null; + private object _detailContext = null; } } diff --git a/src/ViewModels/SubmoduleRevisionCompare.cs b/src/ViewModels/SubmoduleRevisionCompare.cs index bbf41a2dbc..3e4c79a54f 100644 --- a/src/ViewModels/SubmoduleRevisionCompare.cs +++ b/src/ViewModels/SubmoduleRevisionCompare.cs @@ -44,12 +44,7 @@ public List SelectedChanges set { if (SetProperty(ref _selectedChanges, value)) - { - if (value is { Count: 1 }) - DiffContext = new DiffContext(_repo, new Models.DiffOption(_base.SHA, _to.SHA, value[0]), _diffContext); - else - DiffContext = null; - } + UpdateDetail(); } } @@ -63,10 +58,10 @@ public string SearchFilter } } - public DiffContext DiffContext + public object DetailContext { - get => _diffContext; - private set => SetProperty(ref _diffContext, value); + get => _detailContext; + private set => SetProperty(ref _detailContext, value); } public SubmoduleRevisionCompare(Models.SubmoduleDiff diff) @@ -133,10 +128,8 @@ private void Refresh() VisibleChanges = visible; IsLoading = false; - if (VisibleChanges.Count > 0) - SelectedChanges = [VisibleChanges[0]]; - else - SelectedChanges = []; + // No auto-selected file: an empty selection shows the whole comparison as a stacked diff. + SelectedChanges = []; }); }); } @@ -161,6 +154,28 @@ private void RefreshVisible() VisibleChanges = visible; } + + // The whole-set stack tracks the filtered list, but rebuilding it per keystroke would spawn + // a git diff per visible file; coalesce until typing pauses. + if (_selectedChanges is not { Count: > 0 }) + { + _filterDebounce ??= new Debouncer(TimeSpan.FromMilliseconds(300), () => + { + if (_selectedChanges is not { Count: > 0 }) + UpdateDetail(); + }); + _filterDebounce.Trigger(); + } + } + + private void UpdateDetail() + { + DetailContext = MultipleDiffContext.BuildDetail(_repo, MakeDiffOption, _selectedChanges, _visibleChanges, _detailContext); + } + + private Models.DiffOption MakeDiffOption(Models.Change change) + { + return new Models.DiffOption(_base.SHA, _to.SHA, change); } private string _repo; @@ -172,6 +187,7 @@ private void RefreshVisible() private List _visibleChanges = null; private List _selectedChanges = null; private string _searchFilter = string.Empty; - private DiffContext _diffContext = null; + private object _detailContext = null; + private Debouncer _filterDebounce = null; } } diff --git a/src/ViewModels/WorkingCopy.cs b/src/ViewModels/WorkingCopy.cs index 89b574481e..5c98f54bba 100644 --- a/src/ViewModels/WorkingCopy.cs +++ b/src/ViewModels/WorkingCopy.cs @@ -131,7 +131,19 @@ public string Filter VisibleUnstaged = GetVisibleChanges(_unstaged); VisibleStaged = GetVisibleChanges(_staged); - SelectedUnstaged = []; + + // Clearing a live selection rebuilds the detail once via the setter; with nothing + // selected the whole-set stack would otherwise rebuild per keystroke (a git diff + // per visible file), so coalesce until typing pauses. + if (_selectedUnstaged is { Count: > 0 } || _selectedStaged is { Count: > 0 }) + { + SelectedUnstaged = []; + } + else + { + _filterDebounce ??= new Debouncer(TimeSpan.FromMilliseconds(300), UpdateDetail); + _filterDebounce.Trigger(); + } } } } @@ -809,7 +821,16 @@ private void SetMultiDetail(List changes, bool isUnstaged) foreach (var c in sorted) files.Add((c, isUnstaged)); - DetailContext = new MultipleDiffContext(this, files, _detailContext as MultipleDiffContext); + DetailContext = new MultipleDiffContext(CreateFileDiffDetail, files, _detailContext as MultipleDiffContext); + } + + private object CreateFileDiffDetail(Models.Change change, bool isUnstaged, DiffContext previous) + { + if (change.IsConflicted) + return new Conflict(_repo, this, change); + + // Ignore the global `UseFullTextDiff` preference so one toggle cannot materialize every stacked file. + return new DiffContext(_repo.FullPath, new Models.DiffOption(change, isUnstaged), previous, true); } private void SetContinuousDetail() @@ -829,7 +850,7 @@ private void SetContinuousDetail() return; } - DetailContext = new MultipleDiffContext(this, files, _detailContext as MultipleDiffContext); + DetailContext = new MultipleDiffContext(CreateFileDiffDetail, files, _detailContext as MultipleDiffContext); } private bool IsChanged(List old, List cur) @@ -863,6 +884,7 @@ private bool IsChanged(List old, List cur) private List _visibleStaged = []; private List _selectedUnstaged = []; private List _selectedStaged = []; + private Debouncer _filterDebounce = null; private object _detailContext = null; private string _filter = string.Empty; private string _commitMessage = string.Empty; diff --git a/src/Views/CommitChanges.axaml b/src/Views/CommitChanges.axaml index d7d245ccf7..75994a02ce 100644 --- a/src/Views/CommitChanges.axaml +++ b/src/Views/CommitChanges.axaml @@ -83,11 +83,15 @@ - + + + + + diff --git a/src/Views/Compare.axaml b/src/Views/Compare.axaml index 1349ff4529..69d97505e3 100644 --- a/src/Views/Compare.axaml +++ b/src/Views/Compare.axaml @@ -220,11 +220,15 @@ - + + + + + diff --git a/src/Views/RevisionCompare.axaml b/src/Views/RevisionCompare.axaml index 5452a4e5e2..36c2a3c659 100644 --- a/src/Views/RevisionCompare.axaml +++ b/src/Views/RevisionCompare.axaml @@ -150,11 +150,15 @@ - + + + + + diff --git a/src/Views/StashesPage.axaml b/src/Views/StashesPage.axaml index ad9dc6eb98..4fcfcf3377 100644 --- a/src/Views/StashesPage.axaml +++ b/src/Views/StashesPage.axaml @@ -159,11 +159,15 @@ - + + + + + diff --git a/src/Views/SubmoduleRevisionCompare.axaml b/src/Views/SubmoduleRevisionCompare.axaml index c0c8bba0b7..08e48658e9 100644 --- a/src/Views/SubmoduleRevisionCompare.axaml +++ b/src/Views/SubmoduleRevisionCompare.axaml @@ -182,11 +182,15 @@ - + + + + + From 2273f3e18e62e60e767d29b87d1d58ad59c8a49e Mon Sep 17 00:00:00 2001 From: Shawn Koonin Date: Sun, 12 Jul 2026 16:45:35 -0700 Subject: [PATCH 4/5] doc: link changelog entries to PR #8 --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b6e7690a..95dea03b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ Fork releases of SourceGit-SK. Upstream changes arrive through upstream release ### Added -- The stacked multi-file diff now covers every change view: commit details (History), compare, revision compare, submodule compare, and stashes all show the whole change set as one scrollable diff when no file is selected, stack a multi-selection, and show a single file when one is selected. -- "Open Another Repository..." entry at the bottom of the repository switcher (Ctrl/Cmd+P) opens a folder picker to open a repo not yet in the list. +- The stacked multi-file diff now covers every change view: commit details (History), compare, revision compare, submodule compare, and stashes all show the whole change set as one scrollable diff when no file is selected, stack a multi-selection, and show a single file when one is selected ([#8](https://github.com/skoonin/sourcegit-sk/pull/8)) +- "Open Another Repository..." entry at the bottom of the repository switcher (Ctrl/Cmd+P) opens a folder picker to open a repo not yet in the list ([#8](https://github.com/skoonin/sourcegit-sk/pull/8)) ### Changed @@ -15,7 +15,7 @@ Fork releases of SourceGit-SK. Upstream changes arrive through upstream release ### Fixed -- Clicking blank space below a file list clears the selection (returning the diff panel to the whole-changeset view). +- Clicking blank space below a file list clears the selection (returning the diff panel to the whole-changeset view) ([#8](https://github.com/skoonin/sourcegit-sk/pull/8)) ## [2026.15-sk] - 2026-07-11 From 883125863a04326a8d7e676e44f0586039783736 Mon Sep 17 00:00:00 2001 From: Shawn Koonin Date: Sun, 12 Jul 2026 18:29:56 -0700 Subject: [PATCH 5/5] fix: RPM-legal version in Linux packaging (hyphens to dots) --- build/scripts/package.linux.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/build/scripts/package.linux.sh b/build/scripts/package.linux.sh index 262f141ae8..3c923a94ca 100755 --- a/build/scripts/package.linux.sh +++ b/build/scripts/package.linux.sh @@ -82,5 +82,7 @@ sed -i -e "s/^Version:.*/Version: $VERSION/" \ # Build deb package with gzip compression dpkg-deb -Zgzip --root-owner-group --build resources/deb "sourcegit_$VERSION-1_$arch.deb" -rpmbuild -bb --target="$target" resources/rpm/SPECS/build.spec --define "_topdir $(pwd)/resources/rpm" --define "_version $VERSION" -mv "resources/rpm/RPMS/$target/sourcegit-$VERSION-1.$target.rpm" ./ +# RPM forbids '-' in Version (reserved for the Release separator); the fork's -sk/-sk-dev suffix must use dots. +rpm_version=${VERSION//-/.} +rpmbuild -bb --target="$target" resources/rpm/SPECS/build.spec --define "_topdir $(pwd)/resources/rpm" --define "_version $rpm_version" +mv "resources/rpm/RPMS/$target/sourcegit-$rpm_version-1.$target.rpm" ./