10
10
using System . Threading ;
11
11
using System . Threading . Tasks ;
12
12
using Microsoft . AspNetCore . Razor . LanguageServer . Common ;
13
+ using Microsoft . AspNetCore . Razor . PooledObjects ;
13
14
using Microsoft . AspNetCore . Razor . Utilities ;
14
15
using Microsoft . CodeAnalysis . Razor ;
16
+ using Microsoft . CodeAnalysis . Razor . Utilities ;
15
17
using Microsoft . VisualStudio . Threading ;
16
18
17
19
namespace Microsoft . AspNetCore . Razor . LanguageServer ;
18
20
19
- internal class RazorFileChangeDetector : IFileChangeDetector , IDisposable
21
+ internal partial class RazorFileChangeDetector : IFileChangeDetector , IDisposable
20
22
{
21
23
private static readonly TimeSpan s_delay = TimeSpan . FromSeconds ( 1 ) ;
22
24
private static readonly ImmutableArray < string > s_razorFileExtensions = [ ".razor" , ".cshtml" ] ;
23
25
private static readonly string [ ] s_ignoredDirectories = [ "node_modules" ] ;
24
26
25
- // Internal for testing
26
- internal readonly Dictionary < string , DelayedFileChangeNotification > PendingNotifications ;
27
-
28
- private readonly ProjectSnapshotManagerDispatcher _dispatcher ;
29
27
private readonly ImmutableArray < IRazorFileChangeListener > _listeners ;
30
- private readonly List < FileSystemWatcher > _watchers ;
31
- private readonly object _pendingNotificationsLock = new ( ) ;
32
28
33
29
private readonly CancellationTokenSource _disposeTokenSource ;
34
- private readonly TimeSpan _delay ;
30
+ private readonly AsyncBatchingWorkQueue < ( string , RazorFileChangeKind ) > _workQueue ;
31
+ private readonly Dictionary < string , ( RazorFileChangeKind kind , int index ) > _filePathToChangeMap ;
32
+ private readonly HashSet < int > _indicesToSkip ;
33
+ private readonly List < FileSystemWatcher > _watchers ;
35
34
36
- public RazorFileChangeDetector (
37
- ProjectSnapshotManagerDispatcher dispatcher ,
38
- IEnumerable < IRazorFileChangeListener > listeners )
39
- : this ( dispatcher , listeners , s_delay )
35
+ public RazorFileChangeDetector ( IEnumerable < IRazorFileChangeListener > listeners )
36
+ : this ( listeners , s_delay )
40
37
{
41
38
}
42
39
43
- protected RazorFileChangeDetector (
44
- ProjectSnapshotManagerDispatcher dispatcher ,
45
- IEnumerable < IRazorFileChangeListener > listeners ,
46
- TimeSpan delay )
40
+ protected RazorFileChangeDetector ( IEnumerable < IRazorFileChangeListener > listeners , TimeSpan delay )
47
41
{
48
- _dispatcher = dispatcher ;
49
42
_listeners = listeners . ToImmutableArray ( ) ;
50
- _watchers = new List < FileSystemWatcher > ( s_razorFileExtensions . Length ) ;
51
- PendingNotifications = new Dictionary < string , DelayedFileChangeNotification > ( FilePathComparer . Instance ) ;
52
- _delay = delay ;
43
+
53
44
_disposeTokenSource = new ( ) ;
45
+ _workQueue = new AsyncBatchingWorkQueue < ( string , RazorFileChangeKind ) > ( delay , ProcessBatchAsync , _disposeTokenSource . Token ) ;
46
+ _filePathToChangeMap = new ( FilePathComparer . Instance ) ;
47
+ _indicesToSkip = [ ] ;
48
+ _watchers = new List < FileSystemWatcher > ( s_razorFileExtensions . Length ) ;
54
49
}
55
50
56
51
public void Dispose ( )
@@ -59,11 +54,81 @@ public void Dispose()
59
54
_disposeTokenSource . Dispose ( ) ;
60
55
}
61
56
62
- // Used in tests to ensure we can control when delayed notification work starts.
63
- internal ManualResetEventSlim ? BlockNotificationWorkStart { get ; set ; }
57
+ private async ValueTask ProcessBatchAsync ( ImmutableArray < ( string , RazorFileChangeKind ) > items , CancellationToken token )
58
+ {
59
+ // Clear out our helper collections.
60
+ _filePathToChangeMap . Clear ( ) ;
61
+ _indicesToSkip . Clear ( ) ;
62
+
63
+ // First, collect all of the file paths and note the indices add/remove change pairs for the same file path.
64
+ using var potentialItems = new PooledArrayBuilder < string > ( capacity : items . Length ) ;
65
+
66
+ var index = 0 ;
67
+
68
+ foreach ( var ( filePath , kind ) in items )
69
+ {
70
+ if ( token . IsCancellationRequested )
71
+ {
72
+ return ;
73
+ }
74
+
75
+ if ( _filePathToChangeMap . TryGetValue ( filePath , out var value ) )
76
+ {
77
+ // We've already seen this file path, so we should skip it later.
78
+ _indicesToSkip . Add ( index ) ;
79
+
80
+ var ( existingKind , existingIndex ) = value ;
64
81
65
- // Used in tests to ensure we can understand when notification work noops.
66
- internal ManualResetEventSlim ? NotifyNotificationNoop { get ; set ; }
82
+ // We only ever get added or removed. So, if we've already received an add and are getting
83
+ // a remove, there's no need to send the notification. Likewise, if we've received a remove
84
+ // and are getting an add, we can just elide this notification altogether.
85
+ if ( kind != existingKind )
86
+ {
87
+ _filePathToChangeMap . Remove ( filePath ) ;
88
+ _indicesToSkip . Add ( existingIndex ) ;
89
+ }
90
+ else
91
+ {
92
+ Debug . Fail ( $ "Unexpected { kind } event because our prior tracked state was the same.") ;
93
+ }
94
+ }
95
+ else
96
+ {
97
+ _filePathToChangeMap . Add ( filePath , ( kind , index ) ) ;
98
+ }
99
+
100
+ potentialItems . Add ( filePath ) ;
101
+ index ++ ;
102
+ }
103
+
104
+ // Now, loop through all of the file paths we collected and notify listeners of changes,
105
+ // taking care of to skip any indices that we noted earlier.
106
+ for ( var i = 0 ; i < potentialItems . Count ; i ++ )
107
+ {
108
+ if ( token . IsCancellationRequested )
109
+ {
110
+ return ;
111
+ }
112
+
113
+ if ( _indicesToSkip . Contains ( i ) )
114
+ {
115
+ continue ;
116
+ }
117
+
118
+ var filePath = potentialItems [ i ] ;
119
+
120
+ if ( ! _filePathToChangeMap . TryGetValue ( filePath , out var value ) )
121
+ {
122
+ continue ;
123
+ }
124
+
125
+ // We only send notifications for the changes that we kept.
126
+ foreach ( var listener in _listeners )
127
+ {
128
+ await listener . RazorFileChangedAsync ( filePath , value . kind , token ) . ConfigureAwait ( false ) ;
129
+ }
130
+ }
131
+ }
67
132
68
133
public async Task StartAsync ( string workspaceDirectory , CancellationToken cancellationToken )
69
134
{
@@ -100,22 +165,22 @@ public async Task StartAsync(string workspaceDirectory, CancellationToken cancel
100
165
IncludeSubdirectories = true ,
101
166
} ;
102
167
103
- watcher . Created += ( sender , args ) => FileSystemWatcher_RazorFileEvent_Background ( args . FullPath , RazorFileChangeKind . Added ) ;
104
- watcher . Deleted += ( sender , args ) => FileSystemWatcher_RazorFileEvent_Background ( args . FullPath , RazorFileChangeKind . Removed ) ;
168
+ watcher . Created += ( sender , args ) => _workQueue . AddWork ( ( args . FullPath , RazorFileChangeKind . Added ) ) ;
169
+ watcher . Deleted += ( sender , args ) => _workQueue . AddWork ( ( args . FullPath , RazorFileChangeKind . Removed ) ) ;
105
170
watcher . Renamed += ( sender , args ) =>
106
171
{
107
172
// Translate file renames into remove->add
108
173
109
174
if ( args . OldFullPath . EndsWith ( extension , FilePathComparison . Instance ) )
110
175
{
111
176
// Renaming from Razor file to something else.
112
- FileSystemWatcher_RazorFileEvent_Background ( args . OldFullPath , RazorFileChangeKind . Removed ) ;
177
+ _workQueue . AddWork ( ( args . OldFullPath , RazorFileChangeKind . Removed ) ) ;
113
178
}
114
179
115
180
if ( args . FullPath . EndsWith ( extension , FilePathComparison . Instance ) )
116
181
{
117
182
// Renaming to a Razor file.
118
- FileSystemWatcher_RazorFileEvent_Background ( args . FullPath , RazorFileChangeKind . Added ) ;
183
+ _workQueue . AddWork ( ( args . FullPath , RazorFileChangeKind . Added ) ) ;
119
184
}
120
185
} ;
121
186
@@ -129,9 +194,9 @@ public void Stop()
129
194
{
130
195
// We're relying on callers to synchronize start/stops so we don't need to ensure one happens before the other.
131
196
132
- for ( var i = 0 ; i < _watchers . Count ; i ++ )
197
+ foreach ( var watcher in _watchers )
133
198
{
134
- _watchers [ i ] . Dispose ( ) ;
199
+ watcher . Dispose ( ) ;
135
200
}
136
201
137
202
_watchers . Clear ( ) ;
@@ -143,122 +208,16 @@ protected virtual void OnInitializationFinished()
143
208
}
144
209
145
210
// Protected virtual for testing
146
- protected virtual IReadOnlyList < string > GetExistingRazorFiles ( string workspaceDirectory )
211
+ protected virtual ImmutableArray < string > GetExistingRazorFiles ( string workspaceDirectory )
147
212
{
148
- var existingRazorFiles = Enumerable . Empty < string > ( ) ;
213
+ using var result = new PooledArrayBuilder < string > ( ) ;
214
+
149
215
foreach ( var extension in s_razorFileExtensions )
150
216
{
151
217
var existingFiles = DirectoryHelper . GetFilteredFiles ( workspaceDirectory , "*" + extension , s_ignoredDirectories ) ;
152
- existingRazorFiles = existingRazorFiles . Concat ( existingFiles ) ;
218
+ result . AddRange ( existingFiles ) ;
153
219
}
154
220
155
- return existingRazorFiles . ToArray ( ) ;
156
- }
157
-
158
- // Internal for testing
159
- internal void FileSystemWatcher_RazorFileEvent_Background ( string physicalFilePath , RazorFileChangeKind kind )
160
- {
161
- lock ( _pendingNotificationsLock )
162
- {
163
- if ( ! PendingNotifications . TryGetValue ( physicalFilePath , out var currentNotification ) )
164
- {
165
- currentNotification = new DelayedFileChangeNotification ( ) ;
166
- PendingNotifications [ physicalFilePath ] = currentNotification ;
167
- }
168
-
169
- if ( currentNotification . ChangeKind != null )
170
- {
171
- // We've already has a file change event for this file. Chances are we need to normalize the result.
172
-
173
- Debug . Assert ( currentNotification . ChangeKind == RazorFileChangeKind . Added || currentNotification . ChangeKind == RazorFileChangeKind . Removed ) ;
174
-
175
- if ( currentNotification . ChangeKind != kind )
176
- {
177
- // Previous was added and current is removed OR previous was removed and current is added. Either way there's no
178
- // actual change to notify, null it out.
179
- currentNotification . ChangeKind = null ;
180
- }
181
- else
182
- {
183
- Debug . Fail ( $ "Unexpected { kind } event because our prior tracked state was the same.") ;
184
- }
185
- }
186
- else
187
- {
188
- currentNotification . ChangeKind = kind ;
189
- }
190
-
191
- if ( currentNotification . NotifyTask is null )
192
- {
193
- // The notify task is only ever null when it's the first time we're being notified about a change to the corresponding file.
194
- currentNotification . NotifyTask = NotifyAfterDelayAsync ( physicalFilePath ) ;
195
- }
196
- }
197
- }
198
-
199
- private async Task NotifyAfterDelayAsync ( string physicalFilePath )
200
- {
201
- await Task . Delay ( _delay ) . ConfigureAwait ( false ) ;
202
-
203
- OnStartingDelayedNotificationWork ( ) ;
204
-
205
- await _dispatcher . RunAsync (
206
- ( ) => NotifyAfterDelay_ProjectSnapshotManagerDispatcher ( physicalFilePath ) ,
207
- _disposeTokenSource . Token ) . ConfigureAwait ( false ) ;
208
- }
209
-
210
- private void NotifyAfterDelay_ProjectSnapshotManagerDispatcher ( string physicalFilePath )
211
- {
212
- lock ( _pendingNotificationsLock )
213
- {
214
- var result = PendingNotifications . TryGetValue ( physicalFilePath , out var notification ) ;
215
- Debug . Assert ( result , "We should always have an associated notification after delaying an update." ) ;
216
-
217
- Assumes . NotNull ( notification ) ;
218
-
219
- PendingNotifications . Remove ( physicalFilePath ) ;
220
-
221
- if ( notification . ChangeKind is null )
222
- {
223
- // The file to be notified has been brought back to its original state.
224
- // Aka Add -> Remove is equivalent to the file never having been added.
225
-
226
- OnNoopingNotificationWork ( ) ;
227
-
228
- return ;
229
- }
230
-
231
- var kind = notification . ChangeKind . Value ;
232
-
233
- foreach ( var listener in _listeners )
234
- {
235
- listener . RazorFileChangedAsync ( physicalFilePath , kind , _disposeTokenSource . Token ) . Forget ( ) ;
236
- }
237
- }
238
- }
239
-
240
- private void OnStartingDelayedNotificationWork ( )
241
- {
242
- if ( BlockNotificationWorkStart != null )
243
- {
244
- BlockNotificationWorkStart . Wait ( ) ;
245
- BlockNotificationWorkStart . Reset ( ) ;
246
- }
247
- }
248
-
249
- private void OnNoopingNotificationWork ( )
250
- {
251
- if ( NotifyNotificationNoop != null )
252
- {
253
- NotifyNotificationNoop . Set ( ) ;
254
- }
255
- }
256
-
257
- // Internal for testing
258
- internal class DelayedFileChangeNotification
259
- {
260
- public Task ? NotifyTask { get ; set ; }
261
-
262
- public RazorFileChangeKind ? ChangeKind { get ; set ; }
221
+ return result . DrainToImmutable ( ) ;
263
222
}
264
223
}
0 commit comments