diff --git a/Module/PSFavorite.psd1 b/Module/PSFavorite.psd1
index 49b3b89..8ee18d6 100644
--- a/Module/PSFavorite.psd1
+++ b/Module/PSFavorite.psd1
@@ -76,7 +76,8 @@
"Get-PSFavorites",
"Initialize-PSFavorite",
"Optimize-PSFavorites",
- "Remove-PSFavorite"
+ "Remove-PSFavorite",
+ "Unregister-PSFavoritePredictor"
)
# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
diff --git a/Module/PSFavorite.psm1 b/Module/PSFavorite.psm1
index 7d554ba..2c20281 100644
--- a/Module/PSFavorite.psm1
+++ b/Module/PSFavorite.psm1
@@ -18,3 +18,8 @@ Get-ChildItem -Path "$PSScriptRoot\Public" -Filter "*.ps1" | ForEach-Object {
# Initialize the PSFavorite module with the default configuration
Initialize-PSFavorite
+
+# Load the favorites from the configuration file if it exists
+if ($Script:FavoritesPath) {
+ [PSFavorite.PSFavoritePredictor]::Initialize($Script:FavoritesPath)
+}
diff --git a/Module/Private/Initialize-Configuration.ps1 b/Module/Private/Initialize-Configuration.ps1
index 803a212..e68ea8b 100644
--- a/Module/Private/Initialize-Configuration.ps1
+++ b/Module/Private/Initialize-Configuration.ps1
@@ -19,6 +19,9 @@ function Initialize-Configuration(
# Name of the parent folder
[string] $ModuleName = "PSFavorite",
+ # Name of the configuration file
+ [string] $FavoritesFile = "Favorites.txt",
+
# Path to the configuration file
[string] $FavoritesPath
) {
@@ -31,9 +34,7 @@ function Initialize-Configuration(
else {
# Create the PSFavorite directory if it doesn't already exist
$local = if ($IsWindows) { $Env:LOCALAPPDATA } else { "$HOME/.local/share" }
- $folder = Join-Path $local $ModuleName
- # Path to the Favorites file
- $Script:FavoritesPath = Join-Path $folder "Favorites.txt"
+ $Script:FavoritesPath = Join-Path $local $ModuleName $FavoritesFile
}
# Create the directory if it doesn't exist
diff --git a/Module/Public/Unregister-PSFavoritePredictor.ps1 b/Module/Public/Unregister-PSFavoritePredictor.ps1
new file mode 100644
index 0000000..0bb89b7
--- /dev/null
+++ b/Module/Public/Unregister-PSFavoritePredictor.ps1
@@ -0,0 +1,14 @@
+<#
+.SYNOPSIS
+ Unregister the PSFavorite predictor from PSReadLine.
+.DESCRIPTION
+ Explicitly removes the PSFavorite predictor from the PSReadLine subsystem.
+ This is safe to call multiple times and is useful for testing or when you
+ want to cleanly unload the predictor before re-importing the module.
+.EXAMPLE
+ Unregister-PSFavoritePredictor
+ Unregisters the predictor from PSReadLine.
+#>
+function Unregister-PSFavoritePredictor {
+ [PSFavorite.PSFavoritePredictor]::Unregister()
+}
diff --git a/Module/Tests/Public/PredictorInitialization.Tests.ps1 b/Module/Tests/Public/PredictorInitialization.Tests.ps1
new file mode 100644
index 0000000..1533278
--- /dev/null
+++ b/Module/Tests/Public/PredictorInitialization.Tests.ps1
@@ -0,0 +1,28 @@
+Describe "Predictor initialization on first import" {
+ Context "When initializing module with a custom favorites path" {
+ It "Creates the favorites file when given a temp path" {
+ $testRoot = Join-Path $PSScriptRoot '..\temp-' + ([guid]::NewGuid().ToString())
+ New-Item -Path $testRoot -ItemType Directory -Force | Out-Null
+
+ $favoritesFile = Join-Path $testRoot 'PSFavorite\Favorites.txt'
+
+ try {
+ # Import the module (should not throw)
+ $manifest = Join-Path (Join-Path $PSScriptRoot '..\..') 'PSFavorite.psd1'
+ Import-Module $manifest -Force -ErrorAction Stop
+
+ # Initialize using a custom favorites path under our temp folder
+ Initialize-PSFavorite -FavoritesPath $favoritesFile -ErrorAction Stop
+
+ # Assert the favorites file exists
+ Test-Path $favoritesFile | Should -BeTrue
+ }
+ finally {
+ # Cleanup: unregister predictor, remove module and temp folder
+ Unregister-PSFavoritePredictor -ErrorAction SilentlyContinue
+ Remove-Module PSFavorite -ErrorAction SilentlyContinue
+ Remove-Item -Path $testRoot -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ }
+ }
+}
diff --git a/Predictor/PSFavoritePredictor.cs b/Predictor/PSFavoritePredictor.cs
index d4b5d58..d3c87be 100644
--- a/Predictor/PSFavoritePredictor.cs
+++ b/Predictor/PSFavoritePredictor.cs
@@ -1,4 +1,9 @@
-using System.Management.Automation;
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Collections.Generic;
+using System.Management.Automation;
using System.Management.Automation.Subsystem;
using System.Management.Automation.Subsystem.Prediction;
@@ -6,8 +11,16 @@ namespace PSFavorite
{
public class PSFavoritePredictor : ICommandPredictor
{
+ ///
+ /// The unique identifier for this predictor instance.
+ /// This is set through the constructor and used for registration with the subsystem manager.
+ ///
private readonly Guid _guid;
+ ///
+ /// Initializes a new instance of the class with a specified GUID.
+ ///
+ /// The GUID to associate with this predictor instance.
internal PSFavoritePredictor(string guid)
{
_guid = new Guid(guid);
@@ -28,15 +41,87 @@ internal PSFavoritePredictor(string guid)
///
public string Description => "A predictor that uses a list of favorite commands to provide suggestions.";
+ ///
+ /// A fixed GUID to identify the predictor. This should be unique to avoid conflicts with other predictors.
+ /// This is used for registration and unregistration of the predictor with the subsystem manager.
+ ///
+ internal const string Identifier = "843b51d0-55c8-4c1a-8116-f0728d419306";
+
+ #region "Favorites"
+
///
/// The file path of the favorite commands file.
+ /// For Windows, the default path is "%LocalAppData%\PSFavorite\Favorites.txt" and for Linux/macOS, the default path is "$HOME/.local/share/PSFavorite/Favorites.txt".
+ /// The file is expected to contain one favorite command per line, and an optional description after a '#' character.
///
- private static readonly string _FavoritesFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PSFavorite", "Favorites.txt");
+ private static string _FavoritesFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PSFavorite", "Favorites.txt");
///
- /// A list of favorite commands.
+ /// A cached list of favorite commands.
+ /// Can be updated by calling LoadFavoritesIfExists, which is triggered during initialization.
///
- private readonly string[] favorites = File.ReadAllLines(_FavoritesFilePath);
+ private static string[] _favorites = Array.Empty();
+
+ ///
+ /// An object used for locking access to the favorites array to ensure thread safety.
+ /// This is necessary because the predictor may be called from multiple threads concurrently, and we want to avoid race conditions
+ /// when loading or accessing the favorites.
+ ///
+ private static readonly object _favoritesLock = new object();
+
+ ///
+ /// Initialize the predictor with an explicit favorites path.
+ /// Safe to call from PowerShell after the module's configuration is resolved.
+ ///
+ /// Full path to the favorites file.
+ public static void Initialize(string favoritesPath)
+ {
+ if (!string.IsNullOrWhiteSpace(favoritesPath))
+ {
+ _FavoritesFilePath = favoritesPath;
+ }
+
+ LoadFavoritesIfExists();
+ }
+
+ ///
+ /// Load the favorites from the file if it exists. If any error occurs, set favorites to an empty array.
+ ///
+ private static void LoadFavoritesIfExists()
+ {
+ try
+ {
+ // Check if the favorites file exists. If it does, read all lines and update the _favorites array.
+ if (File.Exists(_FavoritesFilePath))
+ {
+ lock (_favoritesLock)
+ {
+ _favorites = File.ReadAllLines(_FavoritesFilePath);
+ }
+ }
+ // ...otherwise, if the file does not exist, set _favorites to an empty array.
+ else
+ {
+ lock (_favoritesLock)
+ {
+ _favorites = Array.Empty();
+ }
+ }
+ }
+ // If any exception occurs during file access (e.g., file is locked, permission issues, etc.),
+ // catch the exception and set _favorites to an empty array to avoid crashing the predictor.
+ catch
+ {
+ lock (_favoritesLock)
+ {
+ _favorites = Array.Empty();
+ }
+ }
+ }
+
+ #endregion
+
+ #region "Suggestions"
///
/// Get the predictive suggestions. It indicates the start of a suggestion rendering session.
@@ -53,9 +138,19 @@ public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContex
{
return default;
}
+
+ string[] favoritesSnapshot;
+ lock (_favoritesLock)
+ {
+ favoritesSnapshot = _favorites;
+ }
- // Generate the list of predictive suggestions.
- List suggestions = favorites
+ if (favoritesSnapshot is null || favoritesSnapshot.Length == 0)
+ {
+ return default;
+ }
+
+ List suggestions = favoritesSnapshot
.Select(line => (Line: line, Score: DetermineScore(input, line))) // Determine the score for each line.
.Where(tuple => tuple.Score >= ScoreThreshold) // Filter out the lines below the score threshold.
.OrderByDescending(tuple => tuple.Score) // Order the list by the score in descending order.
@@ -130,6 +225,8 @@ private static string GetTooltip(string line)
}
}
+ #endregion
+
#region "interface methods for processing feedback"
///
@@ -177,7 +274,23 @@ public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList
/// Shows whether the execution was successful.
public void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) { }
- #endregion;
+ #endregion
+
+ ///
+ /// Explicitly unregister the predictor from the PSReadLine subsystem.
+ /// Safe to call multiple times; silently ignores if not currently registered.
+ ///
+ public static void Unregister()
+ {
+ try
+ {
+ SubsystemManager.UnregisterSubsystem(SubsystemKind.CommandPredictor, new Guid(Identifier));
+ }
+ catch (InvalidOperationException)
+ {
+ // Predictor was already unregistered or never registered; no-op.
+ }
+ }
}
///
@@ -185,15 +298,21 @@ public void OnCommandLineExecuted(PredictionClient client, string commandLine, b
///
public class Init : IModuleAssemblyInitializer, IModuleAssemblyCleanup
{
- private const string Identifier = "843b51d0-55c8-4c1a-8116-f0728d419306";
-
///
/// Gets called when assembly is loaded.
///
public void OnImport()
{
- var predictor = new PSFavoritePredictor(Identifier);
- SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, predictor);
+ var predictor = new PSFavoritePredictor(PSFavoritePredictor.Identifier);
+ try
+ {
+ SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, predictor);
+ }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("already registered"))
+ {
+ // The predictor may already be registered (e.g., repeated module import in the same process).
+ // Treat duplicate registration as a no-op to make initialization idempotent.
+ }
}
///
@@ -201,7 +320,7 @@ public void OnImport()
///
public void OnRemove(PSModuleInfo psModuleInfo)
{
- SubsystemManager.UnregisterSubsystem(SubsystemKind.CommandPredictor, new Guid(Identifier));
+ PSFavoritePredictor.Unregister();
}
}
}