diff --git a/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs b/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs index e1e0824cb..f3c40d74a 100644 --- a/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs +++ b/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs @@ -28,6 +28,11 @@ public sealed class EditorWorkspace /// public string[] Paths => editorOperations.GetWorkspacePaths(); + /// + /// Get all currently open documents in the workspace. + /// + public WorkspaceOpenDocument[] Documents => editorOperations.GetWorkspaceOpenDocuments(); + #endregion #region Constructors @@ -76,6 +81,7 @@ public sealed class EditorWorkspace /// The path to the file to be closed. [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] public void CloseFile(string filePath) => editorOperations.CloseFileAsync(filePath).Wait(); + public void CloseFile(WorkspaceOpenDocument document) => CloseFile(document.Path); /// /// Saves an open file in the workspace. @@ -83,6 +89,7 @@ public sealed class EditorWorkspace /// The path to the file to be saved. [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] public void SaveFile(string filePath) => editorOperations.SaveFileAsync(filePath).Wait(); + public void SaveFile(WorkspaceOpenDocument document) => SaveFile(document.Path); /// /// Saves a file with a new name AKA a copy. diff --git a/src/PowerShellEditorServices/Extensions/IEditorOperations.cs b/src/PowerShellEditorServices/Extensions/IEditorOperations.cs index 3ec33ebc6..7fb39b22c 100644 --- a/src/PowerShellEditorServices/Extensions/IEditorOperations.cs +++ b/src/PowerShellEditorServices/Extensions/IEditorOperations.cs @@ -3,9 +3,34 @@ using System.Threading.Tasks; using Microsoft.PowerShell.EditorServices.Services.TextDocument; +#nullable enable namespace Microsoft.PowerShell.EditorServices.Extensions { + public readonly struct WorkspaceOpenDocument(string path, bool saved) + { + /// + /// Gets the path or URI of the open document. + /// + public string Path { get; } = path; + + /// + /// Gets whether the document is backed by a saved file path (not in-memory). + /// + public bool Saved { get; } = saved; + + /// + /// Gets the display name of this document and unsaved status. + /// + /// The display name of this document. + public override string ToString() + { + string documentPath = Path ?? string.Empty; + string fileName = System.IO.Path.GetFileName(documentPath); + return Saved ? fileName : fileName + " [Unsaved]"; + } + } + /// /// Provides an interface that must be implemented by an editor /// host to perform operations invoked by extensions written in @@ -32,6 +57,12 @@ internal interface IEditorOperations /// string[] GetWorkspacePaths(); + /// + /// Get all open documents in the current workspace session. + /// + /// All currently open documents. + WorkspaceOpenDocument[] GetWorkspaceOpenDocuments(); + /// /// Resolves the given file path relative to the current workspace path. /// diff --git a/src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs b/src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs index 607d38720..9c3e0c4ff 100644 --- a/src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs +++ b/src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs @@ -198,6 +198,14 @@ public async Task SaveFileAsync(string currentPath, string newSavePath) public string[] GetWorkspacePaths() => _workspaceService.WorkspacePaths.ToArray(); + public WorkspaceOpenDocument[] GetWorkspaceOpenDocuments() + => [.. + _workspaceService + .GetOpenedFiles() + .Where(static scriptFile => scriptFile.IsOpen) + .Select(static scriptFile => new WorkspaceOpenDocument(scriptFile.FilePath, !scriptFile.IsInMemory)) + ]; + public string GetWorkspaceRelativePath(ScriptFile scriptFile) => _workspaceService.GetRelativePath(scriptFile); public async Task ShowInformationMessageAsync(string message) diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs index c3d7a39c5..8dbb8f798 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs @@ -366,7 +366,7 @@ private CompletionItem CreateProviderItemCompletion( if (textToBeReplaced.IndexOf(PSScriptRootVariable, StringComparison.OrdinalIgnoreCase) is int variableIndex and not -1 && System.IO.Path.GetDirectoryName(scriptFile.FilePath) is string scriptFolder and not "" && completionText.IndexOf(scriptFolder, StringComparison.OrdinalIgnoreCase) is int pathIndex and not -1 - && !scriptFile.IsInMemory) + && !scriptFile.IsUntitled) { completionText = completionText .Remove(pathIndex, scriptFolder.Length) diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/TextDocumentHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/TextDocumentHandler.cs index a02e7b884..ba2e9242b 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/TextDocumentHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/TextDocumentHandler.cs @@ -111,10 +111,11 @@ public override Task Handle(DidCloseTextDocumentParams notification, Cance { fileToClose.IsOpen = false; - // If the file watcher is supported, only close in-memory files when this + // If the file watcher is supported, only close non-file-backed documents when this // notification is triggered. This lets us keep workspace files open so we can scan // for references. When a file is deleted, the file watcher will close the file. - if (!_isFileWatcherSupported || fileToClose.IsInMemory) + bool isBackedByFile = !fileToClose.IsUntitled; + if (!_isFileWatcherSupported || !isBackedByFile) { _workspaceService.CloseFile(fileToClose); } @@ -132,6 +133,9 @@ public override async Task Handle(DidSaveTextDocumentParams notification, if (savedFile != null) { + // On a save, untitled files will remain in memory, so this won't change for those + savedFile.IsInMemory = savedFile.IsUntitled; + if (_remoteFileManagerService.IsUnderRemoteTempPath(savedFile.FilePath)) { await _remoteFileManagerService.SaveRemoteFileAsync(savedFile.FilePath).ConfigureAwait(false); diff --git a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs index fb6772d2b..9cff31d41 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs @@ -51,9 +51,15 @@ internal sealed class ScriptFile /// /// Gets a boolean that determines whether this file is - /// in-memory or not (either unsaved or non-file content). + /// in-memory or not (either unsaved or non-file content) aka "dirty" /// - public bool IsInMemory { get; } + public bool IsInMemory { get; internal set; } + + /// + /// Gets a value indicating whether the document URI is not a file:// URI + /// (for example, an untitled: URI). + /// + public bool IsUntitled => !DocumentUri.ToUri().IsFile; /// /// Gets a string containing the full contents of the file. @@ -105,6 +111,9 @@ public Token[] ScriptTokens internal ReferenceTable References { get; } + /// + /// Indicates whether the file is currently open in the editor. PSES may open files for analysis that aren't actually visible in the editor. + /// internal bool IsOpen { get; set; } #endregion @@ -127,11 +136,15 @@ internal ScriptFile( // so that other operations know it's untitled/in-memory // and don't think that it's a relative path // on the file system. - IsInMemory = !docUri.ToUri().IsFile; + DocumentUri = docUri; + + // Initial state of document. Untitled files are in memory by definition, otherwise files start non-dirty on a filesystem + IsInMemory = IsUntitled; + FilePath = IsInMemory ? docUri.ToString() : docUri.GetFileSystemPath(); - DocumentUri = docUri; + IsAnalysisEnabled = true; this.powerShellVersion = powerShellVersion; @@ -365,6 +378,9 @@ public void ApplyChange(FileChange fileChange) // Parse the script again to be up-to-date ParseFileContents(); References.TagAsChanged(); + + // Flag the script as modified + IsInMemory = true; } /// diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index efd7f82d7..9b721387a 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -302,7 +302,14 @@ public void CloseFile(ScriptFile scriptFile) Validate.IsNotNull(nameof(scriptFile), scriptFile); string keyName = GetFileKey(scriptFile.DocumentUri); - workspaceFiles.TryRemove(keyName, out ScriptFile _); + if (workspaceFiles.TryRemove(keyName, out ScriptFile _)) + { + logger.LogDebug("Closed file: " + scriptFile.DocumentUri); + } + else + { + logger.LogWarning("Tried to close file that was not open: " + scriptFile.DocumentUri); + } } /// @@ -312,7 +319,7 @@ public void CloseFile(ScriptFile scriptFile) public string GetRelativePath(ScriptFile scriptFile) { Uri fileUri = scriptFile.DocumentUri.ToUri(); - if (!scriptFile.IsInMemory) + if (!scriptFile.IsUntitled) { // Support calculating out-of-workspace relative paths in the common case of a // single workspace folder. Otherwise try to get the matching folder. diff --git a/test/PowerShellEditorServices.Test/Extensions/EditorOperationsServiceTests.cs b/test/PowerShellEditorServices.Test/Extensions/EditorOperationsServiceTests.cs new file mode 100644 index 000000000..45ef4e538 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Extensions/EditorOperationsServiceTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerShell.EditorServices.Extensions; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Extension; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol; +using Xunit; + +namespace PowerShellEditorServices.Test.Extensions +{ + [Trait("Category", "Extensions")] + public class EditorOperationsServiceTests + { + [Fact] + public void GetWorkspaceOpenDocumentsReturnsOnlyOpenDocumentsAndCurrentInMemoryState() + { + WorkspaceService workspaceService = new(NullLoggerFactory.Instance); + + ScriptFile openSaved = CreateFileBuffer(workspaceService, "open-saved.ps1"); + openSaved.IsOpen = true; + openSaved.IsInMemory = false; + + ScriptFile openUnsaved = CreateFileBuffer(workspaceService, "open-unsaved.ps1"); + openUnsaved.IsOpen = true; + openUnsaved.IsInMemory = true; + + ScriptFile closed = CreateFileBuffer(workspaceService, "closed.ps1"); + closed.IsOpen = false; + closed.IsInMemory = false; + + EditorOperationsService editorOperationsService = new( + psesHost: null, + workspaceService, + languageServer: null); + + WorkspaceOpenDocument[] documents = editorOperationsService.GetWorkspaceOpenDocuments(); + + Assert.Equal(2, documents.Length); + Assert.Contains(documents, static document => document.Path.EndsWith("open-saved.ps1") && document.Saved); + Assert.Contains(documents, static document => document.Path.EndsWith("open-unsaved.ps1") && !document.Saved); + Assert.DoesNotContain(documents, static document => document.Path.EndsWith("closed.ps1")); + } + + [Fact] + public void GetWorkspaceOpenDocumentsTracksEditedAndUntitledSaveStates() + { + WorkspaceService workspaceService = new(NullLoggerFactory.Instance); + + ScriptFile openSaved = CreateFileBuffer(workspaceService, "open-saved.ps1"); + openSaved.IsOpen = true; + + ScriptFile openUntitled = workspaceService.GetFileBuffer("untitled:Untitled-1", initialBuffer: string.Empty); + openUntitled.IsOpen = true; + + EditorOperationsService editorOperationsService = new( + psesHost: null, + workspaceService, + languageServer: null); + + WorkspaceOpenDocument[] initialDocuments = editorOperationsService.GetWorkspaceOpenDocuments(); + Assert.Contains(initialDocuments, static document => document.Path.EndsWith("open-saved.ps1") && document.Saved); + Assert.Contains(initialDocuments, static document => document.Path.StartsWith("untitled:", StringComparison.Ordinal) && !document.Saved); + + openSaved.ApplyChange(new FileChange + { + Line = 1, + Offset = 1, + EndLine = 1, + EndOffset = 1, + InsertString = "Set-StrictMode -Version Latest" + }); + Assert.Contains("Set-StrictMode -Version Latest", openSaved.Contents, StringComparison.Ordinal); + + WorkspaceOpenDocument[] editedDocuments = editorOperationsService.GetWorkspaceOpenDocuments(); + Assert.Contains(editedDocuments, static document => document.Path.EndsWith("open-saved.ps1") && !document.Saved); + + MarkAsSaved(openSaved); + MarkAsSaved(openUntitled); + + WorkspaceOpenDocument[] savedDocuments = editorOperationsService.GetWorkspaceOpenDocuments(); + Assert.Contains(savedDocuments, static document => document.Path.EndsWith("open-saved.ps1") && document.Saved); + Assert.Contains(savedDocuments, static document => document.Path.StartsWith("untitled:", StringComparison.Ordinal) && !document.Saved); + } + + private static ScriptFile CreateFileBuffer(WorkspaceService workspaceService, string fileName) + { + string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), fileName); + return workspaceService.GetFileBuffer(DocumentUri.FromFileSystemPath(filePath), initialBuffer: string.Empty); + } + + private static void MarkAsSaved(ScriptFile scriptFile) => scriptFile.IsInMemory = scriptFile.IsUntitled; + } +} diff --git a/test/PowerShellEditorServices.Test/Extensions/EditorWorkspaceTests.cs b/test/PowerShellEditorServices.Test/Extensions/EditorWorkspaceTests.cs new file mode 100644 index 000000000..673d864d5 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Extensions/EditorWorkspaceTests.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Extensions; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Xunit; + +namespace PowerShellEditorServices.Test.Extensions +{ + [Trait("Category", "Extensions")] + public class EditorWorkspaceTests + { + private static readonly string WorkspacePath = Path.Combine("test"); + + [Fact] + public void DocumentsReturnsOpenWorkspaceDocuments() + { + string firstPath = Path.Combine(WorkspacePath, "one.ps1"); + string secondPath = Path.Combine(WorkspacePath, "two.ps1"); + + TestEditorOperations editorOperations = new() + { + OpenDocuments = + [ + new WorkspaceOpenDocument(firstPath, saved: true), + new WorkspaceOpenDocument(secondPath, saved: true) + ] + }; + + EditorWorkspace workspace = new(editorOperations); + + WorkspaceOpenDocument[] documents = workspace.Documents; + + Assert.Collection( + documents, + document => + { + Assert.Equal(firstPath, document.Path); + Assert.True(document.Saved); + }, + document => + { + Assert.Equal(secondPath, document.Path); + Assert.True(document.Saved); + }); + } + + [Fact] + public void DocumentToStringReturnsFileNameAndSavedStatus() + { + string savedFilePath = Path.Combine(WorkspacePath, "file.ps1"); + string unsavedFilePath = Path.Combine(WorkspacePath, "other.ps1"); + TestEditorOperations editorOperations = new() + { + OpenDocuments = [ + new WorkspaceOpenDocument(savedFilePath, saved: true), + new WorkspaceOpenDocument(unsavedFilePath, saved: false) + ] + }; + + EditorWorkspace workspace = new(editorOperations); + IEnumerable documents = workspace.Documents; + + Assert.Collection( + documents, + document => Assert.Equal("file.ps1", document.ToString()), + document => Assert.Equal("other.ps1 [Unsaved]", document.ToString())); + } + + [Fact] + public void DocumentSavedReturnsWorkspaceSavedState() + { + TestEditorOperations editorOperations = new() + { + OpenDocuments = [ + new WorkspaceOpenDocument(Path.Combine(WorkspacePath, "saved.ps1"), saved: true), + new WorkspaceOpenDocument(Path.Combine(WorkspacePath, "unsaved.ps1"), saved: false) + ] + }; + + EditorWorkspace workspace = new(editorOperations); + IEnumerable documents = workspace.Documents; + + Assert.Collection( + documents, + document => Assert.True(document.Saved), + document => Assert.False(document.Saved)); + } + + private sealed class TestEditorOperations : IEditorOperations + { + public WorkspaceOpenDocument[] OpenDocuments { get; set; } = Array.Empty(); + + public List Calls { get; } = new(); + + public Task GetEditorContextAsync() => Task.FromResult(default(EditorContext)); + + public string GetWorkspacePath() => WorkspacePath; + + public string[] GetWorkspacePaths() => [WorkspacePath]; + + public WorkspaceOpenDocument[] GetWorkspaceOpenDocuments() => OpenDocuments; + + public string GetWorkspaceRelativePath(ScriptFile scriptFile) => scriptFile.FilePath; + + public Task NewFileAsync() => Task.CompletedTask; + + public Task NewFileAsync(string content) => Task.CompletedTask; + + public Task OpenFileAsync(string filePath) + { + Calls.Add("OpenFile:" + filePath); + return Task.CompletedTask; + } + + public Task OpenFileAsync(string filePath, bool preview) => Task.CompletedTask; + + public Task CloseFileAsync(string filePath) + { + Calls.Add("CloseFile:" + filePath); + return Task.CompletedTask; + } + + public Task SaveFileAsync(string filePath) + { + Calls.Add("SaveFile:" + filePath); + return Task.CompletedTask; + } + + public Task SaveFileAsync(string oldFilePath, string newFilePath) => Task.CompletedTask; + + public Task InsertTextAsync(string filePath, string insertText, BufferRange insertRange) => Task.CompletedTask; + + public Task SetSelectionAsync(BufferRange selectionRange) => Task.CompletedTask; + + public Task ShowInformationMessageAsync(string message) => Task.CompletedTask; + + public Task ShowErrorMessageAsync(string message) => Task.CompletedTask; + + public Task ShowWarningMessageAsync(string message) => Task.CompletedTask; + + public Task SetStatusBarMessageAsync(string message, int? timeout) => Task.CompletedTask; + + public void ClearTerminal() + { + } + } + } +} diff --git a/test/vim-simple-test.vim b/test/vim-simple-test.vim index fe0adda74..4a670b8db 100644 --- a/test/vim-simple-test.vim +++ b/test/vim-simple-test.vim @@ -1,6 +1,18 @@ let s:suite = themis#suite('pses') let s:assert = themis#helper('assert') +function s:wait_for_diagnostics(bufname, expected) + let l:attempts = 20 + while l:attempts > 0 + if getbufvar(a:bufname, 'LanguageClient_statusLineDiagnosticsCounts') == a:expected + return + endif + + execute 'sleep 500m' + let l:attempts -= 1 + endwhile +endfunction + function s:suite.before() let l:pses_path = g:repo_root . '/module' let g:LanguageClient_serverCommands = { @@ -33,7 +45,7 @@ function s:suite.analyzes_powershell_file() call s:assert.equal(getbufvar(l:bufinfo.bufnr, '&filetype'), 'ps1') execute 'LanguageClientStart' - execute 'sleep' 5 + call s:wait_for_diagnostics(l:bufinfo.name, {'E': 0, 'W': 1, 'H': 0, 'I': 0}) call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_isServerRunning'), 1) call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_projectRoot'), g:repo_root) call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_statusLineDiagnosticsCounts'), {'E': 0, 'W': 1, 'H': 0, 'I': 0}) diff --git a/test/vim-test.vim b/test/vim-test.vim index 3d94d174c..4fc5cfcd5 100644 --- a/test/vim-test.vim +++ b/test/vim-test.vim @@ -1,6 +1,18 @@ let s:suite = themis#suite('pses') let s:assert = themis#helper('assert') +function s:wait_for_diagnostics(bufname, expected) + let l:attempts = 20 + while l:attempts > 0 + if getbufvar(a:bufname, 'LanguageClient_statusLineDiagnosticsCounts') == a:expected + return + endif + + execute 'sleep 500m' + let l:attempts -= 1 + endwhile +endfunction + function s:suite.before() let l:pses_path = g:repo_root . '/module' let g:LanguageClient_serverCommands = { @@ -37,7 +49,7 @@ function s:suite.analyzes_powershell_file() call s:assert.equal(getbufvar(l:bufinfo.bufnr, '&filetype'), 'ps1') execute 'LanguageClientStart' - execute 'sleep' 5 + call s:wait_for_diagnostics(l:bufinfo.name, {'E': 0, 'W': 1, 'H': 0, 'I': 0}) call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_isServerRunning'), 1) call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_projectRoot'), g:repo_root) call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_statusLineDiagnosticsCounts'), {'E': 0, 'W': 1, 'H': 0, 'I': 0})