Skip to content

Commit 85dd7a0

Browse files
authored
Merge pull request #61 from DevMando/codex/fix-list-refresh-on-delete
Fix: Keep snapshot, history, and note lists stable after deletion
2 parents 1a0e064 + 1289cbc commit 85dd7a0

8 files changed

Lines changed: 140 additions & 57 deletions

File tree

‎src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
<Compile Include="..\MandoCode.Desktop\Services\BuiltInBackgrounds.cs" Link="src\BuiltInBackgrounds.cs" />
5858
<Compile Include="..\MandoCode.Desktop\Services\TranscriptJournal.cs" Link="src\TranscriptJournal.cs" />
5959
<Compile Include="..\MandoCode.Desktop\Services\ModelNoticeReplay.cs" Link="src\ModelNoticeReplay.cs" />
60+
<Compile Include="..\MandoCode.Desktop\Services\StableCollection.cs" Link="src\StableCollection.cs" />
6061
<Compile Include="..\MandoCode.Desktop\Services\ConversationLog.cs" Link="src\ConversationLog.cs" />
6162
<!-- History's full-text matching + snippet extraction. Pure (text in, match out); the file reads
6263
and caching live in ConversationTextCache, which is NOT compiled here because it touches
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System.Collections.ObjectModel;
2+
using System.Collections.Specialized;
3+
using MandoCode.Desktop.Services;
4+
using Xunit;
5+
6+
namespace MandoCode.Desktop.Tests;
7+
8+
public class StableCollectionTests
9+
{
10+
private sealed record Row(int Id, string Text);
11+
12+
[Fact]
13+
public void DeleteAndRescanKeepSurvivingRowsWithoutResetOrAdd()
14+
{
15+
var first = new Row(1, "one");
16+
var last = new Row(3, "three");
17+
var rows = new ObservableCollection<Row> { first, new(2, "two"), last };
18+
var events = new List<NotifyCollectionChangedAction>();
19+
rows.CollectionChanged += (_, e) => events.Add(e.Action);
20+
StableCollection.Update(rows, new[] { new Row(1, "one"), new Row(3, "three") }, r => r.Id);
21+
StableCollection.Update(rows, new[] { new Row(1, "one"), new Row(3, "three") }, r => r.Id);
22+
Assert.Equal(new[] { NotifyCollectionChangedAction.Remove }, events);
23+
Assert.Same(first, rows[0]);
24+
Assert.Same(last, rows[1]);
25+
}
26+
27+
[Fact]
28+
public void ChangesReplaceOnlyChangedRowsAndMaintainRequestedOrder()
29+
{
30+
var retained = new Row(1, "one");
31+
var rows = new ObservableCollection<Row> { retained, new(2, "two") };
32+
var events = new List<NotifyCollectionChangedAction>();
33+
rows.CollectionChanged += (_, e) => events.Add(e.Action);
34+
StableCollection.Update(rows, new[] { new Row(2, "edited"), retained, new Row(3, "added") }, r => r.Id);
35+
Assert.Equal(new[] { 2, 1, 3 }, rows.Select(r => r.Id));
36+
Assert.Equal("edited", rows[0].Text);
37+
Assert.Same(retained, rows[1]);
38+
Assert.DoesNotContain(NotifyCollectionChangedAction.Reset, events);
39+
}
40+
41+
[Fact]
42+
public void RemovingLastGroupDoesNotRecreateRemainingGroup()
43+
{
44+
var remaining = new ObservableCollection<Row> { new(1, "one") };
45+
var deleted = new ObservableCollection<Row> { new(2, "two") };
46+
var groups = new ObservableCollection<ObservableCollection<Row>> { deleted, remaining };
47+
StableCollection.Update(groups, new[] { remaining }, g => g[0].Id, (a, b) => true);
48+
Assert.Single(groups);
49+
Assert.Same(remaining, groups[0]);
50+
}
51+
}

‎src/MandoCode.Desktop/MainWindow.History.cs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ private bool Matches(SessionArchiveEntry s, string q) =>
234234
|| (s.Preview?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
235235
|| _historyContentHits.ContainsKey(s.Key);
236236

237+
private readonly System.Collections.ObjectModel.ObservableCollection<HistoryGroup> _visibleHistoryGroups = new();
238+
237239
private void PopulateHistory()
238240
{
239241
var all = _archive.Items; // newest-first copy
@@ -244,8 +246,10 @@ private void PopulateHistory()
244246
var filtered = string.IsNullOrEmpty(q) ? all : all.Where(s => Matches(s, q)).ToList();
245247

246248
// Stamp the snippet onto EVERY row, not just the matches, so a snippet from a previous query
247-
// can't linger on a row the new query matched by title. Read once by a OneTime x:Bind —
248-
// ItemsSource is reassigned below, so the templates always re-bind.
249+
// can't linger on a row the new query matched by title. Rebind rows when snippets change;
250+
// ordinary deletes keep surviving rows and expanded groups in place.
251+
var snippetsChanged = all.Any(entry => entry.MatchSnippet !=
252+
(_historyContentHits.TryGetValue(entry.Key, out var hit) ? hit : null));
249253
foreach (var entry in all)
250254
entry.MatchSnippet = _historyContentHits.TryGetValue(entry.Key, out var snippet) ? snippet : null;
251255

@@ -257,7 +261,14 @@ private void PopulateHistory()
257261
.Select(g => new HistoryGroup(g.Key, g) { IsExpanded = !_collapsedHistoryGroups.Contains(g.Key) })
258262
.ToList();
259263

260-
HistoryList.ItemsSource = groups;
264+
foreach (var incoming in groups)
265+
{
266+
var existing = _visibleHistoryGroups.FirstOrDefault(g => g.Project == incoming.Project);
267+
if (existing != null)
268+
StableCollection.Update(existing, incoming, row => row.Key, (a, b) => !snippetsChanged && ReferenceEquals(a, b));
269+
}
270+
StableCollection.Update(_visibleHistoryGroups, groups, g => g.Project, (a, b) => true);
271+
if (HistoryList.ItemsSource == null) HistoryList.ItemsSource = _visibleHistoryGroups;
261272

262273
var nothingToShow = groups.Count == 0;
263274
HistoryEmpty.Text = storeEmpty

‎src/MandoCode.Desktop/MainWindow.Notes.cs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ private void RestoreLastNoteOnce()
193193
/// switches between them: an open note means editor, otherwise the grouped list. Safe to call any
194194
/// time — everything is derived from state.
195195
/// </summary>
196+
private readonly System.Collections.ObjectModel.ObservableCollection<NoteGroup> _visibleNotesGroups = new();
197+
196198
private void PopulateNotes()
197199
{
198200
var editing = NoteEditor.Current != null;
@@ -234,7 +236,14 @@ private void PopulateNotes()
234236
})
235237
.ToList();
236238

237-
NotesList.ItemsSource = groups;
239+
foreach (var incoming in groups)
240+
{
241+
var existing = _visibleNotesGroups.FirstOrDefault(g => g.Project == incoming.Project);
242+
if (existing != null)
243+
StableCollection.Update(existing, incoming, row => row.Note.Path.ToUpperInvariant(), (a, b) => a.Note == b.Note && a.MatchSnippet == b.MatchSnippet);
244+
}
245+
StableCollection.Update(_visibleNotesGroups, groups, g => g.Project, (a, b) => true);
246+
if (NotesList.ItemsSource == null) NotesList.ItemsSource = _visibleNotesGroups;
238247

239248
var nothingToShow = groups.Count == 0;
240249
NotesEmpty.Text = !_notesScanned

‎src/MandoCode.Desktop/MainWindow.Snapshots.cs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ private static bool Matches(ContextSnapshot s, string q) =>
178178
|| s.ProjectLabel.Contains(q, StringComparison.OrdinalIgnoreCase)
179179
|| (s.Recap?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false);
180180

181+
private readonly System.Collections.ObjectModel.ObservableCollection<SnapshotGroup> _visibleSnapshotsGroups = new();
182+
181183
private void PopulateSnapshots()
182184
{
183185
var all = _snapshotStore.Items; // newest-first copy of the shared store
@@ -202,7 +204,14 @@ private void PopulateSnapshots()
202204
.Select(g => new SnapshotGroup(g.Key, g) { IsExpanded = !_collapsedSnapshotGroups.Contains(g.Key) })
203205
.ToList();
204206

205-
SnapshotsList.ItemsSource = groups;
207+
foreach (var incoming in groups)
208+
{
209+
var existing = _visibleSnapshotsGroups.FirstOrDefault(g => g.Project == incoming.Project);
210+
if (existing != null)
211+
StableCollection.Update(existing, incoming, row => row.Id, ReferenceEquals);
212+
}
213+
StableCollection.Update(_visibleSnapshotsGroups, groups, g => g.Project, (a, b) => true);
214+
if (SnapshotsList.ItemsSource == null) SnapshotsList.ItemsSource = _visibleSnapshotsGroups;
206215

207216
var nothingToShow = groups.Count == 0;
208217
SnapshotsEmpty.Text = storeEmpty

‎src/MandoCode.Desktop/MainWindow.ViewModels.cs‎

Lines changed: 15 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -38,50 +38,27 @@ public sealed record ModelChoice(string Name, bool IsCloud)
3838
public string Tag => IsCloud ? "cloud · uses tokens" : "local · free";
3939
}
4040

41-
/// <summary>A project's snapshots, as one group in the (grouped) snapshots panel. Derives from
42-
/// <see cref="List{T}"/> so a <see cref="Microsoft.UI.Xaml.Data.CollectionViewSource"/> can group
43-
/// on it directly — the ListView's group-header template binds to <see cref="Project"/> and
44-
/// <see cref="Count"/>.</summary>
45-
public sealed class SnapshotGroup : List<Services.ContextSnapshot>
41+
/// <summary>A stable project group. Row changes notify counts without recreating its Expander.</summary>
42+
public abstract class PanelGroup<T> : ObservableCollection<T>
4643
{
47-
public SnapshotGroup(string project, IEnumerable<Services.ContextSnapshot> items) : base(items)
48-
=> Project = project;
49-
44+
protected PanelGroup(string project, IEnumerable<T> items) : base(items) => Project = project;
5045
public string Project { get; }
51-
52-
/// <summary>Whether the group's Expander is open. Set when the groups are rebuilt (from the
53-
/// remembered collapsed-set) and read once via a OneTime x:Bind — the Expander's own
54-
/// expand/collapse events keep the remembered set current thereafter.</summary>
5546
public bool IsExpanded { get; set; } = true;
56-
57-
/// <summary>Label for the group's "delete everything shown here" button. Computed here rather
58-
/// than assembled in XAML so the count is exact; a OneTime binding is always current because the
59-
/// groups are rebuilt on every panel populate.</summary>
6047
public string DeleteAllLabel => $"Delete all {Count}";
61-
62-
/// <summary>The group action only earns its space once there's more than one item — with a single
63-
/// card, that card's own Delete button already does the same job. Bound as Visibility rather than
64-
/// a bool because x:Bind does no implicit bool-to-Visibility conversion.</summary>
6548
public Visibility DeleteAllVisibility => Count > 1 ? Visibility.Visible : Visibility.Collapsed;
49+
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
50+
{
51+
base.OnPropertyChanged(e);
52+
if (e.PropertyName == nameof(Count))
53+
{
54+
base.OnPropertyChanged(new(nameof(DeleteAllLabel)));
55+
base.OnPropertyChanged(new(nameof(DeleteAllVisibility)));
56+
}
57+
}
6658
}
6759

68-
/// <summary>A project's closed conversations, as one collapsible group in the History panel —
69-
/// the archive twin of <see cref="SnapshotGroup"/>.</summary>
70-
public sealed class HistoryGroup : List<Services.SessionArchiveEntry>
71-
{
72-
public HistoryGroup(string project, IEnumerable<Services.SessionArchiveEntry> items) : base(items)
73-
=> Project = project;
74-
75-
public string Project { get; }
76-
77-
public bool IsExpanded { get; set; } = true;
78-
79-
/// <summary>See <see cref="SnapshotGroup.DeleteAllLabel"/>.</summary>
80-
public string DeleteAllLabel => $"Delete all {Count}";
81-
82-
/// <summary>See <see cref="SnapshotGroup.DeleteAllVisibility"/>.</summary>
83-
public Visibility DeleteAllVisibility => Count > 1 ? Visibility.Visible : Visibility.Collapsed;
84-
}
60+
public sealed class SnapshotGroup(string project, IEnumerable<ContextSnapshot> items) : PanelGroup<ContextSnapshot>(project, items);
61+
public sealed class HistoryGroup(string project, IEnumerable<SessionArchiveEntry> items) : PanelGroup<SessionArchiveEntry>(project, items);
8562

8663
/// <summary>
8764
/// One note as the panel shows it: the note itself plus the search snippet that explains why it
@@ -115,14 +92,7 @@ public sealed class NoteRow
11592
/// Deliberately WITHOUT the "Delete all n" group action those two carry. A snapshot or an archived
11693
/// conversation is a derived artifact the app made; a note is something the user wrote by hand, and
11794
/// one button that deletes a folder's worth of writing is a different class of risk.</summary>
118-
public sealed class NoteGroup : List<NoteRow>
119-
{
120-
public NoteGroup(string project, IEnumerable<NoteRow> items) : base(items) => Project = project;
121-
122-
public string Project { get; }
123-
124-
public bool IsExpanded { get; set; } = true;
125-
}
95+
public sealed class NoteGroup(string project, IEnumerable<NoteRow> items) : PanelGroup<NoteRow>(project, items);
12696

12797
/// <summary>
12898
/// One tile in the Appearance page's shipped-background gallery. Selection is baked in at build

‎src/MandoCode.Desktop/MainWindow.xaml‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@
269269
across search/import (see _collapsedSnapshotGroups). -->
270270
<ScrollViewer x:Name="SnapshotsScroller" Grid.Row="2">
271271
<ListView x:Name="SnapshotsList" SelectionMode="None" Padding="10,2,10,12">
272+
<ListView.ItemContainerTransitions><TransitionCollection/></ListView.ItemContainerTransitions>
272273
<ListView.ItemContainerStyle>
273274
<Style TargetType="ListViewItem">
274275
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
@@ -289,7 +290,7 @@
289290
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
290291
<Border CornerRadius="7" Padding="6,0" VerticalAlignment="Center"
291292
Background="{StaticResource MandoPanelBrush}">
292-
<TextBlock Text="{x:Bind Count}" FontSize="10" Opacity="0.7"/>
293+
<TextBlock Text="{x:Bind Count, Mode=OneWay}" FontSize="10" Opacity="0.7"/>
293294
</Border>
294295
</StackPanel>
295296
</Expander.Header>
@@ -301,14 +302,14 @@
301302
Hidden for a single-item group — that card's
302303
own Delete already does the same job. -->
303304
<Button Click="SnapshotDeleteGroup_Click" Tag="{x:Bind}"
304-
Visibility="{x:Bind DeleteAllVisibility}"
305+
Visibility="{x:Bind DeleteAllVisibility, Mode=OneWay}"
305306
HorizontalAlignment="Right" Margin="0,0,0,8" Padding="8,3"
306307
Background="Transparent" BorderThickness="0"
307308
ToolTipService.ToolTip="Delete every snapshot shown in this project"
308309
AutomationProperties.Name="Delete every snapshot shown in this project">
309310
<StackPanel Orientation="Horizontal" Spacing="6">
310311
<FontIcon Glyph="&#xE74D;" FontSize="11" Opacity="0.75"/>
311-
<TextBlock Text="{x:Bind DeleteAllLabel}" FontSize="11" Opacity="0.75"/>
312+
<TextBlock Text="{x:Bind DeleteAllLabel, Mode=OneWay}" FontSize="11" Opacity="0.75"/>
312313
</StackPanel>
313314
</Button>
314315
<ItemsControl ItemsSource="{x:Bind}">
@@ -417,6 +418,7 @@
417418
is remembered in code (see _collapsedHistoryGroups). -->
418419
<ScrollViewer x:Name="HistoryScroller" Grid.Row="2">
419420
<ListView x:Name="HistoryList" SelectionMode="None" Padding="10,2,10,12">
421+
<ListView.ItemContainerTransitions><TransitionCollection/></ListView.ItemContainerTransitions>
420422
<ListView.ItemContainerStyle>
421423
<Style TargetType="ListViewItem">
422424
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
@@ -437,7 +439,7 @@
437439
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
438440
<Border CornerRadius="7" Padding="6,0" VerticalAlignment="Center"
439441
Background="{StaticResource MandoPanelBrush}">
440-
<TextBlock Text="{x:Bind Count}" FontSize="10" Opacity="0.7"/>
442+
<TextBlock Text="{x:Bind Count, Mode=OneWay}" FontSize="10" Opacity="0.7"/>
441443
</Border>
442444
</StackPanel>
443445
</Expander.Header>
@@ -449,14 +451,14 @@
449451
Hidden for a single-item group — that card's
450452
own Delete already does the same job. -->
451453
<Button Click="HistoryDeleteGroup_Click" Tag="{x:Bind}"
452-
Visibility="{x:Bind DeleteAllVisibility}"
454+
Visibility="{x:Bind DeleteAllVisibility, Mode=OneWay}"
453455
HorizontalAlignment="Right" Margin="0,0,0,8" Padding="8,3"
454456
Background="Transparent" BorderThickness="0"
455457
ToolTipService.ToolTip="Delete every conversation shown in this project"
456458
AutomationProperties.Name="Delete every conversation shown in this project">
457459
<StackPanel Orientation="Horizontal" Spacing="6">
458460
<FontIcon Glyph="&#xE74D;" FontSize="11" Opacity="0.75"/>
459-
<TextBlock Text="{x:Bind DeleteAllLabel}" FontSize="11" Opacity="0.75"/>
461+
<TextBlock Text="{x:Bind DeleteAllLabel, Mode=OneWay}" FontSize="11" Opacity="0.75"/>
460462
</StackPanel>
461463
</Button>
462464
<ItemsControl ItemsSource="{x:Bind}">
@@ -622,6 +624,7 @@
622624

623625
<ScrollViewer x:Name="NotesScroller" Grid.Row="2">
624626
<ListView x:Name="NotesList" SelectionMode="None" Padding="10,2,10,12">
627+
<ListView.ItemContainerTransitions><TransitionCollection/></ListView.ItemContainerTransitions>
625628
<ListView.ItemContainerStyle>
626629
<Style TargetType="ListViewItem">
627630
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
@@ -642,7 +645,7 @@
642645
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
643646
<Border CornerRadius="7" Padding="6,0" VerticalAlignment="Center"
644647
Background="{StaticResource MandoPanelBrush}">
645-
<TextBlock Text="{x:Bind Count}" FontSize="10" Opacity="0.7"/>
648+
<TextBlock Text="{x:Bind Count, Mode=OneWay}" FontSize="10" Opacity="0.7"/>
646649
</Border>
647650
</StackPanel>
648651
</Expander.Header>

0 commit comments

Comments
 (0)