diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/RazorProjectService.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/RazorProjectService.cs index ba74f7e3503..778059d38a1 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/RazorProjectService.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/RazorProjectService.cs @@ -391,10 +391,15 @@ private void UpdateProjectDocuments( _logger.LogTrace($"Updating document '{newHostDocument.FilePath}''s file kind to '{newHostDocument.FileKind}' and target path to '{newHostDocument.TargetPath}'."); - var remoteTextLoader = _remoteTextLoaderFactory.Create(newFilePath); + // If the physical file name hasn't changed, we use the current document snapshot as the source of truth for text, in case + // it has received text change info from LSP. eg, if someone changes the TargetPath of the file while its open in the editor + // with unsaved changes, we don't want to reload it from disk. + var textLoader = FilePathComparer.Instance.Equals(currentHostDocument.FilePath, newHostDocument.FilePath) + ? new DocumentSnapshotTextLoader(documentSnapshot) + : _remoteTextLoaderFactory.Create(newFilePath); updater.DocumentRemoved(currentProjectKey, currentHostDocument); - updater.DocumentAdded(currentProjectKey, newHostDocument, remoteTextLoader); + updater.DocumentAdded(currentProjectKey, newHostDocument, textLoader); } project = _projectManager.GetLoadedProject(project.Key); @@ -493,18 +498,27 @@ private void TryMigrateMiscellaneousDocumentsToProject(ProjectSnapshotManager.Up } // Remove from miscellaneous project - var defaultMiscProject = miscellaneousProject; - - updater.DocumentRemoved(defaultMiscProject.Key, documentSnapshot.State.HostDocument); + updater.DocumentRemoved(miscellaneousProject.Key, documentSnapshot.State.HostDocument); // Add to new project var textLoader = new DocumentSnapshotTextLoader(documentSnapshot); - var defaultProject = projectSnapshot; - var newHostDocument = new HostDocument(documentSnapshot.FilePath, documentSnapshot.TargetPath); + + // If we're moving from the misc files project to a real project, then target path will be the full path to the file + // and the next update to the project will update it to be a relative path. To save a bunch of busy work if that is + // the only change necessary, we can proactively do that work here. This also means that when we later find out about + // this document the "real" way, it will be equal to the one we already know about, and we won't lose content + var projectDirectory = FilePathNormalizer.GetNormalizedDirectoryName(projectSnapshot.FilePath); + var newTargetPath = documentSnapshot.TargetPath; + if (FilePathNormalizer.Normalize(newTargetPath).StartsWith(projectDirectory)) + { + newTargetPath = newTargetPath[projectDirectory.Length..]; + } + + var newHostDocument = new HostDocument(documentSnapshot.FilePath, newTargetPath, documentSnapshot.FileKind); _logger.LogInformation($"Migrating '{documentFilePath}' from the '{miscellaneousProject.Key}' project to '{projectSnapshot.Key}' project."); - updater.DocumentAdded(defaultProject.Key, newHostDocument, textLoader); + updater.DocumentAdded(projectSnapshot.Key, newHostDocument, textLoader); } } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorProjectServiceTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorProjectServiceTest.cs index 49ea5dc39a1..a243c9644f5 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorProjectServiceTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorProjectServiceTest.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System; +using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -1137,13 +1138,77 @@ await _projectManager.UpdateAsync(updater => x => x.DocumentRemoved(DocumentFilePath2, miscProject.Key)); } - private static TextLoader CreateEmptyTextLoader() + [Fact] + public async Task AddProject_MigratesMiscellaneousDocumentsToNewOwnerProject_FixesTargetPath() + { + // Arrange + const string ProjectFilePath = "C:/path/to/project.csproj"; + const string IntermediateOutputPath = "C:/path/to/obj"; + const string DocumentFilePath1 = "C:/path/to/document1.cshtml"; + const string DocumentFilePath2 = "C:/path/to/document2.cshtml"; + + var miscProject = _snapshotResolver.GetMiscellaneousProject(); + + await _projectManager.UpdateAsync(updater => + { + updater.DocumentAdded(miscProject.Key, + new HostDocument(DocumentFilePath1, "C:/path/to/document1.cshtml"), CreateEmptyTextLoader()); + updater.DocumentAdded(miscProject.Key, + new HostDocument(DocumentFilePath2, "C:/path/to/document2.cshtml"), CreateEmptyTextLoader()); + }); + + // Act + var newProjectKey = await _projectService.AddProjectAsync( + ProjectFilePath, IntermediateOutputPath, RazorConfiguration.Default, rootNamespace: null, displayName: null, DisposalToken); + + // Assert + var newProject = _projectManager.GetLoadedProject(newProjectKey); + Assert.Equal("document1.cshtml", newProject.GetDocument(DocumentFilePath1)!.TargetPath); + Assert.Equal("document2.cshtml", newProject.GetDocument(DocumentFilePath2)!.TargetPath); + } + + [Fact] + public async Task AddOrUpdateProjectAsync_MigratesMiscellaneousDocumentsToNewOwnerProject_MaintainsTextState() + { + // Arrange + const string ProjectFilePath = "C:/path/to/project.csproj"; + const string IntermediateOutputPath = "C:/path/to/obj"; + const string DocumentFilePath1 = "C:/path/to/document1.cshtml"; + + var miscProject = _snapshotResolver.GetMiscellaneousProject(); + + await _projectManager.UpdateAsync(updater => + { + updater.DocumentAdded(miscProject.Key, + new HostDocument(DocumentFilePath1, "other/document1.cshtml"), CreateTextLoader(SourceText.From("Hello"))); + }); + + var projectKey = new ProjectKey(IntermediateOutputPath); + + var documentHandles = ImmutableArray.Create(new DocumentSnapshotHandle(DocumentFilePath1, "document1.cshtml", "mvc")); + + // Act + await _projectService.AddOrUpdateProjectAsync( + projectKey, ProjectFilePath, RazorConfiguration.Default, rootNamespace: null, displayName: null, ProjectWorkspaceState.Default, documentHandles, DisposalToken); + + // Assert + var newProject = _projectManager.GetLoadedProject(projectKey); + var documentText = await newProject.GetDocument(DocumentFilePath1)!.GetTextAsync(); + Assert.Equal("Hello", documentText.ToString()); + } + + private static TextLoader CreateTextLoader(SourceText text) { var textLoaderMock = new StrictMock(); textLoaderMock .Setup(x => x.LoadTextAndVersionAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(TextAndVersion.Create(s_emptyText, VersionStamp.Create())); + .ReturnsAsync(TextAndVersion.Create(text, VersionStamp.Create())); return textLoaderMock.Object; } + + private static TextLoader CreateEmptyTextLoader() + { + return CreateTextLoader(s_emptyText); + } }