From c9425602d69f6339b7668545fdfb198d0251387f Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 19 May 2026 11:32:28 -0500 Subject: [PATCH 1/4] feat: revive provider-backed workspace Rebase feature/get-content onto current upstream/main. Keep WorkspaceService synchronous while routing file reads and workspace enumeration through PowerShell when a host is available, add pspath URI support, and cover provider-backed launching in tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Handlers/DidChangeWatchedFilesHandler.cs | 8 +- .../Services/TextDocument/ScriptFile.cs | 27 ++- .../Services/Workspace/WorkspaceService.cs | 178 +++++++++++++++--- .../Debugging/DebugServiceTests.cs | 35 +++- .../Session/ScriptFileTests.cs | 3 +- 5 files changed, 216 insertions(+), 35 deletions(-) diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs index aa4c0a969..d018f7c6e 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs @@ -68,7 +68,11 @@ public Task Handle(DidChangeWatchedFilesParams request, CancellationToken matcher.AddExcludePatterns(_workspaceService.ExcludeFilesGlob); foreach (FileEvent change in request.Changes) { - if (matcher.Match(change.Uri.GetFileSystemPath()).HasMatches) + string changePath = change.Uri.ToUri().IsFile + ? change.Uri.GetFileSystemPath() + : change.Uri.ToUri().AbsolutePath; + + if (matcher.Match(changePath).HasMatches) { continue; } @@ -102,7 +106,7 @@ public Task Handle(DidChangeWatchedFilesParams request, CancellationToken string fileContents; try { - fileContents = WorkspaceService.ReadFileContents(change.Uri); + fileContents = _workspaceService.ReadFileContents(change.Uri); } catch { diff --git a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs index 9cff31d41..288dafd65 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs @@ -196,12 +196,31 @@ internal static List GetLines(string text) /// True if the path is an untitled file, false otherwise. internal static bool IsUntitledPath(string path) { - Validate.IsNotNull(nameof(path), path); - // This may not have been given a URI, so return false instead of throwing. - return Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute) && - !string.Equals(DocumentUri.From(path).Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase); + if (!Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute)) + { + return false; + } + + DocumentUri documentUri = DocumentUri.From(path); + string scheme = documentUri.Scheme?.ToLowerInvariant(); + if (!IsSupportedScheme(scheme)) + { + return false; + } + + return scheme switch + { + "inmemory" or "untitled" or "vscode-notebook-cell" => true, + _ => false, + }; } + internal static bool IsSupportedScheme(string scheme) => scheme?.ToLowerInvariant() switch + { + "file" or "inmemory" or "untitled" or "vscode-notebook-cell" or "pspath" => true, + _ => false, + }; + /// /// Gets a line from the file's contents. /// diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index 9b721387a..4e5e02941 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -6,10 +6,14 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Management.Automation; using System.Security; using System.Text; +using System.Threading; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; using Microsoft.PowerShell.EditorServices.Services.TextDocument; using Microsoft.PowerShell.EditorServices.Services.Workspace; using Microsoft.PowerShell.EditorServices.Utility; @@ -51,9 +55,12 @@ internal class WorkspaceService "**/*" }; + private const string s_psPathScheme = "pspath"; + private readonly ILogger logger; private readonly Version powerShellVersion; private readonly ConcurrentDictionary workspaceFiles = new(); + private readonly PsesInternalHost psesInternalHost; #endregion @@ -100,6 +107,12 @@ public WorkspaceService(ILoggerFactory factory) FollowSymlinks = true; } + /// + /// Creates a new instance of the Workspace class backed by a PowerShell host. + /// + public WorkspaceService(ILoggerFactory factory, PsesInternalHost psesInternalHost) + : this(factory) => this.psesInternalHost = psesInternalHost; + #endregion #region Public Methods @@ -139,18 +152,8 @@ public ScriptFile GetFile(DocumentUri documentUri) // Make sure the file isn't already loaded into the workspace if (!workspaceFiles.TryGetValue(keyName, out ScriptFile scriptFile)) { - // This method allows FileNotFoundException to bubble up - // if the file isn't found. - using (StreamReader streamReader = OpenStreamReader(documentUri)) - { - scriptFile = - new ScriptFile( - documentUri, - streamReader, - powerShellVersion); - - workspaceFiles[keyName] = scriptFile; - } + scriptFile = ScriptFile.Create(documentUri, ReadFileContents(documentUri), powerShellVersion); + workspaceFiles[keyName] = scriptFile; logger.LogDebug("Opened file on disk: " + documentUri.ToString()); } @@ -192,18 +195,10 @@ public bool TryGetFile(Uri fileUri, out ScriptFile scriptFile) => /// The out parameter that will contain the ScriptFile object. public bool TryGetFile(DocumentUri documentUri, out ScriptFile scriptFile) { - switch (documentUri.Scheme) + if (ScriptFile.IsUntitledPath(documentUri.ToString()) || !ScriptFile.IsSupportedScheme(documentUri.Scheme)) { - // List supported schemes here - case "file": - case "inmemory": - case "untitled": - case "vscode-notebook-cell": - break; - - default: - scriptFile = null; - return false; + scriptFile = null; + return false; } try @@ -396,11 +391,53 @@ public IEnumerable EnumeratePSFiles( int maxDepth, bool ignoreReparsePoints) { + string[] workspacePaths = GetPowerShellWorkspacePaths() + .Where(path => !string.IsNullOrEmpty(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (workspacePaths.Length == 0) + { + yield break; + } + + if (psesInternalHost is not null) + { + PSCommand psCommand = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Management\Get-ChildItem") + .AddParameter("LiteralPath", workspacePaths) + .AddParameter("Recurse") + .AddParameter("ErrorAction", ActionPreference.SilentlyContinue) + .AddParameter("Force") + .AddParameter("Include", includeGlobs.Concat(VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework).ToArray()) + .AddParameter("Exclude", excludeGlobs) + .AddParameter("Depth", maxDepth); + + if (VersionUtils.IsNetCore) + { + psCommand.AddParameter("FollowSymlink", !ignoreReparsePoints); + } + + psCommand + .AddCommand("Where-Object") + .AddParameter("Property", "PSIsContainer") + .AddParameter("EQ") + .AddParameter("Value", false); + + IReadOnlyList results = psesInternalHost.InvokePSCommand(psCommand, null, CancellationToken.None); + foreach (string path in results.Select(ConvertWorkspaceItemPath).Where(path => !string.IsNullOrEmpty(path))) + { + yield return path; + } + + yield break; + } + Matcher matcher = new(); foreach (string pattern in includeGlobs) { matcher.AddInclude(pattern); } foreach (string pattern in excludeGlobs) { matcher.AddExclude(pattern); } - foreach (string rootPath in WorkspacePaths) + foreach (string rootPath in workspacePaths) { if (!Directory.Exists(rootPath)) { @@ -439,10 +476,97 @@ internal static StreamReader OpenStreamReader(DocumentUri uri) return new StreamReader(fileStream, new UTF8Encoding(), detectEncodingFromByteOrderMarks: true); } - internal static string ReadFileContents(DocumentUri uri) + internal string ReadFileContents(DocumentUri uri) + { + if (psesInternalHost is null) + { + using StreamReader reader = OpenStreamReader(uri); + return reader.ReadToEnd(); + } + + string psPath = GetPowerShellPath(uri); + try + { + IReadOnlyList result = psesInternalHost.InvokePSCommand( + new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Management\Get-Content") + .AddParameter("LiteralPath", psPath) + .AddParameter("ErrorAction", ActionPreference.Stop), + new PowerShellExecutionOptions { ThrowOnError = true }, + CancellationToken.None); + + return string.Join(Environment.NewLine, result); + } + catch (ActionPreferenceStopException ex) + when (ex.ErrorRecord.CategoryInfo.Category == ErrorCategory.ObjectNotFound + && ex.ErrorRecord.TargetObject is string[] missingFiles + && missingFiles.Length == 1) + { + throw new FileNotFoundException(ex.ErrorRecord.ToString(), missingFiles[0], ex.ErrorRecord.Exception); + } + } + + private IEnumerable GetPowerShellWorkspacePaths() + { + if (WorkspaceFolders.Count > 0) + { + return WorkspaceFolders.Select(folder => GetPowerShellPath(folder.Uri)); + } + + return string.IsNullOrEmpty(InitialWorkingDirectory) + ? Array.Empty() + : new[] { InitialWorkingDirectory }; + } + + private static string ConvertWorkspaceItemPath(PSObject item) + { + if (item.Properties["FullName"]?.Value is string fullName && !string.IsNullOrEmpty(fullName)) + { + return fullName; + } + + return item.Properties["PSPath"]?.Value is string psPath && !string.IsNullOrEmpty(psPath) + ? CreatePowerShellPathUri(psPath) + : null; + } + + private static string GetPowerShellPath(DocumentUri uri) { - using StreamReader reader = OpenStreamReader(uri); - return reader.ReadToEnd(); + Uri parsedUri = uri.ToUri(); + if (parsedUri.IsFile) + { + return parsedUri.LocalPath; + } + + if (string.Equals(uri.Scheme, s_psPathScheme, StringComparison.OrdinalIgnoreCase)) + { + string provider = parsedUri.GetComponents(UriComponents.Host, UriFormat.Unescaped); + string path = Uri.UnescapeDataString(parsedUri.AbsolutePath); + if (path.Length >= 3 && path[0] == '/' && char.IsLetter(path[1]) && path[2] == ':') + { + path = path.TrimStart('/'); + } + + return string.IsNullOrEmpty(provider) + ? path.TrimStart('/') + : $"{provider}::{path}"; + } + + throw new NotSupportedException($"Unsupported URI scheme '{uri.Scheme}'."); + } + + private static string CreatePowerShellPathUri(string psPath) + { + string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None); + if (parts.Length != 2) + { + return $"{s_psPathScheme}:///{Uri.EscapeDataString(psPath)}"; + } + + string provider = parts[0].Split('\\').Last(); + string normalizedPath = parts[1].Replace('\\', '/'); + string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString)); + return $"{s_psPathScheme}://{Uri.EscapeDataString(provider)}/{encodedPath}"; } /// diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 3ba16008d..b5a0a08cf 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -42,6 +42,7 @@ public class DebugServiceTests : IAsyncLifetime private WorkspaceService workspace; private ScriptFile debugScriptFile; private ScriptFile oddPathScriptFile; + private ScriptFile psProviderPathScriptFile; private ScriptFile variableScriptFile; private readonly TestReadLine testReadLine = new(); @@ -70,10 +71,19 @@ public async Task InitializeAsync() debugService.DebuggerStopped += OnDebuggerStopped; // Load the test debug files. - workspace = new WorkspaceService(NullLoggerFactory.Instance); + workspace = new WorkspaceService(NullLoggerFactory.Instance, psesHost); debugScriptFile = GetDebugScript("DebugTest.ps1"); oddPathScriptFile = GetDebugScript("Debug' W&ith $Params [Test].ps1"); variableScriptFile = GetDebugScript("VariableTest.ps1"); + + string variableScriptFilePath = TestUtilities.GetSharedPath(Path.Combine("Debugging", "VariableTest.ps1")); + dynamic psItem = (await psesHost.ExecutePSCommandAsync( + new PSCommand() + .AddCommand("Get-Item") + .AddParameter("LiteralPath", variableScriptFilePath), + CancellationToken.None)).First(); + + psProviderPathScriptFile = workspace.GetFile(ConvertPSPathToUri((string)psItem.PSPath.ToString())); } public async Task DisposeAsync() @@ -94,6 +104,15 @@ public async Task DisposeAsync() [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "This intentionally fires and forgets on another thread.")] private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) => Task.Run(() => debuggerStoppedQueue.Add(e)); + private static string ConvertPSPathToUri(string psPath) + { + string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None); + string provider = parts[0].Split('\\').Last(); + string normalizedPath = parts[1].Replace('\\', '/'); + string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString)); + return $"pspath://{Uri.EscapeDataString(provider)}/{encodedPath}"; + } + private ScriptFile GetDebugScript(string fileName) => workspace.GetFile(TestUtilities.GetSharedPath(Path.Combine("Debugging", fileName))); private Task GetVariables(string scopeName) @@ -626,6 +645,20 @@ public async Task OddFilePathsLaunchCorrectly() Assert.Equal(". " + PSCommandHelpers.EscapeScriptFilePath(oddPathScriptFile.FilePath), Assert.Single(historyResult)); } + [Fact] + public async Task PSProviderPathsLaunchCorrectly() + { + ConfigurationDoneHandler configurationDoneHandler = new( + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); + await configurationDoneHandler.LaunchScriptAsync(psProviderPathScriptFile.FilePath); + + IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("(Get-History).CommandLine"), + CancellationToken.None); + + Assert.Equal(". $args[0]", Assert.Single(historyResult)); + } + [Fact] public async Task DebuggerVariableStringDisplaysCorrectly() { diff --git a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs index 11c27c4cf..7a1239ea8 100644 --- a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs +++ b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs @@ -665,8 +665,9 @@ public void DocumentUriReturnsCorrectStringForAbsolutePath() [InlineData(@"C:\Users\me\Documents\test.ps1", false)] [InlineData("/Users/me/Documents/test.ps1", false)] [InlineData("vscode-notebook-cell:/Users/me/Documents/test.ps1#0001", true)] - [InlineData("https://microsoft.com", true)] + [InlineData("https://microsoft.com", false)] [InlineData("Untitled:Untitled-1", true)] + [InlineData("pspath://filesystem/C%3A/Users/me/Documents/test.ps1", false)] [InlineData(@"'a log statement' > 'c:\Users\me\Documents\test.txt' ", false)] public void IsUntitledFileIsCorrect(string path, bool expected) => Assert.Equal(expected, ScriptFile.IsUntitledPath(path)); From ea55e6b4e722a2df7319ae7ce0efceee3cc51f00 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 19 May 2026 14:08:01 -0500 Subject: [PATCH 2/4] fix: keep untitled buffers discoverable Allow TryGetFile() to return existing in-memory untitled documents. This preserves the debugger's untitled script workflow while still letting provider-backed paths flow through WorkspaceService. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/Workspace/WorkspaceService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index 4e5e02941..51e608f16 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -195,7 +195,7 @@ public bool TryGetFile(Uri fileUri, out ScriptFile scriptFile) => /// The out parameter that will contain the ScriptFile object. public bool TryGetFile(DocumentUri documentUri, out ScriptFile scriptFile) { - if (ScriptFile.IsUntitledPath(documentUri.ToString()) || !ScriptFile.IsSupportedScheme(documentUri.Scheme)) + if (!ScriptFile.IsSupportedScheme(documentUri.Scheme)) { scriptFile = null; return false; From 3287b911ebf14f88c2a0c2a29e8508e1dd7fe73f Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 19 May 2026 14:49:16 -0500 Subject: [PATCH 3/4] fix: narrow workspace PS path usage Use direct file I/O for file-backed documents and filesystem traversal for file-backed workspace folders, while preserving PowerShell-backed behavior for provider URIs. This avoids cross-thread/apartment host invocations for ordinary workspace requests and keeps provider-backed paths working. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/Workspace/WorkspaceService.cs | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index 51e608f16..f52c69556 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -117,7 +117,8 @@ public WorkspaceService(ILoggerFactory factory, PsesInternalHost psesInternalHos #region Public Methods - public IEnumerable WorkspacePaths => WorkspaceFolders.Select(i => i.Uri.GetFileSystemPath()); + public IEnumerable WorkspacePaths => WorkspaceFolders.Select( + folder => folder.Uri.ToUri().IsFile ? folder.Uri.GetFileSystemPath() : GetPowerShellPath(folder.Uri)); /// /// Gets an open file in the workspace. If the file isn't open but exists on the filesystem, load and return it. @@ -391,21 +392,26 @@ public IEnumerable EnumeratePSFiles( int maxDepth, bool ignoreReparsePoints) { - string[] workspacePaths = GetPowerShellWorkspacePaths() + string[] powerShellWorkspacePaths = GetPowerShellWorkspacePaths() .Where(path => !string.IsNullOrEmpty(path)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); - if (workspacePaths.Length == 0) + string[] fileSystemWorkspacePaths = GetFileSystemWorkspacePaths() + .Where(path => !string.IsNullOrEmpty(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (powerShellWorkspacePaths.Length == 0 && fileSystemWorkspacePaths.Length == 0) { yield break; } - if (psesInternalHost is not null) + if (psesInternalHost is not null && powerShellWorkspacePaths.Length > 0) { PSCommand psCommand = new PSCommand() .AddCommand(@"Microsoft.PowerShell.Management\Get-ChildItem") - .AddParameter("LiteralPath", workspacePaths) + .AddParameter("LiteralPath", powerShellWorkspacePaths) .AddParameter("Recurse") .AddParameter("ErrorAction", ActionPreference.SilentlyContinue) .AddParameter("Force") @@ -437,7 +443,7 @@ public IEnumerable EnumeratePSFiles( foreach (string pattern in includeGlobs) { matcher.AddInclude(pattern); } foreach (string pattern in excludeGlobs) { matcher.AddExclude(pattern); } - foreach (string rootPath in workspacePaths) + foreach (string rootPath in fileSystemWorkspacePaths) { if (!Directory.Exists(rootPath)) { @@ -478,7 +484,7 @@ internal static StreamReader OpenStreamReader(DocumentUri uri) internal string ReadFileContents(DocumentUri uri) { - if (psesInternalHost is null) + if (uri.ToUri().IsFile || psesInternalHost is null) { using StreamReader reader = OpenStreamReader(uri); return reader.ReadToEnd(); @@ -506,11 +512,14 @@ internal string ReadFileContents(DocumentUri uri) } } - private IEnumerable GetPowerShellWorkspacePaths() + private IEnumerable GetFileSystemWorkspacePaths() { if (WorkspaceFolders.Count > 0) { - return WorkspaceFolders.Select(folder => GetPowerShellPath(folder.Uri)); + return WorkspaceFolders + .Select(folder => folder.Uri) + .Where(uri => uri.ToUri().IsFile) + .Select(uri => uri.GetFileSystemPath()); } return string.IsNullOrEmpty(InitialWorkingDirectory) @@ -518,6 +527,19 @@ private IEnumerable GetPowerShellWorkspacePaths() : new[] { InitialWorkingDirectory }; } + private IEnumerable GetPowerShellWorkspacePaths() + { + if (WorkspaceFolders.Count > 0) + { + return WorkspaceFolders + .Select(folder => folder.Uri) + .Where(uri => !uri.ToUri().IsFile) + .Select(GetPowerShellPath); + } + + return Array.Empty(); + } + private static string ConvertWorkspaceItemPath(PSObject item) { if (item.Properties["FullName"]?.Value is string fullName && !string.IsNullOrEmpty(fullName)) From 4e53839e8bf57d0a3a91ce7fd90e7180ed7dbf29 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 19 May 2026 17:08:08 -0500 Subject: [PATCH 4/4] docs: clarify pspath conversions Add concrete input/output examples to the pspath helper methods called out in PR review comments so the document/path transformations are easier to follow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/Workspace/WorkspaceService.cs | 20 +++++++++++++++++++ .../Debugging/DebugServiceTests.cs | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index f52c69556..fd9c68e44 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -512,6 +512,10 @@ internal string ReadFileContents(DocumentUri uri) } } + // Return only file-backed workspace roots as filesystem paths. + // Example: + // file:///repo -> /repo + // pspath://FileSystem/C%3A/repo -> excluded private IEnumerable GetFileSystemWorkspacePaths() { if (WorkspaceFolders.Count > 0) @@ -527,6 +531,10 @@ private IEnumerable GetFileSystemWorkspacePaths() : new[] { InitialWorkingDirectory }; } + // Return only provider-backed workspace roots as PowerShell literal paths. + // Example: + // pspath://FileSystem/C%3A/repo -> FileSystem::C:/repo + // file:///repo -> excluded private IEnumerable GetPowerShellWorkspacePaths() { if (WorkspaceFolders.Count > 0) @@ -540,6 +548,10 @@ private IEnumerable GetPowerShellWorkspacePaths() return Array.Empty(); } + // Normalize Get-ChildItem output to a workspace path string. + // Example: + // FullName=/repo/a.ps1 -> /repo/a.ps1 + // PSPath=Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo private static string ConvertWorkspaceItemPath(PSObject item) { if (item.Properties["FullName"]?.Value is string fullName && !string.IsNullOrEmpty(fullName)) @@ -552,6 +564,10 @@ private static string ConvertWorkspaceItemPath(PSObject item) : null; } + // Convert a document URI to the literal path PowerShell commands should use. + // Example: + // file:///repo/a.ps1 -> /repo/a.ps1 + // pspath://FileSystem/C%3A/repo/a.ps1 -> FileSystem::C:/repo/a.ps1 private static string GetPowerShellPath(DocumentUri uri) { Uri parsedUri = uri.ToUri(); @@ -577,6 +593,10 @@ private static string GetPowerShellPath(DocumentUri uri) throw new NotSupportedException($"Unsupported URI scheme '{uri.Scheme}'."); } + // Convert a PowerShell provider path to the pspath:// document form used by the workspace. + // Example: + // FileSystem::C:\\repo\\a.ps1 -> pspath://FileSystem/C%3A/repo/a.ps1 + // Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo private static string CreatePowerShellPathUri(string psPath) { string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None); diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index b5a0a08cf..5142bdbde 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -104,6 +104,10 @@ public async Task DisposeAsync() [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "This intentionally fires and forgets on another thread.")] private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) => Task.Run(() => debuggerStoppedQueue.Add(e)); + // Convert a PowerShell provider path into the pspath:// URI form used by workspace tests. + // Example: + // FileSystem::C:\\repo\\a.ps1 -> pspath://FileSystem/C%3A/repo/a.ps1 + // Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo private static string ConvertPSPathToUri(string psPath) { string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None);