-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Expand file tree
/
Copy pathExpandableSidebarItemBase.cs
More file actions
292 lines (257 loc) · 11.4 KB
/
Copy pathExpandableSidebarItemBase.cs
File metadata and controls
292 lines (257 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
// Copyright (c) Files Community
// SPDX-License-Identifier: MPL-2.0
using Files.App.Controls;
using Microsoft.Extensions.Logging;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media.Imaging;
using System.IO;
namespace Files.App.Data.Items
{
/// <summary>
/// Shared expansion plumbing for tree-view-style sidebar items (LocationItem, DriveItem).
/// Stores IsExpandableFolder / HasUnrealizedChildren / IsExpanded and runs the async lazy
/// subfolder load on first expansion. Subclasses provide the path + child collection via
/// abstract members and keep their own Children getter (Home / non-expandable cases vary).
/// </summary>
public abstract partial class ExpandableSidebarItemBase : ObservableObject
{
protected abstract string ExpansionPath { get; }
protected abstract BulkConcurrentObservableCollection<INavigationControlItem> EnsureChildItems();
private bool isExpandableFolder;
public bool IsExpandableFolder
{
get => isExpandableFolder;
set
{
if (SetProperty(ref isExpandableFolder, value))
{
OnPropertyChanged(nameof(ISidebarItemModel.Children));
OnPropertyChanged(nameof(IsLeafWithChildren));
// Tab-state restoration may have flipped IsExpanded before expandability was known — drive the lazy load now.
if (value && isExpanded && !childrenLoaded && !childrenLoading)
_ = LoadSubfoldersAsync();
}
}
}
private bool hasUnrealizedChildren;
public bool HasUnrealizedChildren
{
get => hasUnrealizedChildren;
set => SetProperty(ref hasUnrealizedChildren, value);
}
public bool IsLeafWithChildren => IsExpandableFolder;
private bool childrenLoaded;
private bool childrenLoading;
private bool isExpanded;
public bool IsExpanded
{
get => isExpanded;
set
{
if (!SetProperty(ref isExpanded, value))
return;
if (value)
{
if (IsExpandableFolder && !childrenLoaded && !childrenLoading)
{
_ = LoadSubfoldersAsync();
}
else if (childrenLoaded)
{
// Re-expanded after a collapse: watcher was stopped, so do a single catch-up resync (bypassing the debounce) to pick up any changes that happened while invisible, then resume live watching.
StartWatchingSubfolders();
_ = ResyncSubfoldersAsync();
}
}
else
{
// Children aren't in the flat tree while collapsed; StopWatchingSubfolders disposes the watcher + debounce timer and cancels any in-flight resync via the generation counter.
StopWatchingSubfolders();
}
}
}
public async Task LoadSubfoldersAsync()
{
if (childrenLoaded || childrenLoading)
return;
childrenLoading = true;
try
{
await LocationItem.LoadSubfoldersIntoAsync(ExpansionPath, EnsureChildItems(), () =>
{
HasUnrealizedChildren = false;
childrenLoaded = true;
StartWatchingSubfolders();
});
}
finally
{
childrenLoading = false;
}
}
public bool IsLoaded => childrenLoaded;
// Wipes the cached child collection and (if still expanded) re-runs LoadSubfoldersAsync so the next enumeration picks up the latest IUserSettingsService.FoldersSettingsService filter flags. The tab-expansion tracker re-expands matching descendants as they get re-added, so deep subtrees fan back out without an explicit walk.
public async Task ReloadSubfoldersAsync()
{
if (!childrenLoaded)
return;
StopWatchingSubfoldersAndDescendants();
EnsureChildItems().Clear();
childrenLoaded = false;
if (IsExpanded && IsExpandableFolder)
await LoadSubfoldersAsync();
}
#region Subfolder watcher
// Generous debounce: the sidebar tree is a navigation aid, not a real-time mirror; waiting a few seconds before resyncing coalesces bursts (extract / clone / build) into a single enumeration.
private static readonly TimeSpan ResyncDebounce = TimeSpan.FromSeconds(3);
private FileSystemWatcher? subfolderWatcher;
private DispatcherQueueTimer? resyncDebounceTimer;
// Incremented every time the watcher (re)starts or stops. ResyncSubfoldersAsync captures the value on entry and aborts at every await if it no longer matches — this cancels in-flight resyncs when the row is collapsed, when ReloadSubfoldersAsync wipes ChildItems, or when a parent pruned this item out of the tree.
private int resyncGeneration;
// Starts watching the folder for child folder add / remove / rename so the sidebar reflects filesystem changes without an app restart. No-op when the path doesn't exist (drives that aren't ready, ejected media, etc.) or watcher construction fails (UnauthorizedAccessException on protected roots).
private void StartWatchingSubfolders()
{
if (subfolderWatcher is not null || !Directory.Exists(ExpansionPath))
return;
try
{
subfolderWatcher = new FileSystemWatcher(ExpansionPath) { NotifyFilter = NotifyFilters.DirectoryName };
subfolderWatcher.Created += OnSubfolderWatcherEvent;
subfolderWatcher.Deleted += OnSubfolderWatcherEvent;
subfolderWatcher.Renamed += OnSubfolderWatcherEvent;
subfolderWatcher.EnableRaisingEvents = true;
}
// FileSystemWatcher ctor throws ArgumentException for invalid paths, UnauthorizedAccessException for protected roots; either way fall back to no live updates for this subtree.
catch (Exception ex)
{
App.Logger?.LogDebug(ex, "Sidebar subfolder watcher start failed for {Path}", LogPathHelper.RedactPath(ExpansionPath));
subfolderWatcher?.Dispose();
subfolderWatcher = null;
}
}
private void StopWatchingSubfolders()
{
// Bumping the generation cancels any in-flight ResyncSubfoldersAsync after its next await — without this, a watcher tick fired just before collapse / prune would resume on the dispatcher and mutate ChildItems that nothing is observing.
Interlocked.Increment(ref resyncGeneration);
if (resyncDebounceTimer is not null)
{
try { resyncDebounceTimer.Stop(); }
catch (Exception ex) { App.Logger?.LogDebug(ex, "Sidebar subfolder debounce timer stop failed"); }
resyncDebounceTimer = null;
}
if (subfolderWatcher is null)
return;
// Dispose stops raising events and releases the OS notification subscription; the catch covers ObjectDisposedException / IOException if the watched folder has gone away.
try { subfolderWatcher.Dispose(); }
catch (Exception ex) { App.Logger?.LogDebug(ex, "Sidebar subfolder watcher dispose failed"); }
subfolderWatcher = null;
}
// Called by a parent's ReloadSubfoldersAsync (and by the section-sync remove paths in SidebarViewModel) before clearing/removing items; without this, descendants would be detached from the tree but keep firing into orphaned timers/handlers.
internal void StopWatchingSubfoldersAndDescendants()
{
StopWatchingSubfolders();
if (!childrenLoaded)
return;
foreach (var child in EnsureChildItems().OfType<ExpandableSidebarItemBase>())
child.StopWatchingSubfoldersAndDescendants();
}
// Fires on a thread-pool thread; FileSystemWatcher can fan out a single mkdir into Created + LastWrite. Marshal to UI + debounce so a burst yields one resync.
private void OnSubfolderWatcherEvent(object sender, FileSystemEventArgs e)
{
var dispatcher = MainWindow.Instance?.DispatcherQueue;
if (dispatcher is null)
return;
dispatcher.TryEnqueue(() =>
{
if (resyncDebounceTimer is null)
{
resyncDebounceTimer = dispatcher.CreateTimer();
resyncDebounceTimer.Interval = ResyncDebounce;
resyncDebounceTimer.Tick += async (t, _) => { t.Stop(); await ResyncSubfoldersAsync(); };
}
resyncDebounceTimer.Stop();
resyncDebounceTimer.Start();
});
}
// Reconciles ChildItems with the live filesystem state via a single merge walk over two name-sorted sequences (collection by LocationItem.Text, freshEntries by SubfolderEntry.Name, both OrdinalIgnoreCase). New rows paint with the cached generic folder icon up front; per-path custom icons are background-upgraded after — same two-phase paint as the initial load.
private async Task ResyncSubfoldersAsync()
{
// Children are off-screen while collapsed; the IsExpanded=true branch fires a catch-up resync when the row reopens.
if (!childrenLoaded || !IsExpanded)
return;
// Capture the current generation; if the watcher is stopped or restarted before we finish, the comparisons below short-circuit so we never mutate a collapsed / orphaned ChildItems.
var generation = Volatile.Read(ref resyncGeneration);
var showHidden = Ioc.Default.GetService<IUserSettingsService>()?.FoldersSettingsService?.ShowHiddenItems ?? false;
List<SubfolderEntry> freshEntries;
try
{
freshEntries = await Task.Run(() => FolderHelpers.EnumerateSubfolders(ExpansionPath, showHidden, showProtected: false, showDot: false));
}
// EnumerateSubfolders can throw UnauthorizedAccessException / IOException if the folder is in a bad state mid-resync; treat as "no change visible right now" rather than tearing down ChildItems.
catch (Exception ex)
{
App.Logger?.LogDebug(ex, "Sidebar subfolder resync enumeration failed for {Path}", LogPathHelper.RedactPath(ExpansionPath));
return;
}
if (IsResyncCancelled(generation))
return;
var iconBytes = await LocationItem.GetGenericSmallFolderIconBytesAsync();
var dispatcher = MainWindow.Instance?.DispatcherQueue;
if (dispatcher is null || IsResyncCancelled(generation))
return;
List<LocationItem>? inserted = null;
await dispatcher.EnqueueOrInvokeAsync(async () =>
{
if (IsResyncCancelled(generation))
return;
var collection = EnsureChildItems();
var cmp = StringComparer.OrdinalIgnoreCase;
BitmapImage? sharedIcon = null;
var iconAttempted = false;
var ci = 0;
var fi = 0;
void Prune()
{
if (collection[ci] is ExpandableSidebarItemBase orphan)
orphan.StopWatchingSubfoldersAndDescendants();
collection.RemoveAt(ci);
}
async Task InsertAsync(SubfolderEntry entry)
{
// Decode the shared bitmap lazily: a delete-only resync skips it entirely.
if (!iconAttempted)
{
iconAttempted = true;
if (iconBytes is not null)
{
try { sharedIcon = await iconBytes.ToBitmapAsync(); }
// BitmapImage.SetSourceAsync throws on corrupt bytes; let new rows render iconless rather than skip the structural sync.
catch (Exception ex) { App.Logger?.LogDebug(ex, "Sidebar resync shared folder icon decode failed"); }
}
}
var newItem = LocationItem.CreateSubfolder(entry, sharedIcon);
collection.Insert(ci, newItem);
(inserted ??= []).Add(newItem);
ci++;
}
while (ci < collection.Count && fi < freshEntries.Count)
{
if (IsResyncCancelled(generation))
return;
var order = cmp.Compare((collection[ci] as LocationItem)?.Text ?? string.Empty, freshEntries[fi].Name);
if (order == 0) { ci++; fi++; }
else if (order < 0) Prune();
else { await InsertAsync(freshEntries[fi]); fi++; }
}
while (ci < collection.Count && !IsResyncCancelled(generation)) Prune();
while (fi < freshEntries.Count && !IsResyncCancelled(generation)) { await InsertAsync(freshEntries[fi]); fi++; }
});
if (inserted is not null && !IsResyncCancelled(generation))
_ = LocationItem.UpgradeIconsAsync(inserted, iconBytes, dispatcher);
}
// Centralizes the "this resync is still relevant" check so every await/loop boundary aborts uniformly when the watcher was stopped or restarted under us.
private bool IsResyncCancelled(int generation)
=> generation != Volatile.Read(ref resyncGeneration) || !childrenLoaded || !IsExpanded;
#endregion
}
}