Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
06342e3
Initial plan
Copilot May 12, 2026
4db3d1d
Expose open workspace documents on PSEditor workspace API
Copilot May 12, 2026
ac47839
Add close operation to workspace documents API
Copilot May 12, 2026
c8721ba
Add ToString override for workspace documents
Copilot May 12, 2026
9b0f372
Add Saved status to workspace documents
Copilot May 12, 2026
9cbd9e6
Fix Saved property XML docs wording
Copilot May 12, 2026
8afd2a5
Fix ToString filename extraction for Windows paths
Copilot May 12, 2026
866c2aa
Harden ToString path handling for null and separators
Copilot May 12, 2026
b781fd1
Simplify methods
JustinGrote May 12, 2026
29ae10f
Use IsInMemory for workspace document saved state
Copilot May 12, 2026
a568cf4
Clarify Saved semantics as file-backed state
Copilot May 12, 2026
945736e
Remove unnecessary newline
JustinGrote May 12, 2026
9b19bc2
Fix EditorWorkspaceTests for IEnumerable Documents
Copilot May 12, 2026
b82b81e
Switch to array and collection expressions
JustinGrote May 12, 2026
dd0f79f
Make EditorWorkspaceTests OS Independent
JustinGrote May 12, 2026
38dd801
Fixup Open/Unsaved file identification
JustinGrote May 12, 2026
8220f53
Try to fix flaky vim test
JustinGrote May 12, 2026
2a0df44
Simplify API abstraction to just the existing WorkspaceOpenDocument
JustinGrote May 13, 2026
9d51063
Remove dangling using
JustinGrote May 13, 2026
cfeba0e
Fix Tests
JustinGrote May 13, 2026
9bba5bf
Additional comments and logging
JustinGrote May 13, 2026
25e2240
Simplify IsUntitled getter (even tho I'm pretty sure this will break …
JustinGrote May 13, 2026
7e2f5d4
Remove unnecessary collection expression
JustinGrote May 13, 2026
8a167cb
Fix file-backed checks to use IsUntitled
Copilot May 13, 2026
f14c10d
Avoid extra recompilation for IsUntitled
JustinGrote May 13, 2026
e105ee6
Potential fix for pull request finding
JustinGrote May 13, 2026
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);
Comment on lines 83 to +92

/// <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
{
Comment on lines 3 to 9
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]";
}
}
Comment on lines +10 to +32

/// <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 @@ -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)
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 = !fileToClose.IsUntitled;
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
24 changes: 20 additions & 4 deletions src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,15 @@ 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>
/// Gets a value indicating whether the document URI is not a <c>file://</c> URI
/// (for example, an <c>untitled:</c> URI).
/// </summary>
public bool IsUntitled => !DocumentUri.ToUri().IsFile;

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

internal ReferenceTable References { get; }

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

#endregion
Expand All @@ -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;

Expand Down Expand Up @@ -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;
}
Comment thread
JustinGrote marked this conversation as resolved.

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
JustinGrote marked this conversation as resolved.
}

/// <summary>
Expand All @@ -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.
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);
}
}
}
Loading