Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
<Compile Include="..\MandoCode.Desktop\Services\BuiltInBackgrounds.cs" Link="src\BuiltInBackgrounds.cs" />
<Compile Include="..\MandoCode.Desktop\Services\TranscriptJournal.cs" Link="src\TranscriptJournal.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ModelNoticeReplay.cs" Link="src\ModelNoticeReplay.cs" />
<Compile Include="..\MandoCode.Desktop\Services\StableCollection.cs" Link="src\StableCollection.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ConversationLog.cs" Link="src\ConversationLog.cs" />
<!-- History's full-text matching + snippet extraction. Pure (text in, match out); the file reads
and caching live in ConversationTextCache, which is NOT compiled here because it touches
Expand Down
51 changes: 51 additions & 0 deletions src/MandoCode.Desktop.Tests/StableCollectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using MandoCode.Desktop.Services;
using Xunit;

namespace MandoCode.Desktop.Tests;

public class StableCollectionTests
{
private sealed record Row(int Id, string Text);

[Fact]
public void DeleteAndRescanKeepSurvivingRowsWithoutResetOrAdd()
{
var first = new Row(1, "one");
var last = new Row(3, "three");
var rows = new ObservableCollection<Row> { first, new(2, "two"), last };
var events = new List<NotifyCollectionChangedAction>();
rows.CollectionChanged += (_, e) => events.Add(e.Action);
StableCollection.Update(rows, new[] { new Row(1, "one"), new Row(3, "three") }, r => r.Id);
StableCollection.Update(rows, new[] { new Row(1, "one"), new Row(3, "three") }, r => r.Id);
Assert.Equal(new[] { NotifyCollectionChangedAction.Remove }, events);
Assert.Same(first, rows[0]);
Assert.Same(last, rows[1]);
}

[Fact]
public void ChangesReplaceOnlyChangedRowsAndMaintainRequestedOrder()
{
var retained = new Row(1, "one");
var rows = new ObservableCollection<Row> { retained, new(2, "two") };
var events = new List<NotifyCollectionChangedAction>();
rows.CollectionChanged += (_, e) => events.Add(e.Action);
StableCollection.Update(rows, new[] { new Row(2, "edited"), retained, new Row(3, "added") }, r => r.Id);
Assert.Equal(new[] { 2, 1, 3 }, rows.Select(r => r.Id));
Assert.Equal("edited", rows[0].Text);
Assert.Same(retained, rows[1]);
Assert.DoesNotContain(NotifyCollectionChangedAction.Reset, events);
}

[Fact]
public void RemovingLastGroupDoesNotRecreateRemainingGroup()
{
var remaining = new ObservableCollection<Row> { new(1, "one") };
var deleted = new ObservableCollection<Row> { new(2, "two") };
var groups = new ObservableCollection<ObservableCollection<Row>> { deleted, remaining };
StableCollection.Update(groups, new[] { remaining }, g => g[0].Id, (a, b) => true);
Assert.Single(groups);
Assert.Same(remaining, groups[0]);
}
}
17 changes: 14 additions & 3 deletions src/MandoCode.Desktop/MainWindow.History.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ private bool Matches(SessionArchiveEntry s, string q) =>
|| (s.Preview?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
|| _historyContentHits.ContainsKey(s.Key);

private readonly System.Collections.ObjectModel.ObservableCollection<HistoryGroup> _visibleHistoryGroups = new();

private void PopulateHistory()
{
var all = _archive.Items; // newest-first copy
Expand All @@ -244,8 +246,10 @@ private void PopulateHistory()
var filtered = string.IsNullOrEmpty(q) ? all : all.Where(s => Matches(s, q)).ToList();

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

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

HistoryList.ItemsSource = groups;
foreach (var incoming in groups)
{
var existing = _visibleHistoryGroups.FirstOrDefault(g => g.Project == incoming.Project);
if (existing != null)
StableCollection.Update(existing, incoming, row => row.Key, (a, b) => !snippetsChanged && ReferenceEquals(a, b));
}
StableCollection.Update(_visibleHistoryGroups, groups, g => g.Project, (a, b) => true);
if (HistoryList.ItemsSource == null) HistoryList.ItemsSource = _visibleHistoryGroups;

var nothingToShow = groups.Count == 0;
HistoryEmpty.Text = storeEmpty
Expand Down
11 changes: 10 additions & 1 deletion src/MandoCode.Desktop/MainWindow.Notes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ private void RestoreLastNoteOnce()
/// switches between them: an open note means editor, otherwise the grouped list. Safe to call any
/// time — everything is derived from state.
/// </summary>
private readonly System.Collections.ObjectModel.ObservableCollection<NoteGroup> _visibleNotesGroups = new();

private void PopulateNotes()
{
var editing = NoteEditor.Current != null;
Expand Down Expand Up @@ -234,7 +236,14 @@ private void PopulateNotes()
})
.ToList();

NotesList.ItemsSource = groups;
foreach (var incoming in groups)
{
var existing = _visibleNotesGroups.FirstOrDefault(g => g.Project == incoming.Project);
if (existing != null)
StableCollection.Update(existing, incoming, row => row.Note.Path.ToUpperInvariant(), (a, b) => a.Note == b.Note && a.MatchSnippet == b.MatchSnippet);
}
StableCollection.Update(_visibleNotesGroups, groups, g => g.Project, (a, b) => true);
if (NotesList.ItemsSource == null) NotesList.ItemsSource = _visibleNotesGroups;

var nothingToShow = groups.Count == 0;
NotesEmpty.Text = !_notesScanned
Expand Down
11 changes: 10 additions & 1 deletion src/MandoCode.Desktop/MainWindow.Snapshots.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ private static bool Matches(ContextSnapshot s, string q) =>
|| s.ProjectLabel.Contains(q, StringComparison.OrdinalIgnoreCase)
|| (s.Recap?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false);

private readonly System.Collections.ObjectModel.ObservableCollection<SnapshotGroup> _visibleSnapshotsGroups = new();

private void PopulateSnapshots()
{
var all = _snapshotStore.Items; // newest-first copy of the shared store
Expand All @@ -202,7 +204,14 @@ private void PopulateSnapshots()
.Select(g => new SnapshotGroup(g.Key, g) { IsExpanded = !_collapsedSnapshotGroups.Contains(g.Key) })
.ToList();

SnapshotsList.ItemsSource = groups;
foreach (var incoming in groups)
{
var existing = _visibleSnapshotsGroups.FirstOrDefault(g => g.Project == incoming.Project);
if (existing != null)
StableCollection.Update(existing, incoming, row => row.Id, ReferenceEquals);
}
StableCollection.Update(_visibleSnapshotsGroups, groups, g => g.Project, (a, b) => true);
if (SnapshotsList.ItemsSource == null) SnapshotsList.ItemsSource = _visibleSnapshotsGroups;

var nothingToShow = groups.Count == 0;
SnapshotsEmpty.Text = storeEmpty
Expand Down
60 changes: 15 additions & 45 deletions src/MandoCode.Desktop/MainWindow.ViewModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,50 +38,27 @@ public sealed record ModelChoice(string Name, bool IsCloud)
public string Tag => IsCloud ? "cloud · uses tokens" : "local · free";
}

/// <summary>A project's snapshots, as one group in the (grouped) snapshots panel. Derives from
/// <see cref="List{T}"/> so a <see cref="Microsoft.UI.Xaml.Data.CollectionViewSource"/> can group
/// on it directly — the ListView's group-header template binds to <see cref="Project"/> and
/// <see cref="Count"/>.</summary>
public sealed class SnapshotGroup : List<Services.ContextSnapshot>
/// <summary>A stable project group. Row changes notify counts without recreating its Expander.</summary>
public abstract class PanelGroup<T> : ObservableCollection<T>
{
public SnapshotGroup(string project, IEnumerable<Services.ContextSnapshot> items) : base(items)
=> Project = project;

protected PanelGroup(string project, IEnumerable<T> items) : base(items) => Project = project;
public string Project { get; }

/// <summary>Whether the group's Expander is open. Set when the groups are rebuilt (from the
/// remembered collapsed-set) and read once via a OneTime x:Bind — the Expander's own
/// expand/collapse events keep the remembered set current thereafter.</summary>
public bool IsExpanded { get; set; } = true;

/// <summary>Label for the group's "delete everything shown here" button. Computed here rather
/// than assembled in XAML so the count is exact; a OneTime binding is always current because the
/// groups are rebuilt on every panel populate.</summary>
public string DeleteAllLabel => $"Delete all {Count}";

/// <summary>The group action only earns its space once there's more than one item — with a single
/// card, that card's own Delete button already does the same job. Bound as Visibility rather than
/// a bool because x:Bind does no implicit bool-to-Visibility conversion.</summary>
public Visibility DeleteAllVisibility => Count > 1 ? Visibility.Visible : Visibility.Collapsed;
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
{
base.OnPropertyChanged(e);
if (e.PropertyName == nameof(Count))
{
base.OnPropertyChanged(new(nameof(DeleteAllLabel)));
base.OnPropertyChanged(new(nameof(DeleteAllVisibility)));
}
}
}

/// <summary>A project's closed conversations, as one collapsible group in the History panel —
/// the archive twin of <see cref="SnapshotGroup"/>.</summary>
public sealed class HistoryGroup : List<Services.SessionArchiveEntry>
{
public HistoryGroup(string project, IEnumerable<Services.SessionArchiveEntry> items) : base(items)
=> Project = project;

public string Project { get; }

public bool IsExpanded { get; set; } = true;

/// <summary>See <see cref="SnapshotGroup.DeleteAllLabel"/>.</summary>
public string DeleteAllLabel => $"Delete all {Count}";

/// <summary>See <see cref="SnapshotGroup.DeleteAllVisibility"/>.</summary>
public Visibility DeleteAllVisibility => Count > 1 ? Visibility.Visible : Visibility.Collapsed;
}
public sealed class SnapshotGroup(string project, IEnumerable<ContextSnapshot> items) : PanelGroup<ContextSnapshot>(project, items);
public sealed class HistoryGroup(string project, IEnumerable<SessionArchiveEntry> items) : PanelGroup<SessionArchiveEntry>(project, items);

/// <summary>
/// One note as the panel shows it: the note itself plus the search snippet that explains why it
Expand Down Expand Up @@ -115,14 +92,7 @@ public sealed class NoteRow
/// Deliberately WITHOUT the "Delete all n" group action those two carry. A snapshot or an archived
/// conversation is a derived artifact the app made; a note is something the user wrote by hand, and
/// one button that deletes a folder's worth of writing is a different class of risk.</summary>
public sealed class NoteGroup : List<NoteRow>
{
public NoteGroup(string project, IEnumerable<NoteRow> items) : base(items) => Project = project;

public string Project { get; }

public bool IsExpanded { get; set; } = true;
}
public sealed class NoteGroup(string project, IEnumerable<NoteRow> items) : PanelGroup<NoteRow>(project, items);

/// <summary>
/// One tile in the Appearance page's shipped-background gallery. Selection is baked in at build
Expand Down
17 changes: 10 additions & 7 deletions src/MandoCode.Desktop/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@
across search/import (see _collapsedSnapshotGroups). -->
<ScrollViewer x:Name="SnapshotsScroller" Grid.Row="2">
<ListView x:Name="SnapshotsList" SelectionMode="None" Padding="10,2,10,12">
<ListView.ItemContainerTransitions><TransitionCollection/></ListView.ItemContainerTransitions>
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
Expand All @@ -289,7 +290,7 @@
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<Border CornerRadius="7" Padding="6,0" VerticalAlignment="Center"
Background="{StaticResource MandoPanelBrush}">
<TextBlock Text="{x:Bind Count}" FontSize="10" Opacity="0.7"/>
<TextBlock Text="{x:Bind Count, Mode=OneWay}" FontSize="10" Opacity="0.7"/>
</Border>
</StackPanel>
</Expander.Header>
Expand All @@ -301,14 +302,14 @@
Hidden for a single-item group — that card's
own Delete already does the same job. -->
<Button Click="SnapshotDeleteGroup_Click" Tag="{x:Bind}"
Visibility="{x:Bind DeleteAllVisibility}"
Visibility="{x:Bind DeleteAllVisibility, Mode=OneWay}"
HorizontalAlignment="Right" Margin="0,0,0,8" Padding="8,3"
Background="Transparent" BorderThickness="0"
ToolTipService.ToolTip="Delete every snapshot shown in this project"
AutomationProperties.Name="Delete every snapshot shown in this project">
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE74D;" FontSize="11" Opacity="0.75"/>
<TextBlock Text="{x:Bind DeleteAllLabel}" FontSize="11" Opacity="0.75"/>
<TextBlock Text="{x:Bind DeleteAllLabel, Mode=OneWay}" FontSize="11" Opacity="0.75"/>
</StackPanel>
</Button>
<ItemsControl ItemsSource="{x:Bind}">
Expand Down Expand Up @@ -417,6 +418,7 @@
is remembered in code (see _collapsedHistoryGroups). -->
<ScrollViewer x:Name="HistoryScroller" Grid.Row="2">
<ListView x:Name="HistoryList" SelectionMode="None" Padding="10,2,10,12">
<ListView.ItemContainerTransitions><TransitionCollection/></ListView.ItemContainerTransitions>
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
Expand All @@ -437,7 +439,7 @@
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<Border CornerRadius="7" Padding="6,0" VerticalAlignment="Center"
Background="{StaticResource MandoPanelBrush}">
<TextBlock Text="{x:Bind Count}" FontSize="10" Opacity="0.7"/>
<TextBlock Text="{x:Bind Count, Mode=OneWay}" FontSize="10" Opacity="0.7"/>
</Border>
</StackPanel>
</Expander.Header>
Expand All @@ -449,14 +451,14 @@
Hidden for a single-item group — that card's
own Delete already does the same job. -->
<Button Click="HistoryDeleteGroup_Click" Tag="{x:Bind}"
Visibility="{x:Bind DeleteAllVisibility}"
Visibility="{x:Bind DeleteAllVisibility, Mode=OneWay}"
HorizontalAlignment="Right" Margin="0,0,0,8" Padding="8,3"
Background="Transparent" BorderThickness="0"
ToolTipService.ToolTip="Delete every conversation shown in this project"
AutomationProperties.Name="Delete every conversation shown in this project">
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE74D;" FontSize="11" Opacity="0.75"/>
<TextBlock Text="{x:Bind DeleteAllLabel}" FontSize="11" Opacity="0.75"/>
<TextBlock Text="{x:Bind DeleteAllLabel, Mode=OneWay}" FontSize="11" Opacity="0.75"/>
</StackPanel>
</Button>
<ItemsControl ItemsSource="{x:Bind}">
Expand Down Expand Up @@ -622,6 +624,7 @@

<ScrollViewer x:Name="NotesScroller" Grid.Row="2">
<ListView x:Name="NotesList" SelectionMode="None" Padding="10,2,10,12">
<ListView.ItemContainerTransitions><TransitionCollection/></ListView.ItemContainerTransitions>
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
Expand All @@ -642,7 +645,7 @@
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<Border CornerRadius="7" Padding="6,0" VerticalAlignment="Center"
Background="{StaticResource MandoPanelBrush}">
<TextBlock Text="{x:Bind Count}" FontSize="10" Opacity="0.7"/>
<TextBlock Text="{x:Bind Count, Mode=OneWay}" FontSize="10" Opacity="0.7"/>
</Border>
</StackPanel>
</Expander.Header>
Expand Down
Loading
Loading