Add support for defining PowerShell modules

Adds a new section "Scripting" in the server UI where PowerShell modules can be defined. These can be used for defining a library of functions that can be used in any script. These modules are automatically synced to the launcher and imported upon script execution.
This commit is contained in:
Pat Hartl 2026-06-28 23:02:35 -05:00
parent 5a80d1e605
commit f87756243e
27 changed files with 1482 additions and 4 deletions

View file

@ -37,7 +37,7 @@ public partial class LibraryViewModel : GamesCollectionViewModel
public override bool IsCollectionFiltered => !string.IsNullOrEmpty(SelectedCollection);
public override string FilteredCollectionName => SelectedCollection ?? string.Empty;
public LibraryViewModel(IServiceProvider serviceProvider)
public LibraryViewModel(IServiceProvider serviceProvider) : base(serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = serviceProvider.GetRequiredService<ILogger<LibraryViewModel>>();
@ -67,6 +67,12 @@ public partial class LibraryViewModel : GamesCollectionViewModel
var mediaClient = scope.ServiceProvider.GetRequiredService<MediaClient>();
var dbContext = scope.ServiceProvider.GetRequiredService<DbContext>();
if (!IsOfflineMode)
{
var moduleClient = scope.ServiceProvider.GetRequiredService<ModuleClient>();
await moduleClient.SyncAsync();
}
var items = await libraryService.GetItemsAsync();
var results = new List<GameItemViewModel>();
var gameModels = new List<LANCommander.Launcher.Data.Models.Game>();

View file

@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Threading.Tasks;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Factories;
using Microsoft.Extensions.Logging;
namespace LANCommander.SDK.Services
{
public class ModuleClient(
ILogger<ModuleClient> logger,
ApiRequestFactory apiRequestFactory,
ISettingsProvider settingsProvider)
{
public async Task<IEnumerable<string>> GetAsync()
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute("/api/Modules")
.GetAsync<IEnumerable<string>>();
}
public string GetLocalPath()
=> settingsProvider.CurrentValue.Modules.StoragePath;
public async Task<FileInfo> DownloadAsync(string destination)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute("/api/Modules/Download")
.DownloadAsync(destination);
}
public async Task SyncAsync()
{
var destination = GetLocalPath();
var archivePath = Path.Combine(Path.GetTempPath(), $"LANCommander.Modules.{Guid.NewGuid()}.zip");
try
{
await DownloadAsync(archivePath);
if (Directory.Exists(destination))
Directory.Delete(destination, true);
Directory.CreateDirectory(destination);
ZipFile.ExtractToDirectory(archivePath, destination, true);
logger?.LogInformation("Synced PowerShell modules to {Destination}", destination);
}
catch (Exception ex)
{
logger?.LogError(ex, "Could not sync PowerShell modules");
}
finally
{
if (File.Exists(archivePath))
File.Delete(archivePath);
}
}
}
}

View file

@ -47,6 +47,7 @@ public static class IServiceCollectionExtensions
services.AddSingleton<LibraryClient>();
services.AddSingleton<LobbyClient>();
services.AddSingleton<MediaClient>();
services.AddSingleton<ModuleClient>();
services.AddSingleton<PlaySessionClient>();
services.AddSingleton<ProfileClient>();
services.AddSingleton<RedistributableClient>();

View file

@ -0,0 +1,6 @@
namespace LANCommander.SDK.Models;
public class ModuleSettings
{
public string StoragePath { get; set; } = AppPaths.GetConfigPath("Modules");
}

View file

@ -13,6 +13,7 @@ public class Settings
public GameSettings Games { get; set; } = new();
public ToolSettings Tools { get; set; } = new();
public MediaSettings Media { get; set; } = new();
public ModuleSettings Modules { get; set; } = new();
public DebugSettings Debug { get; set; } = new();
public UpdateSettings Updates { get; set; } = new();
public IPXRelaySettings IPXRelay { get; set; } = new();

View file

@ -175,9 +175,17 @@ namespace LANCommander.SDK.PowerShell
T result = default;
var initialSessionState = InitialSessionState.CreateDefault();
initialSessionState.AddCustomCmdlets();
// Always import synced PowerShell modules so their functions are available to every script
var modulesPath = AppPaths.GetConfigPath("Modules");
if (Directory.Exists(modulesPath))
{
foreach (var moduleDirectory in Directory.GetDirectories(modulesPath))
initialSessionState.ImportPSModule(new[] { moduleDirectory });
}
DisableWow64Redirection();
using (Runspace runspace = RunspaceFactory.CreateRunspace(initialSessionState))

View file

@ -33,6 +33,7 @@ public static class IServiceCollectionExtensions
services.AddScoped<GameService>();
services.AddScoped<LibraryService>();
services.AddScoped<ScriptService>();
services.AddScoped<ModuleService>();
services.AddScoped<GenreService>();
services.AddScoped<PlatformService>();
services.AddScoped<KeyService>();

View file

@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
namespace LANCommander.Server.Models
{
public enum FunctionVisibility
{
Public,
Private
}
public class ModuleFunction
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; }
public FunctionVisibility Visibility { get; set; } = FunctionVisibility.Public;
public string Content { get; set; }
}
public class Module
{
public string Name { get; set; }
public string Manifest { get; set; }
public List<ModuleFunction> Functions { get; set; } = new();
}
public record VerbGroup(string Name, IReadOnlyList<string> Verbs);
}

View file

@ -0,0 +1,14 @@
namespace LANCommander.Server.Models
{
public class ModuleManifest
{
public string RootModule { get; set; } = string.Empty;
public string ModuleVersion { get; set; } = "1.0.0";
public string Guid { get; set; } = System.Guid.NewGuid().ToString();
public string Author { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public string Copyright { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string PowerShellVersion { get; set; } = string.Empty;
}
}

View file

@ -0,0 +1,478 @@
using System.IO.Compression;
using System.Management.Automation.Language;
using System.Text.RegularExpressions;
using LANCommander.SDK;
using LANCommander.Server.Models;
namespace LANCommander.Server.Services
{
public sealed class ModuleService(SettingsProvider<Settings.Settings> settingsProvider)
{
private static readonly Regex FunctionNamePattern =
new(@"^[A-Za-z][A-Za-z0-9]*-[A-Za-z][A-Za-z0-9]*$", RegexOptions.Compiled);
// Standard set of approved PowerShell verbs (Get-Verb), grouped by verb group.
private static readonly IReadOnlyList<VerbGroup> ApprovedVerbGroups = new[]
{
new VerbGroup("Common", new[]
{
"Add", "Clear", "Close", "Copy", "Enter", "Exit", "Find", "Format", "Get", "Hide", "Join",
"Lock", "Move", "New", "Open", "Optimize", "Pop", "Push", "Redo", "Remove", "Rename", "Reset",
"Resize", "Search", "Select", "Set", "Show", "Skip", "Split", "Step", "Switch", "Undo", "Unlock", "Watch",
}),
new VerbGroup("Communications", new[]
{
"Connect", "Disconnect", "Read", "Receive", "Send", "Write",
}),
new VerbGroup("Data", new[]
{
"Backup", "Checkpoint", "Compare", "Compress", "Convert", "ConvertFrom", "ConvertTo", "Dismount",
"Edit", "Expand", "Export", "Group", "Import", "Initialize", "Limit", "Merge", "Mount", "Out",
"Publish", "Restore", "Save", "Sync", "Unpublish", "Update",
}),
new VerbGroup("Diagnostic", new[]
{
"Debug", "Measure", "Ping", "Repair", "Resolve", "Test", "Trace",
}),
new VerbGroup("Lifecycle", new[]
{
"Approve", "Assert", "Build", "Complete", "Confirm", "Deny", "Deploy", "Disable", "Enable",
"Install", "Invoke", "Register", "Request", "Restart", "Resume", "Start", "Stop", "Submit",
"Suspend", "Uninstall", "Unregister", "Wait",
}),
new VerbGroup("Security", new[]
{
"Block", "Grant", "Protect", "Revoke", "Unblock", "Unprotect",
}),
new VerbGroup("Other", new[]
{
"Use",
}),
};
private static readonly HashSet<string> ApprovedVerbs =
new(ApprovedVerbGroups.SelectMany(g => g.Verbs), StringComparer.OrdinalIgnoreCase);
private string GetStoragePath()
{
var storagePath = settingsProvider.CurrentValue.Server.Scripts.Modules.StoragePath;
if (string.IsNullOrWhiteSpace(storagePath))
{
storagePath = AppPaths.GetConfigPath("Modules");
settingsProvider.Update(s =>
{
s.Server.Scripts.Modules.StoragePath = storagePath;
});
}
if (!Directory.Exists(storagePath))
Directory.CreateDirectory(storagePath);
return storagePath;
}
private string GetModuleDirectory(string name) => Path.Combine(GetStoragePath(), name);
private static string GetManifestPath(string moduleDirectory, string name) =>
Path.Combine(moduleDirectory, $"{name}.psd1");
private static string GetLoaderPath(string moduleDirectory, string name) =>
Path.Combine(moduleDirectory, $"{name}.psm1");
private static string GetVisibilityDirectory(string moduleDirectory, FunctionVisibility visibility) =>
Path.Combine(moduleDirectory, visibility == FunctionVisibility.Public ? "Public" : "Private");
public IEnumerable<Module> GetModules()
{
var storagePath = GetStoragePath();
return Directory
.GetDirectories(storagePath)
.Select(d => GetModule(Path.GetFileName(d)))
.Where(m => m != null)
.OrderBy(m => m.Name);
}
public Module GetModule(string name)
{
var moduleDirectory = GetModuleDirectory(name);
if (!Directory.Exists(moduleDirectory))
return null;
var manifestPath = GetManifestPath(moduleDirectory, name);
var module = new Module
{
Name = name,
Manifest = File.Exists(manifestPath) ? File.ReadAllText(manifestPath) : string.Empty,
Functions = new List<ModuleFunction>(),
};
foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private })
{
var directory = GetVisibilityDirectory(moduleDirectory, visibility);
if (!Directory.Exists(directory))
continue;
foreach (var file in Directory.GetFiles(directory, "*.ps1").OrderBy(f => f))
{
module.Functions.Add(new ModuleFunction
{
Name = Path.GetFileNameWithoutExtension(file),
Visibility = visibility,
Content = File.ReadAllText(file),
});
}
}
return module;
}
public bool ModuleExists(string name) => Directory.Exists(GetModuleDirectory(name));
public Module CreateScaffold(string name)
{
return new Module
{
Name = name,
Manifest = GetDefaultManifest(name),
Functions = new List<ModuleFunction>
{
new()
{
Name = "Get-Example",
Visibility = FunctionVisibility.Public,
Content = GetDefaultFunctionContent("Get-Example"),
},
},
};
}
public void SaveModule(Module module)
{
var moduleDirectory = GetModuleDirectory(module.Name);
if (!Directory.Exists(moduleDirectory))
Directory.CreateDirectory(moduleDirectory);
File.WriteAllText(GetManifestPath(moduleDirectory, module.Name), module.Manifest ?? string.Empty);
File.WriteAllText(GetLoaderPath(moduleDirectory, module.Name), GetLoaderScript());
var desiredFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private })
{
var directory = GetVisibilityDirectory(moduleDirectory, visibility);
Directory.CreateDirectory(directory);
foreach (var function in module.Functions.Where(f => f.Visibility == visibility))
{
if (string.IsNullOrWhiteSpace(function.Name))
continue;
var path = Path.Combine(directory, $"{function.Name}.ps1");
File.WriteAllText(path, function.Content ?? string.Empty);
desiredFiles.Add(path);
}
}
// Prune function files that were removed or moved between Public/Private
foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private })
{
var directory = GetVisibilityDirectory(moduleDirectory, visibility);
if (!Directory.Exists(directory))
continue;
foreach (var file in Directory.GetFiles(directory, "*.ps1"))
{
if (!desiredFiles.Contains(file))
File.Delete(file);
}
}
}
public void RenameModule(string oldName, string newName)
{
if (string.Equals(oldName, newName, StringComparison.Ordinal))
return;
var module = GetModule(oldName);
if (module == null)
return;
DeleteModule(oldName);
module.Name = newName;
SaveModule(module);
}
public void DeleteModule(string name)
{
var moduleDirectory = GetModuleDirectory(name);
if (Directory.Exists(moduleDirectory))
Directory.Delete(moduleDirectory, true);
}
public string GetModulesArchive()
{
var storagePath = GetStoragePath();
var archivePath = Path.Combine(Path.GetTempPath(), $"LANCommander.Modules.{Guid.NewGuid()}.zip");
ZipFile.CreateFromDirectory(storagePath, archivePath, CompressionLevel.Optimal, false);
return archivePath;
}
public ModuleManifest ParseManifest(string manifest)
{
var result = new ModuleManifest();
if (string.IsNullOrWhiteSpace(manifest))
return result;
var ast = Parser.ParseInput(manifest, out _, out _);
var hashtable = ast
.Find(a => a is HashtableAst, false) as HashtableAst;
if (hashtable == null)
return result;
foreach (var pair in hashtable.KeyValuePairs)
{
if (pair.Item1 is not StringConstantExpressionAst keyAst)
continue;
var value = GetScalarString(pair.Item2);
if (value == null)
continue;
switch (keyAst.Value)
{
case "RootModule":
result.RootModule = value;
break;
case "ModuleVersion":
result.ModuleVersion = value;
break;
case "GUID":
result.Guid = value;
break;
case "Author":
result.Author = value;
break;
case "CompanyName":
result.CompanyName = value;
break;
case "Copyright":
result.Copyright = value;
break;
case "Description":
result.Description = value;
break;
case "PowerShellVersion":
result.PowerShellVersion = value;
break;
}
}
return result;
}
public string GenerateManifest(ModuleManifest manifest, IEnumerable<string> functionNames, IEnumerable<string> aliasNames)
{
var functions = functionNames?.Distinct().OrderBy(n => n).ToList() ?? new List<string>();
var aliases = aliasNames?.Distinct().OrderBy(n => n).ToList() ?? new List<string>();
var builder = new System.Text.StringBuilder();
builder.Append("@{\n");
builder.Append($" RootModule = '{Escape(manifest.RootModule)}'\n");
builder.Append($" ModuleVersion = '{Escape(manifest.ModuleVersion)}'\n");
builder.Append($" GUID = '{Escape(manifest.Guid)}'\n");
if (!string.IsNullOrWhiteSpace(manifest.Author))
builder.Append($" Author = '{Escape(manifest.Author)}'\n");
if (!string.IsNullOrWhiteSpace(manifest.CompanyName))
builder.Append($" CompanyName = '{Escape(manifest.CompanyName)}'\n");
if (!string.IsNullOrWhiteSpace(manifest.Copyright))
builder.Append($" Copyright = '{Escape(manifest.Copyright)}'\n");
if (!string.IsNullOrWhiteSpace(manifest.Description))
builder.Append($" Description = '{Escape(manifest.Description)}'\n");
if (!string.IsNullOrWhiteSpace(manifest.PowerShellVersion))
builder.Append($" PowerShellVersion = '{Escape(manifest.PowerShellVersion)}'\n");
builder.Append($" FunctionsToExport = {FormatArray(functions)}\n");
builder.Append(" CmdletsToExport = @()\n");
builder.Append(" VariablesToExport = @()\n");
builder.Append($" AliasesToExport = {FormatArray(aliases)}\n");
builder.Append("}\n");
return builder.ToString();
}
public IReadOnlyList<string> GetExportedFunctions(Module module)
{
if (module?.Functions == null)
return [];
return module.Functions
.Where(f => f.Visibility == FunctionVisibility.Public && !string.IsNullOrWhiteSpace(f.Name))
.Select(f => f.Name)
.Distinct()
.OrderBy(n => n)
.ToList();
}
public IReadOnlyList<string> GetExportedAliases(Module module)
{
if (module?.Functions == null)
return [];
var aliases = new List<string>();
foreach (var function in module.Functions)
aliases.AddRange(GetAliasesFromScript(function.Content));
return aliases.Distinct().OrderBy(a => a).ToList();
}
public IEnumerable<(string Name, string Synopsis, string Module)> GetPublicFunctionCompletions()
{
foreach (var module in GetModules())
{
foreach (var function in module.Functions.Where(f =>
f.Visibility == FunctionVisibility.Public && !string.IsNullOrWhiteSpace(f.Name)))
{
yield return (function.Name, ExtractSynopsis(function.Content), module.Name);
}
}
}
private static string ExtractSynopsis(string content)
{
if (string.IsNullOrWhiteSpace(content))
return null;
var match = Regex.Match(content, @"\.SYNOPSIS\s*\r?\n\s*(?<synopsis>.+)");
return match.Success ? match.Groups["synopsis"].Value.Trim() : null;
}
public IReadOnlyList<VerbGroup> GetApprovedVerbGroups() =>
ApprovedVerbGroups
.Select(g => new VerbGroup(g.Name, g.Verbs.OrderBy(v => v, StringComparer.OrdinalIgnoreCase).ToArray()))
.ToList();
public bool IsValidFunctionName(string name) =>
!string.IsNullOrWhiteSpace(name) && FunctionNamePattern.IsMatch(name);
public bool IsApprovedVerb(string name)
{
if (string.IsNullOrWhiteSpace(name))
return false;
var dash = name.IndexOf('-');
if (dash <= 0)
return false;
return ApprovedVerbs.Contains(name.Substring(0, dash));
}
private static IEnumerable<string> GetAliasesFromScript(string script)
{
if (string.IsNullOrWhiteSpace(script))
yield break;
var ast = Parser.ParseInput(script, out _, out _);
foreach (var command in ast.FindAll(a => a is CommandAst, true).Cast<CommandAst>())
{
var name = command.GetCommandName();
if (!string.Equals(name, "Set-Alias", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(name, "New-Alias", StringComparison.OrdinalIgnoreCase))
continue;
var elements = command.CommandElements;
for (var i = 1; i < elements.Count; i++)
{
if (elements[i] is CommandParameterAst parameter &&
string.Equals(parameter.ParameterName, "Name", StringComparison.OrdinalIgnoreCase) &&
i + 1 < elements.Count)
{
var value = GetScalarString(elements[i + 1]);
if (value != null)
yield return value;
break;
}
if (elements[i] is StringConstantExpressionAst positional && i == 1)
{
yield return positional.Value;
break;
}
}
}
}
private static string GetScalarString(Ast valueAst)
{
return valueAst switch
{
StringConstantExpressionAst s => s.Value,
ExpandableStringExpressionAst e => e.Value,
ConstantExpressionAst c => c.Value?.ToString(),
_ => null,
};
}
private static string FormatArray(IReadOnlyList<string> values)
{
if (values.Count == 0)
return "@()";
return "@(" + string.Join(", ", values.Select(v => $"'{Escape(v)}'")) + ")";
}
private static string Escape(string value) =>
(value ?? string.Empty).Replace("'", "''");
private static string GetDefaultManifest(string name) =>
$"@{{\n RootModule = '{name}.psm1'\n ModuleVersion = '1.0.0'\n GUID = '{Guid.NewGuid()}'\n FunctionsToExport = '*'\n}}\n";
private static string GetDefaultFunctionContent(string name) =>
$"function {name} {{\n [CmdletBinding()]\n param()\n\n \"Hello from a LANCommander module\"\n}}\n";
// Loader that dot-sources every function file and exports only the public ones.
private static string GetLoaderScript() =>
"$Public = @(Get-ChildItem -Path \"$PSScriptRoot\\Public\\*.ps1\" -ErrorAction SilentlyContinue)\n" +
"$Private = @(Get-ChildItem -Path \"$PSScriptRoot\\Private\\*.ps1\" -ErrorAction SilentlyContinue)\n" +
"\n" +
"foreach ($file in @($Public + $Private)) {\n" +
" try { . $file.FullName }\n" +
" catch { Write-Error \"Failed to import function $($file.FullName): $_\" }\n" +
"}\n" +
"\n" +
"Export-ModuleMember -Function $Public.BaseName\n";
}
}

View file

@ -125,5 +125,62 @@ namespace LANCommander.Server.Services
};
});
}
private string GetSnippetsStoragePath()
{
var storagePath = settingsProvider.CurrentValue.Server.Scripts.Snippets.StoragePath;
if (string.IsNullOrWhiteSpace(storagePath))
{
storagePath = AppPaths.GetConfigPath("Snippets");
settingsProvider.Update(s =>
{
s.Server.Scripts.Snippets.StoragePath = storagePath;
});
}
if (!Directory.Exists(storagePath))
Directory.CreateDirectory(storagePath);
return storagePath;
}
private string GetSnippetPath(string group, string name) =>
Path.Combine(GetSnippetsStoragePath(), group, $"{name}.ps1");
public Snippet GetSnippet(string group, string name)
{
var path = GetSnippetPath(group, name);
if (!File.Exists(path))
return null;
return new Snippet
{
Group = group,
Name = name,
Content = File.ReadAllText(path),
};
}
public void SaveSnippet(Snippet snippet)
{
var path = GetSnippetPath(snippet.Group, snippet.Name);
var directory = Path.GetDirectoryName(path);
if (!Directory.Exists(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(path, snippet.Content ?? string.Empty);
}
public void DeleteSnippet(string group, string name)
{
var path = GetSnippetPath(group, name);
if (File.Exists(path))
File.Delete(path);
}
}
}

View file

@ -0,0 +1,8 @@
using LANCommander.SDK;
namespace LANCommander.Server.Settings.Models;
public class ModuleSettings
{
public string StoragePath { get; set; } = AppPaths.GetConfigPath("Modules");
}

View file

@ -7,4 +7,5 @@ public class ScriptSettings
public bool EnableAutomaticRepackaging { get; set; } = false;
public int RepackageEvery { get; set; } = 24;
public SnippetSettings Snippets { get; set; } = new();
public ModuleSettings Modules { get; set; } = new();
}

View file

@ -0,0 +1,32 @@
using LANCommander.Server.Services;
using Microsoft.AspNetCore.Mvc;
namespace LANCommander.Server.Endpoints;
public static class ModulesEndpoints
{
public static void MapModulesEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/Modules").RequireAuthorization();
group.MapGet("/", GetAsync);
group.MapGet("/Download", DownloadAsync);
}
internal static IResult GetAsync([FromServices] ModuleService moduleService)
{
var names = moduleService.GetModules().Select(m => m.Name);
return TypedResults.Ok(names);
}
internal static IResult DownloadAsync([FromServices] ModuleService moduleService)
{
var archivePath = moduleService.GetModulesArchive();
var stream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096,
FileOptions.DeleteOnClose | FileOptions.Asynchronous);
return TypedResults.File(stream, "application/octet-stream", "Modules.zip");
}
}

View file

@ -32,6 +32,7 @@ public static class Endpoints
endpoints.MapLauncherEndpoints();
endpoints.MapLibraryEndpoints();
endpoints.MapRedistributablesEndpoints();
endpoints.MapModulesEndpoints();
endpoints.MapToolsEndpoints();
endpoints.MapIssueEndpoints();
endpoints.MapSaveEndpoints();

View file

@ -18,6 +18,7 @@ public static class Filesystem
settings.Value.Server.Launcher.StoragePath,
settings.Value.Server.Backups.StoragePath,
settings.Value.Server.Scripts.Snippets.StoragePath,
settings.Value.Server.Scripts.Modules.StoragePath,
];
foreach (var directory in directories)

View file

@ -57,6 +57,16 @@
<MenuItem RouterLink="/Redistributables" Icon="@IconType.Outline.Block">Redistributables</MenuItem>
<MenuItem RouterLink="/Tools" Icon="@IconType.Outline.Tool">Tools</MenuItem>
<MenuItem RouterLink="/Servers" Icon="@IconType.Outline.Database">Servers</MenuItem>
<SubMenu>
<TitleTemplate>
<Icon Type="@IconType.Outline.Code"/>
<span>Scripting</span>
</TitleTemplate>
<ChildContent>
<MenuItem RouterLink="/Scripting/Snippets">Snippets</MenuItem>
<MenuItem RouterLink="/Scripting/Modules">Modules</MenuItem>
</ChildContent>
</SubMenu>
<MenuDivider/>
<SubMenu>
<TitleTemplate>

View file

@ -0,0 +1,91 @@
@inject ScriptService ScriptService
@inject ModuleService ModuleService
<Flex Gap="FlexGap.Small" Wrap="FlexWrap.Wrap" Align="FlexAlign.Center" Style="margin-bottom: 16px;">
@foreach (var group in Snippets.Select(s => s.Group).Distinct())
{
<Dropdown>
<Overlay>
<Menu>
@foreach (var snippet in Snippets.Where(s => s.Group == group))
{
<MenuItem OnClick="() => InsertSnippet(snippet)">
@snippet.Name
</MenuItem>
}
</Menu>
</Overlay>
<ChildContent>
<Button Type="@ButtonType.Primary">@group</Button>
</ChildContent>
</Dropdown>
}
@if (Variables != null && Variables.Any())
{
<Dropdown>
<Overlay>
<Menu>
@foreach (var variable in Variables)
{
<MenuItem OnClick="() => InsertText(variable)">
@variable
</MenuItem>
}
</Menu>
</Overlay>
<ChildContent>
<Button Type="@ButtonType.Primary">Variables</Button>
</ChildContent>
</Dropdown>
}
</Flex>
<CodeInput @ref="_codeInput"
Value="@Value"
ValueChanged="@ValueChanged"
Language="Languages.PowerShell"
OnSave="OnSave"
ModuleFunctions="@_moduleFunctions"
Height="@Height" />
@code {
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
[Parameter] public int Height { get; set; } = 500;
[Parameter] public IEnumerable<string>? Variables { get; set; }
[Parameter] public EventCallback OnSave { get; set; }
CodeInput? _codeInput;
IEnumerable<Snippet> Snippets { get; set; } = [];
List<ModuleFunctionCompletion> _moduleFunctions = new();
protected override void OnInitialized()
{
Snippets = ScriptService.GetSnippets();
_moduleFunctions = ModuleService.GetPublicFunctionCompletions()
.Select(f => new ModuleFunctionCompletion(f.Name, string.IsNullOrWhiteSpace(f.Synopsis) ? null : f.Synopsis, f.Module))
.ToList();
}
public async Task LayoutAsync()
{
if (_codeInput != null)
await _codeInput.LayoutAsync();
}
async Task InsertText(string text)
{
if (_codeInput != null)
await _codeInput.InsertText(text);
}
async Task InsertSnippet(Snippet snippet)
{
if (_codeInput != null)
await _codeInput.InsertSnippet(snippet.Content);
}
}

View file

@ -6,6 +6,7 @@
@using YamlDotNet.Serialization.NamingConventions
@inherits FeedbackComponent<ScriptEditorOptions, Script>
@inject ScriptService ScriptService
@inject ModuleService ModuleService
@inject ArchiveService ArchiveService
@inject ServerService ServerService
@inject RedistributableService RedistributableService
@ -91,7 +92,7 @@
<Checkbox @bind-Checked="_script.RequiresAdmin">Requires Admin</Checkbox>
</Flex>
<CodeInput @ref="_codeInput" @bind-Value="_script.Contents" Language="Languages.PowerShell" OnSave="Save" ScriptType="@_script.Type.ToString()" OnReady="OnEditorReady" />
<CodeInput @ref="_codeInput" @bind-Value="_script.Contents" Language="Languages.PowerShell" OnSave="Save" ScriptType="@_script.Type.ToString()" ModuleFunctions="@_moduleFunctions" OnReady="OnEditorReady" />
<PowerShellConsole @ref="_console" Id="_id" @bind-Active="_debugging"/>
@ -113,6 +114,7 @@
CodeInput? _codeInput;
PowerShellConsole? _console;
IEnumerable<Snippet> Snippets { get; set; } = [];
List<ModuleFunctionCompletion> _moduleFunctions = new();
List<string> _variables = new();
List<string> _optionKeys = new();
string _redistributableName = string.Empty;
@ -209,6 +211,10 @@
UpdateVariables();
Snippets = ScriptService.GetSnippets();
_moduleFunctions = ModuleService.GetPublicFunctionCompletions()
.Select(f => new ModuleFunctionCompletion(f.Name, string.IsNullOrWhiteSpace(f.Synopsis) ? null : f.Synopsis, f.Module))
.ToList();
}
async Task OnEditorReady()

View file

@ -0,0 +1,388 @@
@page "/Scripting/Modules/Add"
@page "/Scripting/Modules/{Name}"
@attribute [Authorize(Roles = RoleService.AdministratorRoleName)]
@inject ModuleService ModuleService
@inject NavigationManager NavigationManager
@inject IMessageService MessageService
@inject ILogger<Edit> Logger
<style>
.module-editor .monaco-editor,
.module-editor .monaco-editor-container {
min-height: 400px;
}
</style>
<PageHeader Title="@(IsNew ? "Add Module" : Module.Name)">
<PageHeaderExtra>
<Flex Gap="FlexGap.Small">
<Button OnClick="@(() => NavigationManager.NavigateTo("/Scripting/Modules"))">Back</Button>
<Button OnClick="Save" Type="@ButtonType.Primary">Save</Button>
</Flex>
</PageHeaderExtra>
</PageHeader>
<PageContent Class="module-editor">
<Form Model="@Module" Layout="@FormLayout.Vertical">
<Tabs ActiveKey="@_activeTab" ActiveKeyChanged="OnTabChanged">
<TabPane Key="functions" Tab="Functions">
@if (_activeTab == "functions")
{
<GridRow Gutter="16">
<GridCol Span="6">
<Flex Style="margin-bottom: 16px;">
<Button Type="@ButtonType.Primary" Icon="@IconType.Outline.Plus" OnClick="AddFunction" Block>Add Function</Button>
</Flex>
<Tree TItem="ModuleFunction" SelectedKeys="@_selectedFunctionKeys" OnClick="OnFunctionTreeClick">
<ChildContent>
<TreeNode Title="Public" Selectable="false" Expanded>
<ChildContent>
@foreach (var function in Module.Functions.Where(f => f.Visibility == FunctionVisibility.Public))
{
<TreeNode Key="@function.Id.ToString()" Title="@FunctionTitle(function)" DataItem="function" IsLeaf />
}
</ChildContent>
</TreeNode>
<TreeNode Title="Private" Selectable="false" Expanded>
<ChildContent>
@foreach (var function in Module.Functions.Where(f => f.Visibility == FunctionVisibility.Private))
{
<TreeNode Key="@function.Id.ToString()" Title="@FunctionTitle(function)" DataItem="function" IsLeaf />
}
</ChildContent>
</TreeNode>
</ChildContent>
</Tree>
</GridCol>
<GridCol Span="18">
@if (_selectedFunction != null)
{
<FormItem Label="Function Name">
<Flex Gap="FlexGap.Small" Align="FlexAlign.Center">
<Flex Gap="FlexGap.Small" Align="FlexAlign.Center" Style="flex-grow: 1;">
<Select TItem="VerbOption" TItemValue="string"
DataSource="_verbOptions"
@bind-Value="Verb"
ValueName="@nameof(VerbOption.Verb)"
LabelName="@nameof(VerbOption.Verb)"
GroupName="@nameof(VerbOption.Group)"
SortByGroup="SortDirection.Ascending"
SortByLabel="SortDirection.Ascending"
ShowSearch
Style="width: 160px;" />
<span>-</span>
<Input @bind-Value="Noun" Placeholder="Noun" Style="flex-grow: 1;" />
</Flex>
<Select @bind-Value="_selectedFunction.Visibility" TItemValue="FunctionVisibility" TItem="FunctionVisibility" Style="width: 140px;">
<SelectOptions>
<SelectOption TItemValue="FunctionVisibility" TItem="FunctionVisibility" Value="FunctionVisibility.Public" Label="Public" />
<SelectOption TItemValue="FunctionVisibility" TItem="FunctionVisibility" Value="FunctionVisibility.Private" Label="Private" />
</SelectOptions>
</Select>
<Popconfirm OnConfirm="() => DeleteFunction(_selectedFunction)" Title="Are you sure you want to delete this function?">
<Button Icon="@IconType.Outline.Delete" Danger />
</Popconfirm>
</Flex>
</FormItem>
@if (!string.IsNullOrWhiteSpace(_selectedFunction.Name) && !ModuleService.IsValidFunctionName(_selectedFunction.Name))
{
<Alert Type="@AlertType.Error" Message="Function names must use Verb-Noun format (e.g. Get-Thing)." ShowIcon="true" Style="margin-bottom: 16px;" />
}
else if (!string.IsNullOrWhiteSpace(_selectedFunction.Name) && !ModuleService.IsApprovedVerb(_selectedFunction.Name))
{
<Alert Type="@AlertType.Warning" Message="That verb is not an approved PowerShell verb. Run Get-Verb to see the approved list." ShowIcon="true" Style="margin-bottom: 16px;" />
}
<PowerShellScriptEditor @key="_selectedFunction" @bind-Value="_selectedFunction.Content" OnSave="Save" Height="400" />
}
</GridCol>
</GridRow>
}
</TabPane>
<TabPane Key="manifest" Tab="Manifest">
<FormItem Label="Module Name">
<Input @bind-Value="@context.Name" Disabled="@(!IsNew)" />
</FormItem>
<Flex Justify="FlexJustify.FlexEnd" Align="FlexAlign.Center" Gap="FlexGap.Small" Style="margin-bottom: 16px;">
<span>Advanced</span>
<Switch Checked="@_advancedManifest" OnChange="OnManifestModeChanged" />
</Flex>
@if (_activeTab == "manifest" && _advancedManifest)
{
<CodeInput @bind-Value="@context.Manifest" Language="Languages.PowerShell" OnSave="Save" Height="400" />
}
else if (_activeTab == "manifest")
{
<Form Model="@_manifest" Layout="@FormLayout.Vertical" Context="manifestContext">
<FormItem Label="Module Version">
<Input @bind-Value="@_manifest.ModuleVersion" />
</FormItem>
<FormItem Label="GUID">
<Input @bind-Value="@_manifest.Guid" />
</FormItem>
<FormItem Label="Author">
<Input @bind-Value="@_manifest.Author" />
</FormItem>
<FormItem Label="Description">
<TextArea @bind-Value="@_manifest.Description" />
</FormItem>
<FormItem Label="Exported Functions">
@if (ExportedFunctions.Count == 0)
{
<AntDesign.Text Type="@TextElementType.Secondary">None. Mark functions as Public to export them.</AntDesign.Text>
}
else
{
@foreach (var function in ExportedFunctions)
{
<Tag>@function</Tag>
}
}
</FormItem>
<FormItem Label="Exported Aliases">
@if (ExportedAliases.Count == 0)
{
<AntDesign.Text Type="@TextElementType.Secondary">None detected.</AntDesign.Text>
}
else
{
@foreach (var alias in ExportedAliases)
{
<Tag>@alias</Tag>
}
}
</FormItem>
</Form>
}
</TabPane>
</Tabs>
</Form>
</PageContent>
@code {
[Parameter] public string Name { get; set; }
Module Module { get; set; } = new();
ModuleManifest _manifest = new();
ModuleFunction _selectedFunction;
string _verb = string.Empty;
string _noun = string.Empty;
string[] _selectedFunctionKeys = Array.Empty<string>();
List<VerbOption> _verbOptions = new();
record VerbOption(string Verb, string Group);
string _activeTab = "functions";
bool _advancedManifest;
bool _isNew = true;
bool IsNew => _isNew;
IReadOnlyList<string> ExportedFunctions => ModuleService.GetExportedFunctions(Module);
IReadOnlyList<string> ExportedAliases => ModuleService.GetExportedAliases(Module);
protected override void OnInitialized()
{
if (!string.IsNullOrEmpty(Name))
{
var module = ModuleService.GetModule(Name);
if (module != null)
{
Module = module;
_isNew = false;
}
}
if (_isNew && Module.Functions.Count == 0)
{
Module.Functions.Add(new ModuleFunction
{
Name = "Get-Example",
Visibility = FunctionVisibility.Public,
Content = "function Get-Example {\n [CmdletBinding()]\n param()\n\n \"Hello from a LANCommander module\"\n}\n",
});
}
_verbOptions = ModuleService.GetApprovedVerbGroups()
.SelectMany(g => g.Verbs.Select(v => new VerbOption(v, g.Name)))
.ToList();
_manifest = ModuleService.ParseManifest(Module.Manifest);
SelectFunction(Module.Functions.FirstOrDefault());
}
void OnTabChanged(string key)
{
_activeTab = key;
}
string FunctionTitle(ModuleFunction function) =>
string.IsNullOrWhiteSpace(function.Name) ? "(unnamed)" : function.Name;
void AddFunction()
{
var function = new ModuleFunction
{
Name = string.Empty,
Visibility = FunctionVisibility.Public,
Content = "function Verb-Noun {\n [CmdletBinding()]\n param()\n\n}\n",
};
Module.Functions.Add(function);
SelectFunction(function);
}
void SelectFunction(ModuleFunction function)
{
_selectedFunction = function;
_selectedFunctionKeys = function == null ? Array.Empty<string>() : new[] { function.Id.ToString() };
LoadNameParts();
}
void DeleteFunction(ModuleFunction function)
{
Module.Functions.Remove(function);
if (_selectedFunction == function)
SelectFunction(Module.Functions.FirstOrDefault());
}
void LoadNameParts()
{
_verb = string.Empty;
_noun = string.Empty;
if (_selectedFunction == null || string.IsNullOrEmpty(_selectedFunction.Name))
return;
var dash = _selectedFunction.Name.IndexOf('-');
if (dash > 0)
{
_verb = _selectedFunction.Name.Substring(0, dash);
_noun = _selectedFunction.Name.Substring(dash + 1);
}
else
{
_verb = _selectedFunction.Name;
}
}
string Verb
{
get => _verb;
set { _verb = value; UpdateFunctionName(); }
}
string Noun
{
get => _noun;
set { _noun = value; UpdateFunctionName(); }
}
void OnFunctionTreeClick(TreeEventArgs<ModuleFunction> args)
{
if (args.Node.DataItem != null)
SelectFunction(args.Node.DataItem);
}
void UpdateFunctionName()
{
if (_selectedFunction == null)
return;
var name = string.IsNullOrEmpty(_noun) ? _verb : $"{_verb}-{_noun}";
_selectedFunction.Name = name;
if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(_selectedFunction.Content))
{
var declaration = new System.Text.RegularExpressions.Regex(
@"function\s+[^\s{(]+", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (declaration.IsMatch(_selectedFunction.Content))
_selectedFunction.Content = declaration.Replace(_selectedFunction.Content, $"function {name}", 1);
}
}
void OnManifestModeChanged(bool advanced)
{
if (advanced)
{
if (string.IsNullOrWhiteSpace(_manifest.RootModule))
_manifest.RootModule = $"{Module.Name}.psm1";
Module.Manifest = ModuleService.GenerateManifest(_manifest, ExportedFunctions, ExportedAliases);
}
else
{
_manifest = ModuleService.ParseManifest(Module.Manifest);
}
_advancedManifest = advanced;
}
void Save()
{
try
{
if (string.IsNullOrWhiteSpace(Module.Name))
{
MessageService.Error("Name is required.");
return;
}
if (IsNew && ModuleService.ModuleExists(Module.Name))
{
MessageService.Error("A module with that name already exists.");
return;
}
foreach (var function in Module.Functions)
{
if (!ModuleService.IsValidFunctionName(function.Name))
{
MessageService.Error($"'{function.Name}' is not a valid function name. Use Verb-Noun format.");
return;
}
}
var duplicate = Module.Functions
.GroupBy(f => f.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicate != null)
{
MessageService.Error($"Duplicate function name '{duplicate.Key}'. Function names must be unique.");
return;
}
if (!_advancedManifest)
{
if (string.IsNullOrWhiteSpace(_manifest.RootModule))
_manifest.RootModule = $"{Module.Name}.psm1";
Module.Manifest = ModuleService.GenerateManifest(_manifest, ExportedFunctions, ExportedAliases);
}
ModuleService.SaveModule(Module);
_isNew = false;
MessageService.Success("Module saved!");
NavigationManager.NavigateTo("/Scripting/Modules");
}
catch (Exception ex)
{
MessageService.Error("Could not save module!");
Logger?.LogError(ex, "Could not save module");
}
}
}

View file

@ -0,0 +1,57 @@
@page "/Scripting/Modules"
@attribute [Authorize(Roles = RoleService.AdministratorRoleName)]
@inject ModuleService ModuleService
@inject NavigationManager NavigationManager
@inject IMessageService MessageService
@inject ILogger<Index> Logger
<PageHeader Title="Modules">
<PageHeaderExtra>
<Button OnClick="@(() => NavigationManager.NavigateTo("/Scripting/Modules/Add"))" Type="@ButtonType.Primary">Add Module</Button>
</PageHeaderExtra>
</PageHeader>
<PageContent>
<Table TItem="Module" DataSource="@Modules" Size="@TableSize.Small" Context="module" Responsive>
<PropertyColumn Property="m => m.Name" Title="Name" Sortable DefaultSortOrder="SortDirection.Ascending" />
<ActionColumn Title="" Style="text-align: right">
<Flex Gap="FlexGap.Small" Align="FlexAlign.Center" Justify="FlexJustify.End">
<a href="@($"/Scripting/Modules/{module.Name}")" class="ant-btn ant-btn-primary">Edit</a>
<Popconfirm OnConfirm="() => Delete(module)" Title="Are you sure you want to delete this module?">
<Button Icon="@IconType.Outline.Close" Type="@ButtonType.Text" Danger />
</Popconfirm>
</Flex>
</ActionColumn>
</Table>
</PageContent>
@code {
IEnumerable<Module> Modules { get; set; } = new List<Module>();
protected override void OnInitialized()
{
Load();
}
void Load()
{
Modules = ModuleService.GetModules().ToList();
}
void Delete(Module module)
{
try
{
ModuleService.DeleteModule(module.Name);
Load();
MessageService.Success("Module deleted!");
}
catch (Exception ex)
{
MessageService.Error("Could not delete module!");
Logger?.LogError(ex, "Could not delete module");
}
}
}

View file

@ -0,0 +1,86 @@
@page "/Scripting/Snippets/Add"
@page "/Scripting/Snippets/{Group}/{Name}"
@attribute [Authorize(Roles = RoleService.AdministratorRoleName)]
@inject ScriptService ScriptService
@inject NavigationManager NavigationManager
@inject IMessageService MessageService
@inject ILogger<Edit> Logger
<PageHeader Title="@(IsNew ? "Add Snippet" : $"{Snippet.Group} / {Snippet.Name}")">
<PageHeaderExtra>
<Flex Gap="FlexGap.Small">
<Button OnClick="@(() => NavigationManager.NavigateTo("/Scripting/Snippets"))">Back</Button>
<Button OnClick="Save" Type="@ButtonType.Primary">Save</Button>
</Flex>
</PageHeaderExtra>
</PageHeader>
<PageContent>
<Form Model="@Snippet" Layout="@FormLayout.Vertical">
<FormItem Label="Group">
<Input @bind-Value="@context.Group" />
</FormItem>
<FormItem Label="Name">
<Input @bind-Value="@context.Name" />
</FormItem>
<FormItem Label="Content">
<PowerShellScriptEditor @bind-Value="@context.Content" OnSave="Save" Height="500" />
</FormItem>
</Form>
</PageContent>
@code {
[Parameter] public string Group { get; set; }
[Parameter] public string Name { get; set; }
Snippet Snippet { get; set; } = new();
string _originalGroup;
string _originalName;
bool IsNew => string.IsNullOrEmpty(_originalName);
protected override void OnParametersSet()
{
if (!string.IsNullOrEmpty(Name))
{
var snippet = ScriptService.GetSnippet(Group, Name);
if (snippet != null)
{
Snippet = snippet;
_originalGroup = snippet.Group;
_originalName = snippet.Name;
}
}
}
void Save()
{
try
{
if (string.IsNullOrWhiteSpace(Snippet.Group) || string.IsNullOrWhiteSpace(Snippet.Name))
{
MessageService.Error("Group and Name are required.");
return;
}
if (!IsNew && (_originalGroup != Snippet.Group || _originalName != Snippet.Name))
ScriptService.DeleteSnippet(_originalGroup, _originalName);
ScriptService.SaveSnippet(Snippet);
_originalGroup = Snippet.Group;
_originalName = Snippet.Name;
MessageService.Success("Snippet saved!");
NavigationManager.NavigateTo("/Scripting/Snippets");
}
catch (Exception ex)
{
MessageService.Error("Could not save snippet!");
Logger?.LogError(ex, "Could not save snippet");
}
}
}

View file

@ -0,0 +1,58 @@
@page "/Scripting/Snippets"
@attribute [Authorize(Roles = RoleService.AdministratorRoleName)]
@inject ScriptService ScriptService
@inject NavigationManager NavigationManager
@inject IMessageService MessageService
@inject ILogger<Index> Logger
<PageHeader Title="Snippets">
<PageHeaderExtra>
<Button OnClick="@(() => NavigationManager.NavigateTo("/Scripting/Snippets/Add"))" Type="@ButtonType.Primary">Add Snippet</Button>
</PageHeaderExtra>
</PageHeader>
<PageContent>
<Table TItem="Snippet" DataSource="@Snippets" Size="@TableSize.Small" Context="snippet" Responsive>
<PropertyColumn Property="s => s.Group" Title="Group" Sortable DefaultSortOrder="SortDirection.Ascending" />
<PropertyColumn Property="s => s.Name" Title="Name" Sortable />
<ActionColumn Title="" Style="text-align: right">
<Flex Gap="FlexGap.Small" Align="FlexAlign.Center" Justify="FlexJustify.End">
<a href="@($"/Scripting/Snippets/{snippet.Group}/{snippet.Name}")" class="ant-btn ant-btn-primary">Edit</a>
<Popconfirm OnConfirm="() => Delete(snippet)" Title="Are you sure you want to delete this snippet?">
<Button Icon="@IconType.Outline.Close" Type="@ButtonType.Text" Danger />
</Popconfirm>
</Flex>
</ActionColumn>
</Table>
</PageContent>
@code {
IEnumerable<Snippet> Snippets { get; set; } = new List<Snippet>();
protected override void OnInitialized()
{
Load();
}
void Load()
{
Snippets = ScriptService.GetSnippets().OrderBy(s => s.Group).ThenBy(s => s.Name).ToList();
}
void Delete(Snippet snippet)
{
try
{
ScriptService.DeleteSnippet(snippet.Group, snippet.Name);
Load();
MessageService.Success("Snippet deleted!");
}
catch (Exception ex)
{
MessageService.Error("Could not delete snippet!");
Logger?.LogError(ex, "Could not delete snippet");
}
}
}

View file

@ -17,12 +17,14 @@
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
[Parameter] public string? ScriptType { get; set; }
[Parameter] public IEnumerable<ModuleFunctionCompletion>? ModuleFunctions { get; set; }
static bool _completionsRegistered;
static bool _yamlCompletionsRegistered;
bool _isEditorInitialized = false;
string? _previousValue;
string? _previousScriptType;
IEnumerable<ModuleFunctionCompletion>? _previousModuleFunctions;
IJSObjectReference? _module;
StandaloneCodeEditor _editor;
@ -62,6 +64,11 @@
await RunValidationAsync();
}
if (_isEditorInitialized && _module != null && !ReferenceEquals(ModuleFunctions, _previousModuleFunctions))
{
await PushModuleFunctionsAsync();
}
await base.OnParametersSetAsync();
}
@ -99,10 +106,29 @@
if (Language.Id == "powershell")
await _module.InvokeVoidAsync("SetupFileDropHandler");
if (Language.Id == "powershell")
await PushModuleFunctionsAsync();
if (OnReady.HasDelegate)
await OnReady.InvokeAsync();
}
async Task PushModuleFunctionsAsync()
{
if (_module == null)
return;
_previousModuleFunctions = ModuleFunctions;
await _module.InvokeVoidAsync("setModuleFunctions", ModuleFunctions ?? []);
}
public async Task LayoutAsync()
{
if (_isEditorInitialized && _editor != null)
await _editor.Layout();
}
public async Task InsertText(string text)
{
var selection = await _editor.GetSelection();

View file

@ -0,0 +1,3 @@
namespace LANCommander.UI.Components;
public record ModuleFunctionCompletion(string Name, string? Synopsis, string? Module);

View file

@ -52,8 +52,15 @@ const variables: VariableDefinition[] = [
{ name: "$GameId", type: "string", description: "Game GUID identifier", scriptTypes: ["BeforeStart", "AfterStop", "GameStarted", "GameStopped"] },
];
interface ModuleFunctionDefinition {
name: string;
synopsis: string | null;
module: string | null;
}
let registered = false;
let currentScriptType: string | null = null;
let moduleFunctions: ModuleFunctionDefinition[] = [];
export function registerPowerShellCompletions(): void {
if (registered || typeof monaco === "undefined") return;
@ -67,6 +74,10 @@ export function setScriptType(scriptType: string | null): void {
currentScriptType = scriptType;
}
export function setModuleFunctions(functions: ModuleFunctionDefinition[]): void {
moduleFunctions = functions ?? [];
}
function getFilteredVariables(): VariableDefinition[] {
if (!currentScriptType) return variables;
@ -177,6 +188,18 @@ function registerCompletionProvider(): void {
// Cmdlet name completions
if (!paramMatch && !memberMatch) {
for (const fn of moduleFunctions) {
suggestions.push({
label: fn.name,
kind: monaco.languages.CompletionItemKind.Function,
insertText: fn.name,
detail: fn.module ? `Module: ${fn.module}` : "Module function",
documentation: fn.synopsis || undefined,
range,
sortText: `0_${fn.name}`,
});
}
for (let i = 0; i < allCmdlets.length; i++) {
const c = allCmdlets[i];
const isBuiltin = i >= cmdlets.length;
@ -228,6 +251,24 @@ function registerHoverProvider(): void {
],
};
}
const fn = moduleFunctions.find((f) => f.name === match![0]);
if (fn) {
const body = (fn.module ? `**Module:** ${fn.module}` : "Module function") +
(fn.synopsis ? `\n\n${fn.synopsis}` : "");
return {
range: {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: start,
endColumn: end,
},
contents: [
{ value: `**${fn.name}**` },
{ value: body },
],
};
}
}
}

View file

@ -6,7 +6,7 @@ export { SplitPane } from "./Components/SplitPane/SplitPane";
export { TimeProvider } from "./Components/LocalTime/TimeProvider";
export { Terminal } from "./Components/Terminal/Terminal";
export { DomHelper } from "./Components/DomHelper/DomHelper";
export { registerPowerShellCompletions, setScriptType, validateScript, insertSnippet, getScriptTemplate } from "./Components/MonacoCodeEditor/PowerShellCompletionProvider";
export { registerPowerShellCompletions, setScriptType, setModuleFunctions, validateScript, insertSnippet, getScriptTemplate } from "./Components/MonacoCodeEditor/PowerShellCompletionProvider";
export { registerYamlCompletions } from "./Components/MonacoCodeEditor/YamlCompletionProvider";
export { UploadManager } from "./Components/UploadManager/UploadManager";
export { SetupFileDropHandler } from "./Components/CodeInput/FileDropHandler";