Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/PowerShellEditorServices/Extensions/EditorWorkspace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public sealed class EditorWorkspace
/// </summary>
public string[] Paths => editorOperations.GetWorkspacePaths();

/// <summary>
/// Get all currently open documents in the workspace.
/// </summary>
public WorkspaceOpenDocument[] Documents => [.. editorOperations.GetWorkspaceOpenDocuments()];

#endregion

#region Constructors
Expand Down Expand Up @@ -76,13 +81,15 @@ public sealed class EditorWorkspace
/// <param name="filePath">The path to the file to be closed.</param>
[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);

/// <summary>
/// Saves an open file in the workspace.
/// </summary>
/// <param name="filePath">The path to the file to be saved.</param>
[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);

/// <summary>
/// Saves a file with a new name AKA a copy.
Expand Down
31 changes: 31 additions & 0 deletions src/PowerShellEditorServices/Extensions/IEditorOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
/// <summary>
/// Gets the path or URI of the open document.
/// </summary>
public string Path { get; } = path;

/// <summary>
/// Gets whether the document is backed by a saved file path (not in-memory).
/// </summary>
public bool Saved { get; } = saved;

/// <summary>
/// Gets the display name of this document and unsaved status.
/// </summary>
/// <returns>The display name of this document.</returns>
public override string ToString()
{
string documentPath = Path ?? string.Empty;
string fileName = System.IO.Path.GetFileName(documentPath);
return Saved ? fileName : fileName + " [Unsaved]";
}
}

/// <summary>
/// Provides an interface that must be implemented by an editor
/// host to perform operations invoked by extensions written in
Expand All @@ -32,6 +57,12 @@ internal interface IEditorOperations
/// <returns></returns>
string[] GetWorkspacePaths();

/// <summary>
/// Get all open documents in the current workspace session.
/// </summary>
/// <returns>All currently open documents.</returns>
WorkspaceOpenDocument[] GetWorkspaceOpenDocuments();

/// <summary>
/// Resolves the given file path relative to the current workspace path.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,11 @@ public override Task<Unit> 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 = notification.TextDocument.Uri.ToUri().IsFile;
if (!_isFileWatcherSupported || !isBackedByFile)
{
_workspaceService.CloseFile(fileToClose);
}
Expand All @@ -132,6 +133,9 @@ public override async Task<Unit> 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);
Expand Down
20 changes: 16 additions & 4 deletions src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ internal sealed class ScriptFile

/// <summary>
/// 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"
/// </summary>
public bool IsInMemory { get; }
public bool IsInMemory { get; internal set; }

/// <summary>
/// Getter that returns if the document is not backed by a saved file path (not in-memory).
/// </summary>
public bool IsUntitled => !DocumentUri?.ToUri().IsFile ?? false;

/// <summary>
/// Gets a string containing the full contents of the file.
Expand Down Expand Up @@ -127,11 +132,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;

Expand Down Expand Up @@ -365,6 +374,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;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// 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"));
}

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);
}
}
}
153 changes: 153 additions & 0 deletions test/PowerShellEditorServices.Test/Extensions/EditorWorkspaceTests.cs
Original file line number Diff line number Diff line change
@@ -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<WorkspaceOpenDocument> 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<WorkspaceOpenDocument> 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<WorkspaceOpenDocument>();

public List<string> Calls { get; } = new();

public Task<EditorContext> 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()
{
}
}
}
}
14 changes: 13 additions & 1 deletion test/vim-simple-test.vim
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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})
Expand Down
Loading
Loading