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})