Skip to content

Commit caa9d28

Browse files
authored
Expose open LSP documents via $psEditor.Workspace.Documents (#2285)
Exposes the open editor documents to the PowerShell extension terminal so that users can query, save, or close documents from PS, enabling scripting scenarios.
1 parent e2a0cea commit caa9d28

11 files changed

Lines changed: 359 additions & 11 deletions

File tree

src/PowerShellEditorServices/Extensions/EditorWorkspace.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ public sealed class EditorWorkspace
2828
/// </summary>
2929
public string[] Paths => editorOperations.GetWorkspacePaths();
3030

31+
/// <summary>
32+
/// Get all currently open documents in the workspace.
33+
/// </summary>
34+
public WorkspaceOpenDocument[] Documents => editorOperations.GetWorkspaceOpenDocuments();
35+
3136
#endregion
3237

3338
#region Constructors
@@ -76,13 +81,15 @@ public sealed class EditorWorkspace
7681
/// <param name="filePath">The path to the file to be closed.</param>
7782
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")]
7883
public void CloseFile(string filePath) => editorOperations.CloseFileAsync(filePath).Wait();
84+
public void CloseFile(WorkspaceOpenDocument document) => CloseFile(document.Path);
7985

8086
/// <summary>
8187
/// Saves an open file in the workspace.
8288
/// </summary>
8389
/// <param name="filePath">The path to the file to be saved.</param>
8490
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")]
8591
public void SaveFile(string filePath) => editorOperations.SaveFileAsync(filePath).Wait();
92+
public void SaveFile(WorkspaceOpenDocument document) => SaveFile(document.Path);
8693

8794
/// <summary>
8895
/// Saves a file with a new name AKA a copy.

src/PowerShellEditorServices/Extensions/IEditorOperations.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,34 @@
33

44
using System.Threading.Tasks;
55
using Microsoft.PowerShell.EditorServices.Services.TextDocument;
6+
#nullable enable
67

78
namespace Microsoft.PowerShell.EditorServices.Extensions
89
{
10+
public readonly struct WorkspaceOpenDocument(string path, bool saved)
11+
{
12+
/// <summary>
13+
/// Gets the path or URI of the open document.
14+
/// </summary>
15+
public string Path { get; } = path;
16+
17+
/// <summary>
18+
/// Gets whether the document is backed by a saved file path (not in-memory).
19+
/// </summary>
20+
public bool Saved { get; } = saved;
21+
22+
/// <summary>
23+
/// Gets the display name of this document and unsaved status.
24+
/// </summary>
25+
/// <returns>The display name of this document.</returns>
26+
public override string ToString()
27+
{
28+
string documentPath = Path ?? string.Empty;
29+
string fileName = System.IO.Path.GetFileName(documentPath);
30+
return Saved ? fileName : fileName + " [Unsaved]";
31+
}
32+
}
33+
934
/// <summary>
1035
/// Provides an interface that must be implemented by an editor
1136
/// host to perform operations invoked by extensions written in
@@ -32,6 +57,12 @@ internal interface IEditorOperations
3257
/// <returns></returns>
3358
string[] GetWorkspacePaths();
3459

60+
/// <summary>
61+
/// Get all open documents in the current workspace session.
62+
/// </summary>
63+
/// <returns>All currently open documents.</returns>
64+
WorkspaceOpenDocument[] GetWorkspaceOpenDocuments();
65+
3566
/// <summary>
3667
/// Resolves the given file path relative to the current workspace path.
3768
/// </summary>

src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,14 @@ public async Task SaveFileAsync(string currentPath, string newSavePath)
198198

199199
public string[] GetWorkspacePaths() => _workspaceService.WorkspacePaths.ToArray();
200200

201+
public WorkspaceOpenDocument[] GetWorkspaceOpenDocuments()
202+
=> [..
203+
_workspaceService
204+
.GetOpenedFiles()
205+
.Where(static scriptFile => scriptFile.IsOpen)
206+
.Select(static scriptFile => new WorkspaceOpenDocument(scriptFile.FilePath, !scriptFile.IsInMemory))
207+
];
208+
201209
public string GetWorkspaceRelativePath(ScriptFile scriptFile) => _workspaceService.GetRelativePath(scriptFile);
202210

203211
public async Task ShowInformationMessageAsync(string message)

src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ private CompletionItem CreateProviderItemCompletion(
366366
if (textToBeReplaced.IndexOf(PSScriptRootVariable, StringComparison.OrdinalIgnoreCase) is int variableIndex and not -1
367367
&& System.IO.Path.GetDirectoryName(scriptFile.FilePath) is string scriptFolder and not ""
368368
&& completionText.IndexOf(scriptFolder, StringComparison.OrdinalIgnoreCase) is int pathIndex and not -1
369-
&& !scriptFile.IsInMemory)
369+
&& !scriptFile.IsUntitled)
370370
{
371371
completionText = completionText
372372
.Remove(pathIndex, scriptFolder.Length)

src/PowerShellEditorServices/Services/TextDocument/Handlers/TextDocumentHandler.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,11 @@ public override Task<Unit> Handle(DidCloseTextDocumentParams notification, Cance
111111
{
112112
fileToClose.IsOpen = false;
113113

114-
// If the file watcher is supported, only close in-memory files when this
114+
// If the file watcher is supported, only close non-file-backed documents when this
115115
// notification is triggered. This lets us keep workspace files open so we can scan
116116
// for references. When a file is deleted, the file watcher will close the file.
117-
if (!_isFileWatcherSupported || fileToClose.IsInMemory)
117+
bool isBackedByFile = !fileToClose.IsUntitled;
118+
if (!_isFileWatcherSupported || !isBackedByFile)
118119
{
119120
_workspaceService.CloseFile(fileToClose);
120121
}
@@ -132,6 +133,9 @@ public override async Task<Unit> Handle(DidSaveTextDocumentParams notification,
132133

133134
if (savedFile != null)
134135
{
136+
// On a save, untitled files will remain in memory, so this won't change for those
137+
savedFile.IsInMemory = savedFile.IsUntitled;
138+
135139
if (_remoteFileManagerService.IsUnderRemoteTempPath(savedFile.FilePath))
136140
{
137141
await _remoteFileManagerService.SaveRemoteFileAsync(savedFile.FilePath).ConfigureAwait(false);

src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,15 @@ internal sealed class ScriptFile
5151

5252
/// <summary>
5353
/// Gets a boolean that determines whether this file is
54-
/// in-memory or not (either unsaved or non-file content).
54+
/// in-memory or not (either unsaved or non-file content) aka "dirty"
5555
/// </summary>
56-
public bool IsInMemory { get; }
56+
public bool IsInMemory { get; internal set; }
57+
58+
/// <summary>
59+
/// Gets a value indicating whether the document URI is not a <c>file://</c> URI
60+
/// (for example, an <c>untitled:</c> URI).
61+
/// </summary>
62+
public bool IsUntitled => !DocumentUri.ToUri().IsFile;
5763

5864
/// <summary>
5965
/// Gets a string containing the full contents of the file.
@@ -105,6 +111,9 @@ public Token[] ScriptTokens
105111

106112
internal ReferenceTable References { get; }
107113

114+
/// <summary>
115+
/// Indicates whether the file is currently open in the editor. PSES may open files for analysis that aren't actually visible in the editor.
116+
/// </summary>
108117
internal bool IsOpen { get; set; }
109118

110119
#endregion
@@ -127,11 +136,15 @@ internal ScriptFile(
127136
// so that other operations know it's untitled/in-memory
128137
// and don't think that it's a relative path
129138
// on the file system.
130-
IsInMemory = !docUri.ToUri().IsFile;
139+
DocumentUri = docUri;
140+
141+
// Initial state of document. Untitled files are in memory by definition, otherwise files start non-dirty on a filesystem
142+
IsInMemory = IsUntitled;
143+
131144
FilePath = IsInMemory
132145
? docUri.ToString()
133146
: docUri.GetFileSystemPath();
134-
DocumentUri = docUri;
147+
135148
IsAnalysisEnabled = true;
136149
this.powerShellVersion = powerShellVersion;
137150

@@ -365,6 +378,9 @@ public void ApplyChange(FileChange fileChange)
365378
// Parse the script again to be up-to-date
366379
ParseFileContents();
367380
References.TagAsChanged();
381+
382+
// Flag the script as modified
383+
IsInMemory = true;
368384
}
369385

370386
/// <summary>

src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,14 @@ public void CloseFile(ScriptFile scriptFile)
302302
Validate.IsNotNull(nameof(scriptFile), scriptFile);
303303

304304
string keyName = GetFileKey(scriptFile.DocumentUri);
305-
workspaceFiles.TryRemove(keyName, out ScriptFile _);
305+
if (workspaceFiles.TryRemove(keyName, out ScriptFile _))
306+
{
307+
logger.LogDebug("Closed file: " + scriptFile.DocumentUri);
308+
}
309+
else
310+
{
311+
logger.LogWarning("Tried to close file that was not open: " + scriptFile.DocumentUri);
312+
}
306313
}
307314

308315
/// <summary>
@@ -312,7 +319,7 @@ public void CloseFile(ScriptFile scriptFile)
312319
public string GetRelativePath(ScriptFile scriptFile)
313320
{
314321
Uri fileUri = scriptFile.DocumentUri.ToUri();
315-
if (!scriptFile.IsInMemory)
322+
if (!scriptFile.IsUntitled)
316323
{
317324
// Support calculating out-of-workspace relative paths in the common case of a
318325
// single workspace folder. Otherwise try to get the matching folder.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.IO;
6+
using Microsoft.Extensions.Logging.Abstractions;
7+
using Microsoft.PowerShell.EditorServices.Extensions;
8+
using Microsoft.PowerShell.EditorServices.Services;
9+
using Microsoft.PowerShell.EditorServices.Services.Extension;
10+
using Microsoft.PowerShell.EditorServices.Services.TextDocument;
11+
using OmniSharp.Extensions.LanguageServer.Protocol;
12+
using Xunit;
13+
14+
namespace PowerShellEditorServices.Test.Extensions
15+
{
16+
[Trait("Category", "Extensions")]
17+
public class EditorOperationsServiceTests
18+
{
19+
[Fact]
20+
public void GetWorkspaceOpenDocumentsReturnsOnlyOpenDocumentsAndCurrentInMemoryState()
21+
{
22+
WorkspaceService workspaceService = new(NullLoggerFactory.Instance);
23+
24+
ScriptFile openSaved = CreateFileBuffer(workspaceService, "open-saved.ps1");
25+
openSaved.IsOpen = true;
26+
openSaved.IsInMemory = false;
27+
28+
ScriptFile openUnsaved = CreateFileBuffer(workspaceService, "open-unsaved.ps1");
29+
openUnsaved.IsOpen = true;
30+
openUnsaved.IsInMemory = true;
31+
32+
ScriptFile closed = CreateFileBuffer(workspaceService, "closed.ps1");
33+
closed.IsOpen = false;
34+
closed.IsInMemory = false;
35+
36+
EditorOperationsService editorOperationsService = new(
37+
psesHost: null,
38+
workspaceService,
39+
languageServer: null);
40+
41+
WorkspaceOpenDocument[] documents = editorOperationsService.GetWorkspaceOpenDocuments();
42+
43+
Assert.Equal(2, documents.Length);
44+
Assert.Contains(documents, static document => document.Path.EndsWith("open-saved.ps1") && document.Saved);
45+
Assert.Contains(documents, static document => document.Path.EndsWith("open-unsaved.ps1") && !document.Saved);
46+
Assert.DoesNotContain(documents, static document => document.Path.EndsWith("closed.ps1"));
47+
}
48+
49+
[Fact]
50+
public void GetWorkspaceOpenDocumentsTracksEditedAndUntitledSaveStates()
51+
{
52+
WorkspaceService workspaceService = new(NullLoggerFactory.Instance);
53+
54+
ScriptFile openSaved = CreateFileBuffer(workspaceService, "open-saved.ps1");
55+
openSaved.IsOpen = true;
56+
57+
ScriptFile openUntitled = workspaceService.GetFileBuffer("untitled:Untitled-1", initialBuffer: string.Empty);
58+
openUntitled.IsOpen = true;
59+
60+
EditorOperationsService editorOperationsService = new(
61+
psesHost: null,
62+
workspaceService,
63+
languageServer: null);
64+
65+
WorkspaceOpenDocument[] initialDocuments = editorOperationsService.GetWorkspaceOpenDocuments();
66+
Assert.Contains(initialDocuments, static document => document.Path.EndsWith("open-saved.ps1") && document.Saved);
67+
Assert.Contains(initialDocuments, static document => document.Path.StartsWith("untitled:", StringComparison.Ordinal) && !document.Saved);
68+
69+
openSaved.ApplyChange(new FileChange
70+
{
71+
Line = 1,
72+
Offset = 1,
73+
EndLine = 1,
74+
EndOffset = 1,
75+
InsertString = "Set-StrictMode -Version Latest"
76+
});
77+
Assert.Contains("Set-StrictMode -Version Latest", openSaved.Contents, StringComparison.Ordinal);
78+
79+
WorkspaceOpenDocument[] editedDocuments = editorOperationsService.GetWorkspaceOpenDocuments();
80+
Assert.Contains(editedDocuments, static document => document.Path.EndsWith("open-saved.ps1") && !document.Saved);
81+
82+
MarkAsSaved(openSaved);
83+
MarkAsSaved(openUntitled);
84+
85+
WorkspaceOpenDocument[] savedDocuments = editorOperationsService.GetWorkspaceOpenDocuments();
86+
Assert.Contains(savedDocuments, static document => document.Path.EndsWith("open-saved.ps1") && document.Saved);
87+
Assert.Contains(savedDocuments, static document => document.Path.StartsWith("untitled:", StringComparison.Ordinal) && !document.Saved);
88+
}
89+
90+
private static ScriptFile CreateFileBuffer(WorkspaceService workspaceService, string fileName)
91+
{
92+
string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), fileName);
93+
return workspaceService.GetFileBuffer(DocumentUri.FromFileSystemPath(filePath), initialBuffer: string.Empty);
94+
}
95+
96+
private static void MarkAsSaved(ScriptFile scriptFile) => scriptFile.IsInMemory = scriptFile.IsUntitled;
97+
}
98+
}

0 commit comments

Comments
 (0)