File: Client\RemoteLanguageServiceWorkspace.cs
Web Access
Project: src\src\VisualStudio\LiveShare\Impl\Microsoft.VisualStudio.LanguageServices.LiveShare.csproj (Microsoft.VisualStudio.LanguageServices.LiveShare)
// 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 file in the project root for more information.
 
using System;
using System.Collections.Immutable;
using System.ComponentModel.Composition;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.LanguageServer;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Composition;
using Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem;
using Microsoft.VisualStudio.LanguageServices.LiveShare.Client.Projects;
using Microsoft.VisualStudio.LiveShare;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
using Microsoft.VisualStudio.Shell.TableManager;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Workspace.VSIntegration.Contracts;
using Roslyn.Utilities;
using LSP = Roslyn.LanguageServer.Protocol;
using Task = System.Threading.Tasks.Task;
 
namespace Microsoft.VisualStudio.LanguageServices.LiveShare.Client
{
    /// <summary>
    /// A Roslyn workspace that contains projects that exist on a remote machine.
    /// </summary>
    [Export(typeof(RemoteLanguageServiceWorkspace))]
    internal sealed class RemoteLanguageServiceWorkspace : CodeAnalysis.Workspace, IDisposable, IOpenTextBufferEventListener
    {
        /// <summary>
        /// Gate to make sure we only update the paths and trigger RDT one at a time.
        /// Guards <see cref="_remoteWorkspaceRootPaths"/> and <see cref="_registeredExternalPaths"/>
        /// </summary>
        // Our usage of SemaphoreSlim is fine.  We don't perform blocking waits for it on the UI thread.
#pragma warning disable RS0030 // Do not use banned APIs
        private static readonly SemaphoreSlim s_RemotePathsGate = new SemaphoreSlim(initialCount: 1);
#pragma warning restore RS0030 // Do not use banned APIs
 
        private readonly IServiceProvider _serviceProvider;
        private readonly IThreadingContext _threadingContext;
        private readonly OpenTextBufferProvider _openTextBufferProvider;
        private readonly IVsFolderWorkspaceService _vsFolderWorkspaceService;
 
        private const string ExternalProjectName = "ExternalDocuments";
 
        // A collection of opened documents in RDT, indexed by the moniker of the document.
        private ImmutableDictionary<string, DocumentId> _openedDocs = ImmutableDictionary<string, DocumentId>.Empty;
 
        private CollaborationSession? _session;
 
        /// <summary>
        /// Stores the current base folder path(s) on the client that hold files retrieved from the host workspace(s).
        /// </summary>
        private ImmutableHashSet<string> _remoteWorkspaceRootPaths;
 
        /// <summary>
        /// Stores the current base folder path(s) on the client that holds registered external files.
        /// </summary>
        private ImmutableHashSet<string> _registeredExternalPaths;
 
        public bool IsRemoteSession => _session != null;
 
        /// <summary>
        /// Initializes a new instance of the <see cref="RemoteLanguageServiceWorkspace"/> class.
        /// </summary>
        [ImportingConstructor]
        [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
        public RemoteLanguageServiceWorkspace(
            ExportProvider exportProvider,
            OpenTextBufferProvider openTextBufferProvider,
            IVsFolderWorkspaceService vsFolderWorkspaceService,
            SVsServiceProvider serviceProvider,
            ITableManagerProvider tableManagerProvider,
            IThreadingContext threadingContext)
            : base(VisualStudioMefHostServices.Create(exportProvider), WorkspaceKind.CloudEnvironmentClientWorkspace)
        {
            _serviceProvider = serviceProvider;
 
            _openTextBufferProvider = openTextBufferProvider;
            _openTextBufferProvider.AddListener(this);
            _threadingContext = threadingContext;
            _vsFolderWorkspaceService = vsFolderWorkspaceService;
 
            _remoteWorkspaceRootPaths = ImmutableHashSet<string>.Empty;
            _registeredExternalPaths = ImmutableHashSet<string>.Empty;
        }
 
        void IOpenTextBufferEventListener.OnOpenDocument(string moniker, ITextBuffer textBuffer, IVsHierarchy? hierarchy) => NotifyOnDocumentOpened(moniker, textBuffer);
 
        void IOpenTextBufferEventListener.OnCloseDocument(string moniker) => NotifyOnDocumentClosing(moniker);
 
        void IOpenTextBufferEventListener.OnRefreshDocumentContext(string moniker, IVsHierarchy hierarchy)
        {
            // Handled by Add/Remove
        }
 
        void IOpenTextBufferEventListener.OnRenameDocument(string newMoniker, string oldMoniker, ITextBuffer textBuffer)
        {
            // Handled by Add/Remove.
        }
 
        void IOpenTextBufferEventListener.OnDocumentOpenedIntoWindowFrame(string moniker, IVsWindowFrame windowFrame) { }
 
        void IOpenTextBufferEventListener.OnSaveDocument(string moniker) { }
 
        public async Task SetSessionAsync(CollaborationSession session)
        {
            _session = session;
 
            // Get the initial workspace roots and update any files that have been opened.
            await UpdatePathsToRemoteFilesAsync(session).ConfigureAwait(false);
 
            _vsFolderWorkspaceService.OnActiveWorkspaceChanged += OnActiveWorkspaceChangedAsync;
        }
 
        public string? GetRemoteExternalRoot(string filePath)
            => _registeredExternalPaths.SingleOrDefault(externalPath => filePath.StartsWith(externalPath));
 
        public string? GetRemoteWorkspaceRoot(string filePath)
            => _remoteWorkspaceRootPaths.SingleOrDefault(remoteWorkspaceRoot => filePath.StartsWith(remoteWorkspaceRoot));
 
        /// <summary>
        /// Event that gets triggered whenever the active workspace changes.  If we're in a live share session
        /// this means that the remote workpace roots have also changed and need to be updated.
        /// This will not be called concurrently.
        /// </summary>
        private async Task OnActiveWorkspaceChangedAsync(object? sender, EventArgs args)
        {
            if (IsRemoteSession)
            {
                await UpdatePathsToRemoteFilesAsync(_session!).ConfigureAwait(false);
            }
        }
 
        /// <summary>
        /// Retrieves the base folder paths for files on the client that have been retrieved from the remote host.
        /// Triggers a refresh of all open files so we make sure they are in the correct workspace.
        /// </summary>
        private async Task UpdatePathsToRemoteFilesAsync(CollaborationSession session)
        {
            var (remoteRootPaths, externalPaths) = await GetLocalPathsOfRemoteRootsAsync(session).ConfigureAwait(false);
 
            // Make sure we update our references to the remote roots and iterate RDT only one at a time.
            using (await s_RemotePathsGate.DisposableWaitAsync(CancellationToken.None).ConfigureAwait(false))
            {
                if (IsRemoteSession && (!_remoteWorkspaceRootPaths.Equals(remoteRootPaths) || !_registeredExternalPaths.Equals(externalPaths)))
                {
                    _remoteWorkspaceRootPaths = remoteRootPaths;
                    _registeredExternalPaths = externalPaths;
                    await RefreshAllFilesAsync().ConfigureAwait(false);
                }
            }
        }
 
        private static async Task<(ImmutableHashSet<string> remoteRootPaths, ImmutableHashSet<string> externalPaths)> GetLocalPathsOfRemoteRootsAsync(CollaborationSession session)
        {
            var roots = await session.ListRootsAsync(CancellationToken.None).ConfigureAwait(false);
            var localPathsOfRemoteRoots = roots.Select(root => session.ConvertSharedUriToLocalPath(root)).ToImmutableArray();
 
            var remoteRootPaths = ImmutableHashSet.CreateBuilder<string>();
            var externalPaths = ImmutableHashSet.CreateBuilder<string>();
 
            foreach (var localRoot in localPathsOfRemoteRoots)
            {
                // The local root is something like tmp\\xxx\\<workspace name>
                // The external root should be tmp\\xxx\\~external, so replace the workspace name with ~external.
#pragma warning disable CS8602 // Dereference of a possibly null reference. (Can localRoot be null here?)
                var splitRoot = localRoot.TrimEnd('\\').Split('\\');
#pragma warning restore CS8602 // Dereference of a possibly null reference.
                splitRoot[^1] = "~external";
                var externalPath = string.Join("\\", splitRoot) + "\\";
 
                remoteRootPaths.Add(localRoot);
                externalPaths.Add(externalPath);
            }
 
            return (remoteRootPaths.ToImmutable(), externalPaths.ToImmutable());
        }
 
        public void EndSession()
        {
            _session = null;
            _vsFolderWorkspaceService.OnActiveWorkspaceChanged -= OnActiveWorkspaceChangedAsync;
 
            // Clear the remote paths on end of session.  Live share handles closing all the files.
            using (s_RemotePathsGate.DisposableWait())
            {
                _remoteWorkspaceRootPaths = ImmutableHashSet<string>.Empty;
                _registeredExternalPaths = ImmutableHashSet<string>.Empty;
            }
        }
 
        /// <inheritdoc />
        public override bool CanOpenDocuments => true;
 
        /// <inheritdoc />
        public void NotifyOnDocumentOpened(string moniker, ITextBuffer textBuffer)
        {
            if (_openedDocs.ContainsKey(moniker))
            {
                return;
            }
 
            var document = GetOrAddDocument(moniker);
 
            if (document != null)
            {
                var textContainer = textBuffer.AsTextContainer();
                OnDocumentOpened(document.Id, textContainer);
                _openedDocs = _openedDocs.SetItem(moniker, document.Id);
            }
        }
 
        /// <summary>
        /// Iterates through the RDT and re-opens any files that are present.
        /// Used to update opened files after remote workspace roots change.
        /// </summary>
        public async Task RefreshAllFilesAsync()
        {
            await _threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(CancellationToken.None);
            var documents = _openTextBufferProvider.EnumerateDocumentSet();
            foreach (var (moniker, textBuffer, _) in documents)
            {
                NotifyOnDocumentOpened(moniker, textBuffer);
            }
        }
 
        public Document? GetOrAddDocument(string filePath)
        {
            var docId = CurrentSolution.GetDocumentIdsWithFilePath(filePath).FirstOrDefault();
            if (docId != null)
            {
                return CurrentSolution.GetDocument(docId);
            }
 
            if (!IsRemoteSession)
            {
                return null;
            }
 
            var language = GetLanguage(filePath);
            // Unsupported language.
            if (language == null)
            {
                return null;
            }
 
            // If the document is within the joined folder or it's a registered external file,
            // add it to the workspace, otherwise bail out.
            var remoteWorkspaceRoot = GetRemoteWorkspaceRoot(filePath);
            var remoteExternalRoot = GetRemoteExternalRoot(filePath);
            if (!string.IsNullOrEmpty(remoteWorkspaceRoot))
            {
                return AddDocumentToProject(filePath, language, Path.GetFileName(Path.GetDirectoryName(remoteWorkspaceRoot)));
            }
            else if (!string.IsNullOrEmpty(remoteExternalRoot))
            {
                return AddDocumentToProject(filePath, language, Path.GetFileName(Path.GetDirectoryName(remoteExternalRoot)));
            }
            else
            {
                return null;
            }
        }
 
        public Document? GetOrAddExternalDocument(string filePath, string language)
        {
            var docId = CurrentSolution.GetDocumentIdsWithFilePath(filePath).FirstOrDefault();
            if (docId != null)
            {
                return CurrentSolution.GetDocument(docId);
            }
 
            return AddDocumentToProject(filePath, language, ExternalProjectName);
        }
 
        public async Task<DocumentSpan?> GetDocumentSpanFromLocationAsync(LSP.Location location, CancellationToken cancellationToken)
        {
            var document = GetOrAddDocument(location.Uri.LocalPath);
            if (document == null)
            {
                return null;
            }
 
            var text = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
 
            // The protocol converter would have synced the file to disk but we the document snapshot that was in the workspace before the sync would have empty text.
            // So we need to read from disk in order to map from line\column to a textspan.
            if (string.IsNullOrEmpty(text.ToString()))
            {
                text = SourceText.From(File.ReadAllText(document.FilePath));
 
                // Some features like the FindRefs window try to get the text at the span without opening the document (for eg to classify the span).
                // So fork the document to get one with the text. Note that this new document will not be in the CurrentSolution and we don't intend to
                // apply it back. By fetching the file, the workspace will get updated anyway. The assumption here is that this document that we are
                // handing out is only used for simple inspection and it's version is never compared with the Workspace.CurrentSolution.
                document = document.WithText(text);
            }
 
            var textSpan = ProtocolConversions.RangeToTextSpan(location.Range, text);
            return new DocumentSpan(document, textSpan);
        }
 
        private Document AddDocumentToProject(string filePath, string language, string projectName)
        {
            var project = CurrentSolution.Projects.FirstOrDefault(p => p.Name == projectName && p.Language == language);
            if (project == null)
            {
                var projectInfo = ProjectInfo.Create(
                    new ProjectInfo.ProjectAttributes(
                        ProjectId.CreateNewId(),
                        VersionStamp.Create(),
                        name: projectName,
                        assemblyName: projectName,
                        language,
                        compilationOutputInfo: default,
                        checksumAlgorithm: SourceHashAlgorithms.Default));
 
                OnProjectAdded(projectInfo);
                project = CurrentSolution.GetRequiredProject(projectInfo.Id);
            }
 
            var docInfo = DocumentInfo.Create(
                DocumentId.CreateNewId(project.Id),
                name: Path.GetFileName(filePath),
                loader: new WorkspaceFileTextLoader(Services.SolutionServices, filePath, defaultEncoding: null),
                filePath: filePath);
 
            OnDocumentAdded(docInfo);
            return CurrentSolution.GetRequiredDocument(docInfo.Id);
        }
 
        private static string? GetLanguage(string filePath)
        {
            var fileExtension = Path.GetExtension(filePath).ToLower();
 
            if (fileExtension == ".cs")
            {
                return LanguageNames.CSharp;
            }
            else if (fileExtension is ".ts" or ".js")
            {
                return StringConstants.TypeScriptLanguageName;
            }
            else if (fileExtension == ".vb")
            {
                return LanguageNames.VisualBasic;
            }
 
            return null;
        }
 
        /// <inheritdoc />
        public void NotifyOnDocumentClosing(string moniker)
        {
            if (_openedDocs.TryGetValue(moniker, out var id))
            {
                // check if the doc is part of the current Roslyn workspace before notifying Roslyn.
                if (CurrentSolution.ContainsProject(id.ProjectId))
                {
                    OnDocumentClosed(id, new WorkspaceFileTextLoaderNoException(Services.SolutionServices, moniker, defaultEncoding: null));
                    _openedDocs = _openedDocs.Remove(moniker);
                }
            }
        }
 
        /// <inheritdoc />
        public override void OpenDocument(DocumentId documentId, bool activate = true)
        {
            if (_session == null)
            {
                return;
            }
 
            var doc = CurrentSolution.GetDocument(documentId);
            if (doc != null && doc.FilePath != null)
            {
                var svc = _serviceProvider.GetService(typeof(SVsUIShellOpenDocument)) as IVsUIShellOpenDocument;
                Report.IfNotPresent(svc);
                if (svc == null)
                {
                    return;
                }
 
                _threadingContext.JoinableTaskFactory.Run(async () =>
                {
#pragma warning disable CS8604 // Possible null reference argument. (Can ConvertLocalPathToSharedUri return null here?)
                    await _session.DownloadFileAsync(_session.ConvertLocalPathToSharedUri(doc.FilePath), CancellationToken.None).ConfigureAwait(true);
#pragma warning restore CS8604 // Possible null reference argument.
                });
 
                var logicalView = Guid.Empty;
                if (ErrorHandler.Succeeded(svc.OpenDocumentViaProject(doc.FilePath,
                                                                      ref logicalView,
                                                                      out var sp,
                                                                      out var hier,
                                                                      out var itemid,
                                                                      out var frame))
                    && frame != null)
                {
                    if (activate)
                    {
                        frame.Show();
                    }
                    else
                    {
                        frame.ShowNoActivate();
                    }
 
                    if (_openTextBufferProvider.IsFileOpen(doc.FilePath) && _openTextBufferProvider.TryGetBufferFromFilePath(doc.FilePath, out var buffer))
                    {
                        NotifyOnDocumentOpened(doc.FilePath, buffer);
                    }
                }
            }
        }
 
        /// <inheritdoc />
        public override bool CanApplyChange(ApplyChangesKind feature)
        {
            switch (feature)
            {
                case ApplyChangesKind.ChangeDocument:
                case ApplyChangesKind.AddDocument:
                case ApplyChangesKind.RemoveDocument:
                    return true;
 
                default:
                    return false;
            }
        }
 
        /// <inheritdoc />
        public void AddOpenedDocument(string filePath, DocumentId docId)
        {
            if (_openTextBufferProvider.IsFileOpen(filePath))
            {
                _openedDocs = _openedDocs.SetItem(filePath, docId);
            }
        }
 
        /// <inheritdoc />
        public void RemoveOpenedDocument(string filePath)
        {
            if (_openTextBufferProvider.IsFileOpen(filePath))
            {
                _openedDocs = _openedDocs.Remove(filePath);
            }
        }
 
        /// <inheritdoc/>
        protected override void Dispose(bool disposing)
            => base.Dispose(disposing);
 
        /// <summary>
        /// Marker class to easily group error reporting for missing live share text buffers.
        /// </summary>
        private class LiveShareTextBufferMissingException : Exception
        {
        }
 
        /// <inheritdoc />
        protected override void ApplyDocumentTextChanged(DocumentId documentId, SourceText text)
        {
            var document = CurrentSolution.GetDocument(documentId);
            if (document != null)
            {
                if (_openedDocs.Values.Contains(documentId) || IsDocumentOpen(documentId))
                {
                    var sourceText = document.GetTextSynchronously(CancellationToken.None);
                    var textContainer = sourceText.Container;
                    var textBuffer = textContainer.TryGetTextBuffer();
 
                    if (textBuffer == null)
                    {
                        // Text buffer is missing for opened Live Share document.
                        FatalError.ReportAndCatch(new LiveShareTextBufferMissingException());
                        return;
                    }
 
                    UpdateText(textBuffer, text);
                }
                else
                {
                    // The edits would get sent by the co-authoring service to the owner.
                    // The invisible editor saves the file on being disposed, which should get reflected  on the owner's side.
                    using var invisibleEditor = new InvisibleEditor(_serviceProvider, document.FilePath!, hierarchy: null,
                                                 needsSave: true, needsUndoDisabled: false);
                    UpdateText(invisibleEditor.TextBuffer, text);
                }
            }
        }
 
        private static void UpdateText(ITextBuffer textBuffer, SourceText text)
        {
            using var edit = textBuffer.CreateEdit(EditOptions.DefaultMinimalChange, reiteratedVersionNumber: null, editTag: null);
            var oldSnapshot = textBuffer.CurrentSnapshot;
            var oldText = oldSnapshot.AsText();
            var changes = text.GetTextChanges(oldText);
 
            foreach (var change in changes)
            {
                edit.Replace(change.Span.Start, change.Span.Length, change.NewText);
            }
 
            edit.Apply();
        }
    }
}