Skip to content

Commit 627192e

Browse files
authored
Merge pull request #8803 from MiYanni/FixRenameNamespaceWithUnsavedChanged
QoL code changes for the rename namespace feature
2 parents 449497a + db5b56d commit 627192e

File tree

2 files changed

+103
-143
lines changed

2 files changed

+103
-143
lines changed

src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Rename/FileMoveNotificationListener.cs

Lines changed: 102 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information.
22

3-
using System.Diagnostics.CodeAnalysis;
43
using Microsoft.CodeAnalysis;
54
using Microsoft.CodeAnalysis.Rename;
65
using Microsoft.VisualStudio.LanguageServices;
76
using Microsoft.VisualStudio.OperationProgress;
87
using Microsoft.VisualStudio.ProjectSystem.Waiting;
98
using Microsoft.VisualStudio.Settings;
9+
using Microsoft.VisualStudio.Shell;
10+
using Microsoft.VisualStudio.Text;
1011
using Microsoft.VisualStudio.Threading;
12+
// Debug collides with Microsoft.VisualStudio.ProjectSystem.VS.Debug
13+
using DiagDebug = System.Diagnostics.Debug;
1114
using Path = System.IO.Path;
15+
using static Microsoft.CodeAnalysis.Rename.Renamer;
1216

1317
namespace Microsoft.VisualStudio.ProjectSystem.VS.Rename
1418
{
@@ -21,15 +25,16 @@ internal class FileMoveNotificationListener : IFileMoveNotificationListener
2125

2226
private readonly UnconfiguredProject _unconfiguredProject;
2327
private readonly IUserNotificationServices _userNotificationServices;
24-
private readonly IUnconfiguredProjectVsServices _projectVsServices;
2528
private readonly Workspace _workspace;
2629
private readonly IProjectThreadingService _threadingService;
2730
private readonly IVsService<SVsOperationProgress, IVsOperationProgressStatusService> _operationProgressService;
2831
private readonly IWaitIndicator _waitService;
2932
private readonly IRoslynServices _roslynServices;
3033
private readonly IVsService<SVsSettingsPersistenceManager, ISettingsManager> _settingsManagerService;
3134

32-
private List<(Renamer.RenameDocumentActionSet Set, string FileName)>? _actions;
35+
// The file-paths are the full disk path of the source file (path prior to moving the item).
36+
private readonly Dictionary<string, RenameDocumentActionSet> _renameActionSetByFilePath = new();
37+
private string? _renameMessage;
3338

3439
[ImportingConstructor]
3540
public FileMoveNotificationListener(
@@ -45,7 +50,6 @@ public FileMoveNotificationListener(
4550
{
4651
_unconfiguredProject = unconfiguredProject;
4752
_userNotificationServices = userNotificationServices;
48-
_projectVsServices = projectVsServices;
4953
_workspace = workspace;
5054
_threadingService = threadingService;
5155
_operationProgressService = operationProgressService;
@@ -56,190 +60,146 @@ public FileMoveNotificationListener(
5660

5761
public async Task OnBeforeFilesMovedAsync(IReadOnlyCollection<IFileMoveItem> items)
5862
{
59-
Project? project = GetCurrentProject();
60-
61-
if (project is not null && TryGetFilesToMove(out List<(string file, string destination)>? filesToMove))
62-
{
63-
_actions = await GetNamespaceUpdateActionsAsync();
64-
}
65-
else
66-
{
67-
_actions = null;
68-
}
69-
70-
return;
71-
72-
Project? GetCurrentProject()
63+
Project? project = _workspace.CurrentSolution.Projects.FirstOrDefault(p => StringComparers.Paths.Equals(p.FilePath, _unconfiguredProject.FullPath));
64+
if (project is null)
7365
{
74-
return _workspace.CurrentSolution.Projects.FirstOrDefault(
75-
proj => StringComparers.Paths.Equals(proj.FilePath, _projectVsServices.Project.FullPath));
66+
return;
7667
}
7768

78-
bool TryGetFilesToMove([NotNullWhen(returnValue: true)] out List<(string file, string destination)>? filesToMove)
69+
foreach (IFileMoveItem itemToMove in GetFilesToMove(items))
7970
{
80-
filesToMove = null;
81-
82-
foreach (IFileMoveItem item in items)
71+
Document? currentDocument = project.Documents.FirstOrDefault(d => StringComparers.Paths.Equals(d.FilePath, itemToMove.Source));
72+
if (currentDocument is null)
8373
{
84-
RecursiveTryGetFilesToMove(item, ref filesToMove);
74+
continue;
8575
}
8676

87-
return filesToMove is not null;
88-
}
77+
// Get the relative folder path from the project to the destination.
78+
string destinationFolderPath = Path.GetDirectoryName(_unconfiguredProject.MakeRelative(itemToMove.Destination));
79+
string[] destinationFolders = destinationFolderPath.Split(Delimiter.Path, StringSplitOptions.RemoveEmptyEntries);
8980

90-
void RecursiveTryGetFilesToMove(IFileMoveItem? item, ref List<(string file, string destination)>? filesToMove)
91-
{
92-
if (item is null)
81+
// Since this rename only moves the location of the file to another directory, it will use the SyncNamespaceDocumentAction in Roslyn as the rename action within this set.
82+
// The logic for selecting this rename action can be found here: https://github.com/dotnet/roslyn/blob/960f375f4825a189937d4bfd9fea8162ecc63177/src/Workspaces/Core/Portable/Rename/Renamer.cs#L133-L136
83+
RenameDocumentActionSet renameActionSet = await RenameDocumentAsync(currentDocument, s_renameOptions, null, destinationFolders);
84+
if (renameActionSet.ApplicableActions.IsEmpty || renameActionSet.ApplicableActions.Any(aa => aa.GetErrors().Any()))
9385
{
94-
return;
86+
continue;
9587
}
9688

97-
if (item.IsFolder)
98-
{
99-
if (item is not ICopyPasteItem copyPasteItem)
100-
{
101-
return;
102-
}
89+
// Getting the rename message requires an instance of RenameDocumentAction.
90+
// We only need to set this message text once for the lifetime of the class, since it isn't dynamic.
91+
// Even though it isn't dynamic, it does get localized appropriately in Roslyn.
92+
// The text in English is "Sync namespace to folder structure".
93+
_renameMessage ??= renameActionSet.ApplicableActions.First().GetDescription();
10394

104-
foreach (IFileMoveItem child in copyPasteItem.Children.Cast<IFileMoveItem>())
105-
{
106-
RecursiveTryGetFilesToMove(child, ref filesToMove);
107-
}
108-
}
109-
else
110-
{
111-
bool isCompileItem = StringComparers.ItemTypes.Equals(item.ItemType, Compile.SchemaName);
112-
113-
if (item.WithinProject && isCompileItem && !item.IsLinked && !item.IsFolder)
114-
{
115-
filesToMove ??= new();
116-
filesToMove.Add((item.Source, item.Destination));
117-
}
118-
}
95+
// Add the full source file-path of the item as the key for the rename action set.
96+
_renameActionSetByFilePath.Add(itemToMove.Source, renameActionSet);
11997
}
12098

121-
async Task<List<(Renamer.RenameDocumentActionSet, string)>> GetNamespaceUpdateActionsAsync()
122-
{
123-
List<(Renamer.RenameDocumentActionSet, string)> actions = new();
99+
return;
124100

125-
foreach ((string filenameWithPath, string destination) in filesToMove)
101+
static IEnumerable<IFileMoveItem> GetFilesToMove(IEnumerable<IFileMoveItem> items)
102+
{
103+
var itemQueue = new Queue<IFileMoveItem>(items);
104+
while(itemQueue.Count > 0)
126105
{
127-
string destinationFileRelative = _unconfiguredProject.MakeRelative(destination);
128-
string destinationFolder = Path.GetDirectoryName(destinationFileRelative);
129-
string[] documentFolders = destinationFolder.Split(Delimiter.Path, StringSplitOptions.RemoveEmptyEntries);
130-
131-
string filename = Path.GetFileName(filenameWithPath);
106+
IFileMoveItem item = itemQueue.Dequeue();
132107

133-
Document? oldDocument = project.Documents.FirstOrDefault(d => StringComparers.Paths.Equals(d.FilePath, filenameWithPath));
134-
135-
if (oldDocument is null)
108+
// Termination condition
109+
if (item is { WithinProject: true, IsFolder: false, IsLinked: false } &&
110+
StringComparers.ItemTypes.Equals(item.ItemType, Compile.SchemaName))
136111
{
112+
yield return item;
137113
continue;
138114
}
139115

140-
// This is a file item to another directory, it should only detect this a Update Namespace action.
141-
Renamer.RenameDocumentActionSet documentAction = await Renamer.RenameDocumentAsync(oldDocument, s_renameOptions, null, documentFolders);
142-
143-
if (documentAction.ApplicableActions.IsEmpty ||
144-
documentAction.ApplicableActions.Any(a => !a.GetErrors().IsEmpty))
116+
// Folder navigation
117+
if (item is { IsFolder: true } and ICopyPasteItem copyPasteItem)
145118
{
146-
continue;
119+
IEnumerable<IFileMoveItem> children = copyPasteItem.Children.Select(c => c as IFileMoveItem).WhereNotNull();
120+
foreach (IFileMoveItem child in children)
121+
{
122+
itemQueue.Enqueue(child);
123+
}
147124
}
148-
149-
actions.Add((documentAction, filename));
150125
}
151-
152-
return actions;
153126
}
154127
}
155128

156129
public async Task OnAfterFileMoveAsync()
157130
{
158-
if (_actions is { Count: not 0 } && await CheckUserConfirmationAsync())
131+
if (!_renameActionSetByFilePath.Any() || !await IsEnabledOrConfirmedAsync())
159132
{
160-
ApplyNamespaceUpdateActions();
133+
// Clear the collection since the user declined (or has disabled) the rename namespace option.
134+
_renameActionSetByFilePath.Clear();
135+
return;
161136
}
162137

163-
return;
164-
165-
async Task<bool> CheckUserConfirmationAsync()
138+
_ = _threadingService.JoinableTaskFactory.RunAsync(async () =>
166139
{
167-
ISettingsManager settings = await _settingsManagerService.GetValueAsync();
140+
// The wait service requires the main thread to run.
141+
await _threadingService.SwitchToUIThread();
142+
// Displays a dialog showing the progress of updating the namespaces in the files.
143+
_waitService.Run(
144+
title: string.Empty,
145+
message: _renameMessage!,
146+
allowCancel: true,
147+
asyncMethod: ApplyRenamesAsync,
148+
totalSteps: _renameActionSetByFilePath.Count);
149+
});
168150

169-
bool promptNamespaceUpdate = settings.GetValueOrDefault(VsToolsOptions.OptionPromptNamespaceUpdate, true);
170-
bool enabledNamespaceUpdate = settings.GetValueOrDefault(VsToolsOptions.OptionEnableNamespaceUpdate, true);
151+
return;
171152

172-
if (!enabledNamespaceUpdate || !promptNamespaceUpdate)
153+
async Task ApplyRenamesAsync(IWaitContext context)
154+
{
155+
CancellationToken token = context.CancellationToken;
156+
await TaskScheduler.Default;
157+
// WORKAROUND: We don't yet have a way to wait for the changes to propagate to Roslyn, tracked by https://github.com/dotnet/project-system/issues/3425
158+
// Instead, we wait for the IntelliSense stage to finish for the entire solution.
159+
IVsOperationProgressStatusService statusService = await _operationProgressService.GetValueAsync(token);
160+
await statusService.GetStageStatus(CommonOperationProgressStageIds.Intellisense).WaitForCompletionAsync().WithCancellation(token);
161+
// After waiting, a "new" published Solution is available.
162+
Solution solution = _workspace.CurrentSolution;
163+
164+
int currentStep = 1;
165+
foreach ((string filePath, RenameDocumentActionSet renameActionSet) in _renameActionSetByFilePath)
173166
{
174-
return enabledNamespaceUpdate;
175-
}
176-
177-
await _projectVsServices.ThreadingService.SwitchToUIThread();
167+
// Display the filename being updated to the user in the progress dialog.
168+
context.Update(currentStep: currentStep++, progressText: Path.GetFileName(filePath));
178169

179-
bool confirmation = _userNotificationServices.Confirm(VSResources.UpdateNamespacePromptMessage, out promptNamespaceUpdate);
180-
181-
await settings.SetValueAsync(VsToolsOptions.OptionPromptNamespaceUpdate, !promptNamespaceUpdate, true);
182-
183-
// If the user checked the "Don't show again" checkbox, we need to set the namespace enable state based on their selection of Yes/No in the dialog.
184-
if (promptNamespaceUpdate)
185-
{
186-
await settings.SetValueAsync(VsToolsOptions.OptionEnableNamespaceUpdate, confirmation, isMachineLocal: true);
170+
solution = await renameActionSet.UpdateSolutionAsync(solution, token);
187171
}
188172

189-
return confirmation;
173+
await _threadingService.SwitchToUIThread(token);
174+
bool areChangesApplied = _roslynServices.ApplyChangesToSolution(_workspace, solution);
175+
DiagDebug.Assert(areChangesApplied, "ApplyChangesToSolution returned false");
176+
// Clear the collection after it has been processed.
177+
_renameActionSetByFilePath.Clear();
190178
}
191179

192-
void ApplyNamespaceUpdateActions()
180+
async Task<bool> IsEnabledOrConfirmedAsync()
193181
{
194-
_ = _threadingService.JoinableTaskFactory.RunAsync(async () =>
195-
{
196-
await _projectVsServices.ThreadingService.SwitchToUIThread();
197-
198-
string message = _actions.First().Set.ApplicableActions.First().GetDescription();
199-
200-
_waitService.Run(
201-
title: "",
202-
message: message,
203-
allowCancel: true,
204-
async context =>
205-
{
206-
await TaskScheduler.Default;
207-
208-
Solution solution = await PublishLatestSolutionAsync(context.CancellationToken);
209-
210-
int currentStep = 1;
211-
212-
foreach ((Renamer.RenameDocumentActionSet action, string fileName) in _actions)
213-
{
214-
context.Update(currentStep: currentStep++, progressText: fileName);
215-
216-
solution = await action.UpdateSolutionAsync(solution, context.CancellationToken);
217-
}
218-
219-
await _projectVsServices.ThreadingService.SwitchToUIThread();
220-
221-
bool applied = _roslynServices.ApplyChangesToSolution(solution.Workspace, solution);
222-
223-
System.Diagnostics.Debug.Assert(applied, "ApplyChangesToSolution returned false");
224-
},
225-
totalSteps: _actions.Count);
226-
});
182+
ISettingsManager settings = await _settingsManagerService.GetValueAsync();
227183

228-
async Task<Solution> PublishLatestSolutionAsync(CancellationToken cancellationToken)
184+
bool isEnabled = settings.GetValueOrDefault(VsToolsOptions.OptionEnableNamespaceUpdate, defaultValue: true);
185+
bool isPromptEnabled = settings.GetValueOrDefault(VsToolsOptions.OptionPromptNamespaceUpdate, defaultValue: true);
186+
// If not enabled, returns false.
187+
// If enabled but prompt is not enabled, returns true.
188+
// Otherwise, we display the prompt to the user.
189+
if (!isEnabled || !isPromptEnabled)
229190
{
230-
// WORKAROUND: We don't yet have a way to wait for the changes to propagate
231-
// to Roslyn (tracked by https://github.com/dotnet/project-system/issues/3425), so
232-
// instead we wait for the IntelliSense stage to finish for the entire solution
233-
234-
IVsOperationProgressStatusService operationProgressStatusService = await _operationProgressService.GetValueAsync(cancellationToken);
235-
236-
IVsOperationProgressStageStatus stageStatus = operationProgressStatusService.GetStageStatus(CommonOperationProgressStageIds.Intellisense);
237-
238-
await stageStatus.WaitForCompletionAsync().WithCancellation(cancellationToken);
191+
return isEnabled;
192+
}
239193

240-
// The result of that wait, is basically a "new" published Solution, so grab it
241-
return _workspace.CurrentSolution;
194+
await _threadingService.SwitchToUIThread();
195+
bool isConfirmed = _userNotificationServices.Confirm(VSResources.UpdateNamespacePromptMessage, out bool disablePromptMessage);
196+
await settings.SetValueAsync(VsToolsOptions.OptionPromptNamespaceUpdate, !disablePromptMessage, isMachineLocal: true);
197+
// If the user checked the "Don't show again" checkbox, we need to set the namespace enable state based on their selection of Yes/No in the dialog.
198+
if (disablePromptMessage)
199+
{
200+
await settings.SetValueAsync(VsToolsOptions.OptionEnableNamespaceUpdate, isConfirmed, isMachineLocal: true);
242201
}
202+
return isConfirmed;
243203
}
244204
}
245205
}

src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Waiting/IWaitContext.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ internal interface IWaitContext : IDisposable
1818
/// <param name="message">The message to display, or <see langword="null"/> if no change is required.</param>
1919
/// <param name="currentStep">The current step's (one-based) index, or <see langword="null"/> if no change is required.</param>
2020
/// <param name="totalSteps">The total number of steps, or <see langword="null"/> if no change is required.</param>
21-
/// <param name="progressText">A progress messate display, or <see langword="null"/> if no change is required.</param>
21+
/// <param name="progressText">A progress message display, or <see langword="null"/> if no change is required.</param>
2222
void Update(
2323
string? message = null,
2424
int? currentStep = null,

0 commit comments

Comments
 (0)