Skip to content

Commit b677eb8

Browse files
committed
Register property changed handler to react to date changes so the item can be moved and Unit test also created.
1 parent 311a0d4 commit b677eb8

File tree

4 files changed

+285
-0
lines changed

4 files changed

+285
-0
lines changed

Files.sln

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
Microsoft Visual Studio Solution File, Format Version 12.00
2+
# Visual Studio Version 17
3+
VisualStudioVersion = 17.5.2.0
4+
MinimumVisualStudioVersion = 10.0.40219.1
5+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
6+
EndProject
7+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
8+
EndProject
9+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Files.InteractionTests", "tests\Files.InteractionTests\Files.InteractionTests.csproj", "{9A20CF5B-549E-FB0A-4791-91CA4FFCCFFF}"
10+
EndProject
11+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Files.App.UITests", "tests\Files.App.UITests\Files.App.UITests.csproj", "{85D62465-0545-08C0-6135-FB568D81A323}"
12+
EndProject
13+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Files.Core.SourceGenerator", "src\Files.Core.SourceGenerator\Files.Core.SourceGenerator.csproj", "{7B50ED23-B535-E658-9542-00885ED406FA}"
14+
EndProject
15+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Files.Core.Storage", "src\Files.Core.Storage\Files.Core.Storage.csproj", "{0CD123D7-CAE3-0F58-73C0-8BF39753B788}"
16+
EndProject
17+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Files.App.Server", "src\Files.App.Server\Files.App.Server.csproj", "{A9FC40D5-7AA8-6EB5-C5A5-F5075045DBCC}"
18+
EndProject
19+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Files.App.Controls", "src\Files.App.Controls\Files.App.Controls.csproj", "{98A9E6D7-7C0E-1E78-8CE6-6E42C8A70B34}"
20+
EndProject
21+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Files.App.Storage", "src\Files.App.Storage\Files.App.Storage.csproj", "{C425951C-86B6-E4C0-724E-047E9A2C8599}"
22+
EndProject
23+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Files.App.CsWin32", "src\Files.App.CsWin32\Files.App.CsWin32.csproj", "{C1C83347-1524-3EBB-6B49-ECE025775668}"
24+
EndProject
25+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Files.App.BackgroundTasks", "src\Files.App.BackgroundTasks\Files.App.BackgroundTasks.csproj", "{F8B5749F-C6EA-8FE3-B03A-ACE02A8076A7}"
26+
EndProject
27+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Files.Shared", "src\Files.Shared\Files.Shared.csproj", "{6C01E445-3C11-C76F-CE47-0B9A775ACF0A}"
28+
EndProject
29+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Files.App", "src\Files.App\Files.App.csproj", "{E6BD44A2-F200-6AC3-5A80-68727B1BE71B}"
30+
EndProject
31+
Global
32+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
33+
Debug|Any CPU = Debug|Any CPU
34+
Release|Any CPU = Release|Any CPU
35+
EndGlobalSection
36+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
37+
{9A20CF5B-549E-FB0A-4791-91CA4FFCCFFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
38+
{9A20CF5B-549E-FB0A-4791-91CA4FFCCFFF}.Debug|Any CPU.Build.0 = Debug|Any CPU
39+
{9A20CF5B-549E-FB0A-4791-91CA4FFCCFFF}.Release|Any CPU.ActiveCfg = Release|Any CPU
40+
{9A20CF5B-549E-FB0A-4791-91CA4FFCCFFF}.Release|Any CPU.Build.0 = Release|Any CPU
41+
{85D62465-0545-08C0-6135-FB568D81A323}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
42+
{85D62465-0545-08C0-6135-FB568D81A323}.Debug|Any CPU.Build.0 = Debug|Any CPU
43+
{85D62465-0545-08C0-6135-FB568D81A323}.Release|Any CPU.ActiveCfg = Release|Any CPU
44+
{85D62465-0545-08C0-6135-FB568D81A323}.Release|Any CPU.Build.0 = Release|Any CPU
45+
{7B50ED23-B535-E658-9542-00885ED406FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
46+
{7B50ED23-B535-E658-9542-00885ED406FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
47+
{7B50ED23-B535-E658-9542-00885ED406FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
48+
{7B50ED23-B535-E658-9542-00885ED406FA}.Release|Any CPU.Build.0 = Release|Any CPU
49+
{0CD123D7-CAE3-0F58-73C0-8BF39753B788}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
50+
{0CD123D7-CAE3-0F58-73C0-8BF39753B788}.Debug|Any CPU.Build.0 = Debug|Any CPU
51+
{0CD123D7-CAE3-0F58-73C0-8BF39753B788}.Release|Any CPU.ActiveCfg = Release|Any CPU
52+
{0CD123D7-CAE3-0F58-73C0-8BF39753B788}.Release|Any CPU.Build.0 = Release|Any CPU
53+
{A9FC40D5-7AA8-6EB5-C5A5-F5075045DBCC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
54+
{A9FC40D5-7AA8-6EB5-C5A5-F5075045DBCC}.Debug|Any CPU.Build.0 = Debug|Any CPU
55+
{A9FC40D5-7AA8-6EB5-C5A5-F5075045DBCC}.Release|Any CPU.ActiveCfg = Release|Any CPU
56+
{A9FC40D5-7AA8-6EB5-C5A5-F5075045DBCC}.Release|Any CPU.Build.0 = Release|Any CPU
57+
{98A9E6D7-7C0E-1E78-8CE6-6E42C8A70B34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
58+
{98A9E6D7-7C0E-1E78-8CE6-6E42C8A70B34}.Debug|Any CPU.Build.0 = Debug|Any CPU
59+
{98A9E6D7-7C0E-1E78-8CE6-6E42C8A70B34}.Release|Any CPU.ActiveCfg = Release|Any CPU
60+
{98A9E6D7-7C0E-1E78-8CE6-6E42C8A70B34}.Release|Any CPU.Build.0 = Release|Any CPU
61+
{C425951C-86B6-E4C0-724E-047E9A2C8599}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
62+
{C425951C-86B6-E4C0-724E-047E9A2C8599}.Debug|Any CPU.Build.0 = Debug|Any CPU
63+
{C425951C-86B6-E4C0-724E-047E9A2C8599}.Release|Any CPU.ActiveCfg = Release|Any CPU
64+
{C425951C-86B6-E4C0-724E-047E9A2C8599}.Release|Any CPU.Build.0 = Release|Any CPU
65+
{C1C83347-1524-3EBB-6B49-ECE025775668}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
66+
{C1C83347-1524-3EBB-6B49-ECE025775668}.Debug|Any CPU.Build.0 = Debug|Any CPU
67+
{C1C83347-1524-3EBB-6B49-ECE025775668}.Release|Any CPU.ActiveCfg = Release|Any CPU
68+
{C1C83347-1524-3EBB-6B49-ECE025775668}.Release|Any CPU.Build.0 = Release|Any CPU
69+
{F8B5749F-C6EA-8FE3-B03A-ACE02A8076A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
70+
{F8B5749F-C6EA-8FE3-B03A-ACE02A8076A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
71+
{F8B5749F-C6EA-8FE3-B03A-ACE02A8076A7}.Release|Any CPU.ActiveCfg = Release|Any CPU
72+
{F8B5749F-C6EA-8FE3-B03A-ACE02A8076A7}.Release|Any CPU.Build.0 = Release|Any CPU
73+
{6C01E445-3C11-C76F-CE47-0B9A775ACF0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
74+
{6C01E445-3C11-C76F-CE47-0B9A775ACF0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
75+
{6C01E445-3C11-C76F-CE47-0B9A775ACF0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
76+
{6C01E445-3C11-C76F-CE47-0B9A775ACF0A}.Release|Any CPU.Build.0 = Release|Any CPU
77+
{E6BD44A2-F200-6AC3-5A80-68727B1BE71B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
78+
{E6BD44A2-F200-6AC3-5A80-68727B1BE71B}.Debug|Any CPU.Build.0 = Debug|Any CPU
79+
{E6BD44A2-F200-6AC3-5A80-68727B1BE71B}.Release|Any CPU.ActiveCfg = Release|Any CPU
80+
{E6BD44A2-F200-6AC3-5A80-68727B1BE71B}.Release|Any CPU.Build.0 = Release|Any CPU
81+
EndGlobalSection
82+
GlobalSection(SolutionProperties) = preSolution
83+
HideSolutionNode = FALSE
84+
EndGlobalSection
85+
GlobalSection(NestedProjects) = preSolution
86+
{9A20CF5B-549E-FB0A-4791-91CA4FFCCFFF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
87+
{85D62465-0545-08C0-6135-FB568D81A323} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
88+
{7B50ED23-B535-E658-9542-00885ED406FA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
89+
{0CD123D7-CAE3-0F58-73C0-8BF39753B788} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
90+
{A9FC40D5-7AA8-6EB5-C5A5-F5075045DBCC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
91+
{98A9E6D7-7C0E-1E78-8CE6-6E42C8A70B34} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
92+
{C425951C-86B6-E4C0-724E-047E9A2C8599} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
93+
{C1C83347-1524-3EBB-6B49-ECE025775668} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
94+
{F8B5749F-C6EA-8FE3-B03A-ACE02A8076A7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
95+
{6C01E445-3C11-C76F-CE47-0B9A775ACF0A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
96+
{E6BD44A2-F200-6AC3-5A80-68727B1BE71B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
97+
EndGlobalSection
98+
GlobalSection(ExtensibilityGlobals) = postSolution
99+
SolutionGuid = {8E21E423-8E7A-40CB-BE6B-5B2B56256E35}
100+
EndGlobalSection
101+
EndGlobal

src/Files.App/Utils/Storage/Collection/BulkConcurrentObservableCollection.cs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
// Licensed under the MIT License.
33

44
using System.Collections.Specialized;
5+
using System.Runtime.CompilerServices;
56

67
namespace Files.App.Utils.Storage
78
{
89
[DebuggerTypeProxy(typeof(CollectionDebugView<>))]
910
[DebuggerDisplay("Count = {Count}")]
1011
public class BulkConcurrentObservableCollection<T> : INotifyCollectionChanged, INotifyPropertyChanged, ICollection<T>, IList<T>, ICollection, IList
12+
where T : class
1113
{
1214
protected bool isBulkOperationStarted;
1315
private readonly object syncRoot = new object();
@@ -200,7 +202,10 @@ private void AddItemsToGroup(IEnumerable<T> items, CancellationToken token = def
200202
GroupedCollection?.Add(group);
201203
GroupedCollection!.IsSorted = false;
202204
}
205+
// Register property changed handler to react to date changes so the item can be moved
206+
RegisterPropertyChanged(item);
203207
}
208+
204209
}
205210

206211
private void RemoveItemsFromGroup(IEnumerable<T> items)
@@ -216,6 +221,100 @@ private void RemoveItemsFromGroup(IEnumerable<T> items)
216221
if (group.Count == 0)
217222
GroupedCollection?.Remove(group);
218223
}
224+
225+
// Unregister change handler when item is removed from groups/collection
226+
UnregisterPropertyChanged(item);
227+
}
228+
}
229+
230+
private readonly ConditionalWeakTable<T, PropertyChangedEventHandler> propertyChangedHandlers = new();
231+
232+
private void RegisterPropertyChanged(T item)
233+
{
234+
if (item is INotifyPropertyChanged notifier)
235+
{
236+
// avoid duplicate handler
237+
if (propertyChangedHandlers.TryGetValue(item, out _))
238+
return;
239+
240+
PropertyChangedEventHandler handler = (s, e) =>
241+
{
242+
// React to date fields changing — move item between groups if needed
243+
if (e.PropertyName is "ItemDateModifiedReal" or "ItemDateCreatedReal" or "ItemDateAccessedReal" or "ItemDateDeletedReal")
244+
OnItemDatePropertyChanged((T)s);
245+
};
246+
247+
propertyChangedHandlers.Add(item, handler);
248+
notifier.PropertyChanged += handler;
249+
}
250+
}
251+
252+
private void UnregisterPropertyChanged(T item)
253+
{
254+
if (item is INotifyPropertyChanged notifier)
255+
{
256+
if (propertyChangedHandlers.TryGetValue(item, out var handler))
257+
{
258+
notifier.PropertyChanged -= handler;
259+
propertyChangedHandlers.Remove(item);
260+
}
261+
}
262+
}
263+
264+
private void OnItemDatePropertyChanged(T item)
265+
{
266+
if (!IsGrouped || ItemGroupKeySelector is null)
267+
return;
268+
269+
var newKey = GetGroupKeyForItem(item);
270+
if (newKey is null)
271+
return;
272+
273+
var oldKey = (item is IGroupableItem groupable) ? groupable.Key : null;
274+
if (oldKey == newKey)
275+
return;
276+
277+
// Move item between groups under a lock to keep collection consistent
278+
lock (syncRoot)
279+
{
280+
// remove from old group
281+
if (!string.IsNullOrEmpty(oldKey))
282+
{
283+
var oldGroup = GroupedCollection?.Where(x => x.Model.Key == oldKey).FirstOrDefault();
284+
if (oldGroup is not null && oldGroup.Contains(item))
285+
{
286+
oldGroup.Remove(item);
287+
if (oldGroup.Count == 0)
288+
GroupedCollection?.Remove(oldGroup);
289+
}
290+
}
291+
292+
// add to new group
293+
var groups = GroupedCollection?.Where(x => x.Model.Key == newKey);
294+
if (item is IGroupableItem gp)
295+
gp.Key = newKey;
296+
297+
if (groups is not null && groups.Any())
298+
{
299+
var gp = groups.First();
300+
if (!gp.Contains(item))
301+
gp.Add(item);
302+
gp.IsSorted = false;
303+
}
304+
else
305+
{
306+
var group = new GroupedCollection<T>(newKey)
307+
{
308+
item
309+
};
310+
311+
group.GetExtendedGroupHeaderInfo = GetExtendedGroupHeaderInfo;
312+
if (GetGroupHeaderInfo is not null)
313+
GetGroupHeaderInfo.Invoke(group);
314+
315+
GroupedCollection?.Add(group);
316+
GroupedCollection!.IsSorted = false;
317+
}
219318
}
220319
}
221320

@@ -265,6 +364,10 @@ public void Clear()
265364
{
266365
lock (syncRoot)
267366
{
367+
// Unregister handlers for all items before clearing
368+
foreach (var it in collection.ToList())
369+
UnregisterPropertyChanged(it);
370+
268371
collection.Clear();
269372
GroupedCollection?.Clear();
270373

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using Microsoft.VisualStudio.TestTools.UnitTesting;
2+
using System;
3+
using System.Collections.Generic;
4+
using System.ComponentModel;
5+
using System.Linq;
6+
7+
namespace Files.App.UnitTests
8+
{
9+
[TestClass]
10+
public class BulkConcurrentObservableCollectionTests
11+
{
12+
private class TestItem : INotifyPropertyChanged, Utils.Storage.IGroupableItem
13+
{
14+
public string Key { get; set; }
15+
16+
private DateTimeOffset _date;
17+
public DateTimeOffset ItemDateModifiedReal
18+
{
19+
get => _date;
20+
set
21+
{
22+
if (_date != value)
23+
{
24+
_date = value;
25+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ItemDateModifiedReal)));
26+
}
27+
}
28+
}
29+
30+
public event PropertyChangedEventHandler? PropertyChanged;
31+
32+
public override string ToString() => ItemDateModifiedReal.ToString();
33+
}
34+
35+
[TestMethod]
36+
public void When_ItemDateChanges_ItemMovesBetweenGroups()
37+
{
38+
// Group by logic: within 7 days = "Recent", else "Old"
39+
var col = new Utils.Storage.BulkConcurrentObservableCollection<TestItem>();
40+
col.ItemGroupKeySelector = item => (DateTimeOffset.Now - item.ItemDateModifiedReal).TotalDays <= 7 ? "Recent" : "Old";
41+
42+
var recentItem = new TestItem { ItemDateModifiedReal = DateTimeOffset.Now.AddDays(-3) };
43+
var oldItem = new TestItem { ItemDateModifiedReal = DateTimeOffset.Now.AddDays(-400) };
44+
45+
col.Add(recentItem);
46+
col.Add(oldItem);
47+
48+
Assert.IsNotNull(col.GroupedCollection);
49+
Assert.AreEqual(2, col.GroupedCollection.Count);
50+
51+
// Now change recentItem date so it becomes old
52+
recentItem.ItemDateModifiedReal = DateTimeOffset.Now.AddDays(-400);
53+
54+
// It should have been moved to the old group
55+
var recentGroup = col.GroupedCollection.FirstOrDefault(g => g.Model.Key == "Recent");
56+
var oldGroup = col.GroupedCollection.FirstOrDefault(g => g.Model.Key == "Old");
57+
58+
Assert.IsTrue(recentGroup == null || !recentGroup.Contains(recentItem), "recentItem should not be in recent group anymore");
59+
Assert.IsNotNull(oldGroup, "old group should exist");
60+
Assert.IsTrue(oldGroup.Contains(recentItem), "recentItem should be in old group now");
61+
}
62+
}
63+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net9.0</TargetFramework>
5+
<IsPackable>false</IsPackable>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
10+
<PackageReference Include="MSTest.TestAdapter" Version="3.1.3" />
11+
<PackageReference Include="MSTest.TestFramework" Version="3.1.3" />
12+
</ItemGroup>
13+
14+
<ItemGroup>
15+
<ProjectReference Include="../../src/Files.App/Files.App.csproj" />
16+
</ItemGroup>
17+
18+
</Project>

0 commit comments

Comments
 (0)