diff --git a/PSReadLine.build.ps1 b/PSReadLine.build.ps1 index 4d54be20..2e3467a4 100644 --- a/PSReadLine.build.ps1 +++ b/PSReadLine.build.ps1 @@ -57,6 +57,23 @@ Synopsis: Build main binary module #> task BuildMainModule @binaryModuleParams { exec { dotnet publish -c $Configuration PSReadLine\PSReadLine.csproj } + + # Restructure native SQLite DLLs from NuGet runtimes/{rid}/native/ into flat {rid}/ layout + # that PowerShell's CorePsAssemblyLoadContext.NativeDllHandler expects. + $publishPath = "PSReadLine/bin/$Configuration/$targetFramework/publish" + $runtimesDir = Join-Path $publishPath 'runtimes' + if (Test-Path $runtimesDir) { + Get-ChildItem $runtimesDir -Directory | ForEach-Object { + $rid = $_.Name + $nativeDir = Join-Path $_.FullName 'native' + if (Test-Path $nativeDir) { + $dest = Join-Path $publishPath $rid + New-Item -Path $dest -ItemType Directory -Force > $null + Copy-Item (Join-Path $nativeDir '*') $dest -Force + } + } + Remove-Item $runtimesDir -Recurse -Force + } } <# @@ -110,6 +127,31 @@ task LayoutModule BuildMainModule, { Copy-Item $binPath/Microsoft.PowerShell.PSReadLine.dll $targetDir Copy-Item $binPath/Microsoft.PowerShell.Pager.dll $targetDir + # Copy SQLite managed DLLs + foreach ($dll in @( + 'Microsoft.Data.Sqlite.dll', + 'SQLitePCLRaw.core.dll', + 'SQLitePCLRaw.batteries_v2.dll', + 'SQLitePCLRaw.provider.e_sqlite3.dll' + )) { + $dllPath = Join-Path $binPath $dll + if (Test-Path $dllPath) { + Copy-Item $dllPath $targetDir + } + } + + # Copy native SQLite DLLs in flat {rid}/ layout for PowerShell's NativeDllHandler + foreach ($rid in @('win-x64','win-x86','win-arm','win-arm64','linux-x64','linux-x86','linux-arm','linux-arm64','linux-musl-x64','linux-musl-arm64','osx-x64','osx-arm64')) { + $ridSource = Join-Path $binPath $rid + if (Test-Path $ridSource) { + $ridDest = Join-Path $targetDir $rid + if (-not (Test-Path $ridDest)) { + New-Item $ridDest -ItemType Directory -Force > $null + } + Copy-Item "$ridSource/*" $ridDest -Force + } + } + if ($Configuration -eq 'Debug') { Copy-Item $binPath/*.pdb $targetDir } diff --git a/PSReadLine/Cmdlets.cs b/PSReadLine/Cmdlets.cs index 596b1548..a27e7125 100644 --- a/PSReadLine/Cmdlets.cs +++ b/PSReadLine/Cmdlets.cs @@ -62,7 +62,14 @@ public enum AddToHistoryOption { SkipAdding, MemoryOnly, - MemoryAndFile + MemoryAndFile, + SQLite + } + + public enum HistoryType + { + Text, + SQLite } public enum PredictionSource @@ -107,6 +114,12 @@ public class PSConsoleReadLineOptions public const string DefaultContinuationPrompt = ">> "; + /// + /// The default history type is text-based history. + /// Users can change default behavior by setting this to SQLite in their profile. + /// + public const HistoryType DefaultHistoryType = HistoryType.Text; + /// /// The maximum number of commands to store in the history. /// @@ -151,6 +164,14 @@ public class PSConsoleReadLineOptions public const HistorySaveStyle DefaultHistorySaveStyle = HistorySaveStyle.SaveIncrementally; + /// + /// When enabled, the SQLite-history-awareness UX (F2 list-view stats tooltip and + /// the in-prompt history navigation indicator) renders plain-text labels instead of + /// emoji icons, so screen readers don't verbalize Unicode character names like + /// "clockwise gapped circle arrow" or "card index dividers". + /// + public const bool DefaultAccessibleHistoryDisplay = false; + public const PredictionViewStyle DefaultPredictionViewStyle = PredictionViewStyle.InlineView; /// @@ -198,6 +219,12 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole) ResetColors(); EditMode = DefaultEditMode; ScreenReaderModeEnabled = Accessibility.IsScreenReaderActive(); + // Seed the accessible-history-display flag from the screen-reader state at + // construction time so screen-reader users get plain-text labels by default. + // After construction the two options are independent — toggling + // EnableScreenReaderMode later does NOT auto-flip AccessibleHistoryDisplay. + AccessibleHistoryDisplay = ScreenReaderModeEnabled; + HistoryType = DefaultHistoryType; ContinuationPrompt = DefaultContinuationPrompt; ContinuationPromptColor = Console.ForegroundColor; ExtraPromptLineCount = DefaultExtraPromptLineCount; @@ -225,13 +252,20 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole) var historyFileName = hostName + "_history.txt"; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - HistorySavePath = System.IO.Path.Combine( + HistorySavePathText = System.IO.Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Microsoft", "Windows", "PowerShell", "PSReadLine", historyFileName); + HistorySavePathSQLite = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Microsoft", + "Windows", + "PowerShell", + "PSReadLine", + hostName + "_history.db"); } else { @@ -240,11 +274,16 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole) if (!String.IsNullOrEmpty(historyPath)) { - HistorySavePath = System.IO.Path.Combine( + HistorySavePathText = System.IO.Path.Combine( historyPath, "powershell", "PSReadLine", historyFileName); + HistorySavePathSQLite = System.IO.Path.Combine( + historyPath, + "powershell", + "PSReadLine", + hostName + "_history.db"); } else { @@ -253,18 +292,26 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole) if (!String.IsNullOrEmpty(home)) { - HistorySavePath = System.IO.Path.Combine( + HistorySavePathText = System.IO.Path.Combine( home, ".local", "share", "powershell", "PSReadLine", historyFileName); + HistorySavePathSQLite = System.IO.Path.Combine( + home, + ".local", + "share", + "powershell", + "PSReadLine", + hostName + "_history.db"); } else { // No HOME, then don't save anything - HistorySavePath = "/dev/null"; + HistorySavePathText = "/dev/null"; + HistorySavePathSQLite = "/dev/null"; } } } @@ -333,6 +380,7 @@ public object ContinuationPromptColor /// that do invoke the script block - this covers the most useful cases. /// public HashSet CommandsToValidateScriptBlockArguments { get; set; } + public HistoryType HistoryType { get; set; } = HistoryType.Text; /// /// When true, duplicates will not be recalled from history more than once. @@ -369,9 +417,23 @@ public object ContinuationPromptColor public ScriptBlock ViModeChangeHandler { get; set; } /// - /// The path to the saved history. + /// The path to the text history file. + /// + public string HistorySavePathText { get; set; } + + /// + /// The path to the SQLite history database. /// - public string HistorySavePath { get; set; } + public string HistorySavePathSQLite { get; set; } + + /// + /// Returns the active history save path based on the current . + /// + public string HistorySavePath => HistoryType switch + { + HistoryType.SQLite => HistorySavePathSQLite, + _ => HistorySavePathText, + }; public HistorySaveStyle HistorySaveStyle { get; set; } /// @@ -532,6 +594,16 @@ public object ListPredictionTooltipColor public bool ScreenReaderModeEnabled { get; set; } + /// + /// When true, the SQLite-history-awareness UX renders plain-text labels + /// (e.g., Runs N | Last 2m ago | Dir <path> in the F2 stats tooltip + /// and [History 3/15] / [Location 2/5] in the navigation indicator) + /// instead of emoji icons so screen readers can read them clearly. + /// Initialized at startup from ; thereafter + /// it is independent of the screen-reader option. + /// + public bool AccessibleHistoryDisplay { get; set; } + internal string _defaultTokenColor; internal string _commentColor; internal string _keywordColor; @@ -655,6 +727,20 @@ public EditMode EditMode [AllowEmptyString] public string ContinuationPrompt { get; set; } + [Parameter] + public HistoryType HistoryType + { + get => _historyType.GetValueOrDefault(); + set + { + _historyType = value; + _historyTypeSpecified = true; + } + } + + public HistoryType? _historyType = HistoryType.Text; + internal bool _historyTypeSpecified; + [Parameter] public SwitchParameter HistoryNoDuplicates { @@ -785,15 +871,27 @@ public HistorySaveStyle HistorySaveStyle [Parameter] [ValidateNotNullOrEmpty] - public string HistorySavePath + public string HistorySavePathText { - get => _historySavePath; + get => _historySavePathText; set { - _historySavePath = GetUnresolvedProviderPathFromPSPath(value); + _historySavePathText = GetUnresolvedProviderPathFromPSPath(value); } } - private string _historySavePath; + private string _historySavePathText; + + [Parameter] + [ValidateNotNullOrEmpty] + public string HistorySavePathSQLite + { + get => _historySavePathSQLite; + set + { + _historySavePathSQLite = GetUnresolvedProviderPathFromPSPath(value); + } + } + private string _historySavePathSQLite; [Parameter] [ValidateRange(25, 1000)] @@ -854,6 +952,14 @@ public SwitchParameter EnableScreenReaderMode } internal SwitchParameter? _enableScreenReaderMode; + [Parameter] + public SwitchParameter AccessibleHistoryDisplay + { + get => _accessibleHistoryDisplay.GetValueOrDefault(); + set => _accessibleHistoryDisplay = value; + } + internal SwitchParameter? _accessibleHistoryDisplay; + [ExcludeFromCodeCoverage] protected override void EndProcessing() { diff --git a/PSReadLine/History.cs b/PSReadLine/History.cs index c1490a23..60d55f8c 100644 --- a/PSReadLine/History.cs +++ b/PSReadLine/History.cs @@ -13,7 +13,9 @@ using System.Text.RegularExpressions; using System.Threading; using System.Management.Automation.Language; +using System.Management.Automation.Runspaces; using Microsoft.PowerShell.PSReadLine; +using Microsoft.Data.Sqlite; namespace Microsoft.PowerShell { @@ -82,6 +84,17 @@ public class HistoryItem /// public bool FromHistoryFile { get; internal set; } + /// + /// The location where the command was run, if available. + /// + public string Location { get; internal set; } + + /// + /// The number of times this command has been executed (from SQLite history). + /// Defaults to 1 for text-based or in-session history. + /// + public int ExecutionCount { get; internal set; } = 1; + internal bool _saved; internal bool _sensitive; internal List _edits; @@ -98,6 +111,16 @@ public class HistoryItem private int _getNextHistoryIndex; private int _searchHistoryCommandCount; private int _recallHistoryCommandCount; + private int _locationHistoryCommandCount; + private List _locationSortedIndices; + private int _locationSortedPosition; + // True while location-mode (Alt+Up/Down) is "sticky" — plain Up/Down should + // continue to navigate the same sorted list. Cleared when the user does any + // non-history action (handled in the ReadLine main loop's anyHistory reset branch). + private bool _locationHistoryActive; + // True while we are showing the "[BOOK N/M]" history navigation status line. + // Used so the main loop knows to clear the status when the user stops navigating. + private bool _historyNavStatusActive; private int _anyHistoryCommandCount; private string _searchHistoryPrefix; // When cycling through history, the current line (not yet added to history) @@ -112,6 +135,11 @@ public class HistoryItem private const string _failedForwardISearchPrompt = "failed-fwd-i-search: "; private const string _failedBackwardISearchPrompt = "failed-bck-i-search: "; + private const string _forwardLocationISearchPrompt = "fwd-i-search (location): "; + private const string _backwardLocationISearchPrompt = "bck-i-search (location): "; + private const string _failedForwardLocationISearchPrompt = "failed-fwd-i-search (location): "; + private const string _failedBackwardLocationISearchPrompt = "failed-bck-i-search (location): "; + // Pattern used to check for sensitive inputs. private static readonly Regex s_sensitivePattern = new Regex( "password|asplaintext|token|apikey|secret", @@ -155,6 +183,11 @@ private AddToHistoryOption GetAddToHistoryOption(string line, bool fromHistoryFi return AddToHistoryOption.SkipAdding; } + if (Options.HistoryType is HistoryType.SQLite) + { + return AddToHistoryOption.SQLite; + } + if (!fromHistoryFile && Options.AddToHistoryHandler != null) { if (Options.AddToHistoryHandler == PSConsoleReadLineOptions.DefaultAddToHistoryHandler) @@ -197,10 +230,217 @@ private AddToHistoryOption GetAddToHistoryOption(string line, bool fromHistoryFi return AddToHistoryOption.MemoryAndFile; } + private void InitializeSQLiteDatabase(bool migrateTextHistory = false) + { + string baseConnectionString = $"Data Source={_options.HistorySavePath}"; + var connectionString = new SqliteConnectionStringBuilder(baseConnectionString) + { + Mode = SqliteOpenMode.ReadWriteCreate + }.ToString(); + + try + { + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + // Check if the "Commands" table exists (our primary table for new schema) + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT name +FROM sqlite_master +WHERE type='table' AND name=@TableName"; + command.Parameters.AddWithValue("@TableName", "Commands"); + + var result = command.ExecuteScalar(); + bool isNewDatabase = result == null; + + // If the table doesn't exist, create the normalized schema + if (isNewDatabase) + { + using var createTablesCommand = connection.CreateCommand(); + createTablesCommand.CommandText = @" +-- Table for storing unique command lines +CREATE TABLE Commands ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + CommandLine TEXT NOT NULL UNIQUE, + CommandHash TEXT NOT NULL UNIQUE +); + +-- Table for storing unique locations +CREATE TABLE Locations ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Path TEXT NOT NULL UNIQUE +); + +-- Table for storing execution history with foreign keys +CREATE TABLE ExecutionHistory ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + CommandId INTEGER NOT NULL, + LocationId INTEGER NOT NULL, + StartTime INTEGER NOT NULL, + ElapsedTime INTEGER NOT NULL, + ExecutionCount INTEGER DEFAULT 1, + LastExecuted INTEGER NOT NULL, + FOREIGN KEY (CommandId) REFERENCES Commands(Id), + FOREIGN KEY (LocationId) REFERENCES Locations(Id), + UNIQUE(CommandId, LocationId) +); + +-- Create indexes for optimal performance +CREATE INDEX idx_commands_hash ON Commands(CommandHash); +CREATE INDEX idx_locations_path ON Locations(Path); +CREATE INDEX idx_execution_last_executed ON ExecutionHistory(LastExecuted DESC); +CREATE INDEX idx_execution_count ON ExecutionHistory(ExecutionCount DESC); +CREATE INDEX idx_execution_location_time ON ExecutionHistory(LocationId, LastExecuted DESC); + +-- Create a view for easy querying (mimics the old single-table structure) +CREATE VIEW HistoryView AS +SELECT + eh.Id, + c.CommandLine, + c.CommandHash, + l.Path as Location, + eh.StartTime, + eh.ElapsedTime, + eh.ExecutionCount, + eh.LastExecuted +FROM ExecutionHistory eh +JOIN Commands c ON eh.CommandId = c.Id +JOIN Locations l ON eh.LocationId = l.Id;"; + createTablesCommand.ExecuteNonQuery(); + + // Only migrate text history on initial Text -> SQLite switch, + // not when relocating an existing SQLite database. + if (migrateTextHistory) + { + MigrateTextHistoryToSQLite(connection); + } + } + } + catch (SqliteException ex) + { + Console.WriteLine($"SQLite error initializing database: {ex.Message}"); + } + catch (Exception ex) + { + Console.WriteLine($"Error initializing SQLite database: {ex.Message}"); + } + } + + private void MigrateTextHistoryToSQLite(SqliteConnection connection) + { + // Use the dedicated text history path — no need to derive it from the SQLite path. + string textHistoryPath = _options.HistorySavePathText; + if (string.IsNullOrEmpty(textHistoryPath) || !File.Exists(textHistoryPath)) + { + return; // No text history to migrate + } + + try + { + // Read existing text history using the existing connection (don't open a new one) + var historyLines = ReadHistoryLinesImpl(textHistoryPath, int.MaxValue); + var historyItems = new List(); + + // Convert text lines to HistoryItems + var sb = new StringBuilder(); + foreach (var line in historyLines) + { + if (line.EndsWith("`", StringComparison.Ordinal)) + { + sb.Append(line, 0, line.Length - 1); + sb.Append('\n'); + } + else if (sb.Length > 0) + { + sb.Append(line); + historyItems.Add(new HistoryItem + { + CommandLine = sb.ToString(), + ApproximateElapsedTime = TimeSpan.Zero, + Location = "Unknown" + }); + sb.Clear(); + } + else + { + historyItems.Add(new HistoryItem + { + CommandLine = line, + ApproximateElapsedTime = TimeSpan.Zero, + Location = "Unknown" + }); + } + } + + // Assign timestamps so that: + // 1. All migrated items are older than any future SQLite entry + // 2. The first text line (oldest) gets the earliest timestamp + // 3. The last text line (newest) gets the latest migrated timestamp + // Each item is spaced 1 minute apart, ending 2 minutes before "now". + var migrationBase = DateTime.UtcNow.AddMinutes(-(historyItems.Count + 1)); + for (int idx = 0; idx < historyItems.Count; idx++) + { + historyItems[idx].StartTime = migrationBase.AddMinutes(idx); + } + + // Insert into SQLite database using the new normalized schema + using var transaction = connection.BeginTransaction(); + + foreach (var item in historyItems) + { + try + { + // Generate command hash using SHA256 + string commandHash = ComputeCommandHash(item.CommandLine); + string location = item.Location ?? "Unknown"; + + // Get or create command and location IDs + long commandId = GetOrCreateCommandId(connection, item.CommandLine, commandHash); + long locationId = GetOrCreateLocationId(connection, location); + + // Convert DateTime to Unix timestamp (INTEGER) + long startTimeUnix = ((DateTimeOffset)item.StartTime).ToUnixTimeSeconds(); + long lastExecutedUnix = startTimeUnix; + + // Insert or update execution history using the new schema + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = @" +INSERT INTO ExecutionHistory (CommandId, LocationId, StartTime, ElapsedTime, ExecutionCount, LastExecuted) +VALUES (@CommandId, @LocationId, @StartTime, @ElapsedTime, 1, @LastExecuted) +ON CONFLICT(CommandId, LocationId) DO UPDATE SET + ExecutionCount = ExecutionCount + 1, + LastExecuted = excluded.LastExecuted"; + + command.Parameters.AddWithValue("@CommandId", commandId); + command.Parameters.AddWithValue("@LocationId", locationId); + command.Parameters.AddWithValue("@StartTime", startTimeUnix); + command.Parameters.AddWithValue("@ElapsedTime", item.ApproximateElapsedTime.Ticks); + command.Parameters.AddWithValue("@LastExecuted", lastExecutedUnix); + command.ExecuteNonQuery(); + } + catch (Exception itemEx) + { + Console.WriteLine($"Error migrating history item: {itemEx.Message}"); + // Continue with next item + } + } + + transaction.Commit(); + Console.WriteLine($"Migrated {historyItems.Count} history items from text file to SQLite"); + } + catch (Exception ex) + { + Console.WriteLine($"Error migrating text history: {ex.Message}"); + } + } + private string MaybeAddToHistory( string result, List edits, int undoEditIndex, + string location = null, bool fromDifferentSession = false, bool fromInitialRead = false) { @@ -216,6 +456,7 @@ private string MaybeAddToHistory( _undoEditIndex = undoEditIndex, _editGroupStart = -1, _saved = fromHistoryFile, + Location = location ?? _engineIntrinsics?.SessionState?.Path?.CurrentLocation?.Path ?? "Unknown", FromOtherSession = fromDifferentSession, FromHistoryFile = fromInitialRead, }; @@ -277,12 +518,170 @@ private void IncrementalHistoryWrite() i -= 1; } - WriteHistoryRange(i + 1, _history.Count - 1, overwritten: false); + if (_options.HistoryType == HistoryType.Text) + { + WriteHistoryRange(i + 1, _history.Count - 1, overwritten: false); + } + + if (_options.HistoryType == HistoryType.SQLite) + { + WriteHistoryToSQLite(i + 1, _history.Count - 1); + } + } + + // Helper method to get or create a command ID + private long GetOrCreateCommandId(SqliteConnection connection, string commandLine, string commandHash) + { + // First try to get existing command + using var selectCommand = connection.CreateCommand(); + selectCommand.CommandText = "SELECT Id FROM Commands WHERE CommandHash = @CommandHash"; + selectCommand.Parameters.AddWithValue("@CommandHash", commandHash); + + var existingId = selectCommand.ExecuteScalar(); + if (existingId != null) + { + return Convert.ToInt64(existingId); + } + + // Insert new command + using var insertCommand = connection.CreateCommand(); + insertCommand.CommandText = @" +INSERT INTO Commands (CommandLine, CommandHash) +VALUES (@CommandLine, @CommandHash)"; + insertCommand.Parameters.AddWithValue("@CommandLine", commandLine); + insertCommand.Parameters.AddWithValue("@CommandHash", commandHash); + insertCommand.ExecuteNonQuery(); + + // Get the inserted row ID + using var lastIdCommand = connection.CreateCommand(); + lastIdCommand.CommandText = "SELECT last_insert_rowid()"; + return Convert.ToInt64(lastIdCommand.ExecuteScalar()); + } + + // Helper method to get or create a location ID + private long GetOrCreateLocationId(SqliteConnection connection, string location) + { + // First try to get existing location + using var selectCommand = connection.CreateCommand(); + selectCommand.CommandText = "SELECT Id FROM Locations WHERE Path = @Path"; + selectCommand.Parameters.AddWithValue("@Path", location); + + var existingId = selectCommand.ExecuteScalar(); + if (existingId != null) + { + return Convert.ToInt64(existingId); + } + + // Insert new location + using var insertCommand = connection.CreateCommand(); + insertCommand.CommandText = @" +INSERT INTO Locations (Path) +VALUES (@Path)"; + insertCommand.Parameters.AddWithValue("@Path", location); + insertCommand.ExecuteNonQuery(); + + // Get the inserted row ID + using var lastIdCommand = connection.CreateCommand(); + lastIdCommand.CommandText = "SELECT last_insert_rowid()"; + return Convert.ToInt64(lastIdCommand.ExecuteScalar()); + } + + private void WriteHistoryToSQLite(int start, int end) + { + _historyFileMutex ??= new Mutex(false, GetHistorySaveFileMutexName()); + + WithHistoryFileMutexDo(1000, () => + { + try + { + string baseConnectionString = $"Data Source={_options.HistorySavePath}"; + var connectionString = new SqliteConnectionStringBuilder(baseConnectionString) + { + Mode = SqliteOpenMode.ReadWrite + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var transaction = connection.BeginTransaction(); + + for (var i = start; i <= end; i++) + { + var item = _history[i]; + item._saved = true; + + if (item._sensitive) + { + continue; + } + + // Generate command hash using SHA256 + string commandHash = ComputeCommandHash(item.CommandLine); + string location = item.Location ?? _engineIntrinsics?.SessionState?.Path?.CurrentLocation?.Path ?? "Unknown"; + + // Get or create command and location IDs + long commandId = GetOrCreateCommandId(connection, item.CommandLine, commandHash); + long locationId = GetOrCreateLocationId(connection, location); + + // Convert DateTime to Unix timestamp (INTEGER) + long startTimeUnix = ((DateTimeOffset)item.StartTime).ToUnixTimeSeconds(); + long lastExecutedUnix = ((DateTimeOffset)DateTime.UtcNow).ToUnixTimeSeconds(); + + // Insert or update execution history + using var command = connection.CreateCommand(); + command.CommandText = @" +INSERT INTO ExecutionHistory (CommandId, LocationId, StartTime, ElapsedTime, ExecutionCount, LastExecuted) +VALUES (@CommandId, @LocationId, @StartTime, @ElapsedTime, 1, @LastExecuted) +ON CONFLICT(CommandId, LocationId) DO UPDATE SET + ExecutionCount = ExecutionCount + 1, + LastExecuted = excluded.LastExecuted, + ElapsedTime = excluded.ElapsedTime"; + + command.Parameters.AddWithValue("@CommandId", commandId); + command.Parameters.AddWithValue("@LocationId", locationId); + command.Parameters.AddWithValue("@StartTime", startTimeUnix); + command.Parameters.AddWithValue("@ElapsedTime", item.ApproximateElapsedTime.Ticks); + command.Parameters.AddWithValue("@LastExecuted", lastExecutedUnix); + command.ExecuteNonQuery(); + + // Read back the total ExecutionCount across all locations so the in-memory item stays in sync + using var countCmd = connection.CreateCommand(); + countCmd.Transaction = transaction; + countCmd.CommandText = "SELECT SUM(ExecutionCount) FROM ExecutionHistory WHERE CommandId = @CommandId"; + countCmd.Parameters.AddWithValue("@CommandId", commandId); + var count = countCmd.ExecuteScalar(); + if (count != null) + { + item.ExecutionCount = Convert.ToInt32(count); + } + } + + transaction.Commit(); + } + catch (Exception e) + { + ReportHistoryFileError(e); + } + }); + } + + private static string ComputeCommandHash(string command) + { + using var sha256 = System.Security.Cryptography.SHA256.Create(); + byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(command)); + return BitConverter.ToString(hashBytes).Replace("-", ""); } private void SaveHistoryAtExit() { - WriteHistoryRange(0, _history.Count - 1, overwritten: true); + if (_options.HistoryType == HistoryType.SQLite) + { + WriteHistoryToSQLite(0, _history.Count - 1); + } + else + { + WriteHistoryRange(0, _history.Count - 1, overwritten: true); + } } private int historyErrorReportedCount; @@ -411,6 +810,7 @@ private void WriteHistoryRange(int start, int end, bool overwritten) /// private List ReadHistoryFileIncrementally() { + // Read history from a text file var fileInfo = new FileInfo(Options.HistorySavePath); if (fileInfo.Exists && fileInfo.Length != _historyFileLastSavedSize) { @@ -433,22 +833,209 @@ private List ReadHistoryFileIncrementally() return null; } + private List ReadHistorySQLiteIncrementally() + { + var historyItems = new List(); + try + { + string baseConnectionString = $"Data Source={_options.HistorySavePath}"; + var connectionString = new SqliteConnectionStringBuilder(baseConnectionString) + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using (var command = connection.CreateCommand()) + { + // Use the HistoryView to get all the joined data, filtering by ExecutionHistory.Id. + // ExecutionCount is the SUM across all locations (total runs). + command.CommandText = @" +SELECT hv.CommandLine, hv.StartTime, hv.ElapsedTime, hv.Location, + (SELECT SUM(eh2.ExecutionCount) FROM ExecutionHistory eh2 + JOIN Commands c2 ON eh2.CommandId = c2.Id + WHERE c2.CommandLine = hv.CommandLine) AS TotalExecutionCount +FROM HistoryView hv +WHERE hv.Id > @LastId +ORDER BY hv.Id ASC"; + command.Parameters.AddWithValue("@LastId", _historyFileLastSavedSize); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var item = new HistoryItem + { + CommandLine = reader.GetString(0), + StartTime = DateTimeOffset.FromUnixTimeSeconds(reader.GetInt64(1)).DateTime, + ApproximateElapsedTime = TimeSpan.FromTicks(reader.GetInt64(2)), + Location = reader.GetString(3), + ExecutionCount = reader.GetInt32(4), + FromHistoryFile = true, + FromOtherSession = true, + _saved = true, + _edits = new List { EditItemInsertString.Create(reader.GetString(0), 0) }, + _undoEditIndex = 1, + _editGroupStart = -1 + }; + historyItems.Add(item); + } + } + + // Update the last saved size to the latest ID in the ExecutionHistory table + using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT MAX(Id) FROM ExecutionHistory"; + var result = command.ExecuteScalar(); + if (result != DBNull.Value) + { + _historyFileLastSavedSize = Convert.ToInt64(result); + } + } + } + catch (SqliteException ex) + { + Console.WriteLine($"SQLite error reading history: {ex.Message}"); + } + catch (Exception ex) + { + Console.WriteLine($"Error reading history from SQLite: {ex.Message}"); + } + + return historyItems.Count > 0 ? historyItems : null; + } + private bool MaybeReadHistoryFile() { if (Options.HistorySaveStyle == HistorySaveStyle.SaveIncrementally) { return WithHistoryFileMutexDo(1000, () => { - List historyLines = ReadHistoryFileIncrementally(); - if (historyLines != null) + if (_options.HistoryType == HistoryType.SQLite) { - UpdateHistoryFromFile(historyLines, fromDifferentSession: true, fromInitialRead: false); + List historyItems = ReadHistorySQLiteIncrementally(); + if (historyItems != null) + { + foreach (var item in historyItems) + { + _history.Enqueue(item); + _currentHistoryIndex = _history.Count; + } + } + } + else + { + List historyLines = ReadHistoryFileIncrementally(); + if (historyLines != null) + { + UpdateHistoryFromFile(historyLines, fromDifferentSession: true, fromInitialRead: false); + } } }); } // true means no errors, not that we actually read the file return true; +} + + private void ReadSQLiteHistory(bool fromOtherSession) + { + _historyFileMutex ??= new Mutex(false, GetHistorySaveFileMutexName()); + + WithHistoryFileMutexDo(1000, () => + { + try + { + string baseConnectionString = $"Data Source={_options.HistorySavePath}"; + var connectionString = new SqliteConnectionStringBuilder(baseConnectionString) + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var command = connection.CreateCommand(); + + int limit = Options.MaximumHistoryCount switch + { + <= 10000 => 10000, // Similar to 0.5MB text optimization + <= 20000 => 20000, // Similar to 1MB text optimization + _ => Options.MaximumHistoryCount + }; + + // Load history in chronological order, deduplicated by CommandLine. + // When the same command exists at multiple locations, keep the most + // recently executed entry so that basic Up/Down recall is simple + // reverse-chronological navigation with no duplicates. + // ExecutionCount is the SUM across all locations (total runs). + command.CommandText = @" +WITH Ranked AS ( + SELECT CommandLine, StartTime, ElapsedTime, Location, ExecutionCount, LastExecuted, + ROW_NUMBER() OVER (PARTITION BY CommandLine ORDER BY LastExecuted DESC) AS rn + FROM HistoryView +), +TotalCounts AS ( + SELECT c.CommandLine, SUM(eh.ExecutionCount) AS TotalExecutionCount + FROM ExecutionHistory eh + JOIN Commands c ON eh.CommandId = c.Id + GROUP BY c.CommandLine +) +SELECT r.CommandLine, r.StartTime, r.ElapsedTime, r.Location, tc.TotalExecutionCount +FROM Ranked r +JOIN TotalCounts tc ON r.CommandLine = tc.CommandLine +WHERE r.rn = 1 +ORDER BY r.LastExecuted DESC +LIMIT @Limit"; + command.Parameters.AddWithValue("@Limit", limit); + + var historyItems = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var item = new HistoryItem + { + CommandLine = reader.GetString(0), + StartTime = DateTimeOffset.FromUnixTimeSeconds(reader.GetInt64(1)).DateTime, + ApproximateElapsedTime = TimeSpan.FromTicks(reader.GetInt64(2)), + Location = reader.GetString(3), + ExecutionCount = reader.GetInt32(4), + FromHistoryFile = true, + FromOtherSession = fromOtherSession, + _saved = true, + _edits = new List { EditItemInsertString.Create(reader.GetString(0), 0) }, + _undoEditIndex = 1, + _editGroupStart = -1 + }; + + historyItems.Add(item); + } + + historyItems.Reverse(); + + foreach (var item in historyItems) + { + _history.Enqueue(item); + } + + // Update the last saved size to the latest ID in the database + using var idCommand = connection.CreateCommand(); + idCommand.CommandText = "SELECT MAX(Id) FROM ExecutionHistory"; + var result = idCommand.ExecuteScalar(); + if (result != DBNull.Value) + { + _historyFileLastSavedSize = Convert.ToInt64(result); + } + } + catch (SqliteException ex) + { + ReportHistoryFileError(ex); + } + catch (Exception ex) + { + ReportHistoryFileError(ex); + } + }); } private void ReadHistoryFile() @@ -463,54 +1050,54 @@ private void ReadHistoryFile() _historyFileLastSavedSize = fileInfo.Length; }); } + } - static IEnumerable ReadHistoryLinesImpl(string path, int historyCount) + private IEnumerable ReadHistoryLinesImpl(string path, int historyCount) + { + const long offset_1mb = 1048576; + const long offset_05mb = 524288; + + // 1mb content contains more than 34,000 history lines for a typical usage, which should be + // more than enough to cover 20,000 history records (a history record could be a multi-line + // command). Similarly, 0.5mb content should be enough to cover 10,000 history records. + // We optimize the file reading when the history count falls in those ranges. If the history + // count is even larger, which should be very rare, we just read all lines. + long offset = historyCount switch { - const long offset_1mb = 1048576; - const long offset_05mb = 524288; + <= 10000 => offset_05mb, + <= 20000 => offset_1mb, + _ => 0, + }; - // 1mb content contains more than 34,000 history lines for a typical usage, which should be - // more than enough to cover 20,000 history records (a history record could be a multi-line - // command). Similarly, 0.5mb content should be enough to cover 10,000 history records. - // We optimize the file reading when the history count falls in those ranges. If the history - // count is even larger, which should be very rare, we just read all lines. - long offset = historyCount switch - { - <= 10000 => offset_05mb, - <= 20000 => offset_1mb, - _ => 0, - }; + using var fs = new FileStream(path, FileMode.Open); + using var sr = new StreamReader(fs); - using var fs = new FileStream(path, FileMode.Open); - using var sr = new StreamReader(fs); - - if (offset > 0 && fs.Length > offset) - { - // When the file size is larger than the offset, we only read that amount of content from the end. - fs.Seek(-offset, SeekOrigin.End); + if (offset > 0 && fs.Length > offset) + { + // When the file size is larger than the offset, we only read that amount of content from the end. + fs.Seek(-offset, SeekOrigin.End); - // After seeking, the current position may point at the middle of a history record, or even at a - // byte within a UTF-8 character (history file is saved with UTF-8 encoding). So, let's ignore the - // first line read from that position. - sr.ReadLine(); + // After seeking, the current position may point at the middle of a history record, or even at a + // byte within a UTF-8 character (history file is saved with UTF-8 encoding). So, let's ignore the + // first line read from that position. + sr.ReadLine(); - string line; - while ((line = sr.ReadLine()) is not null) + string line; + while ((line = sr.ReadLine()) is not null) + { + if (!line.EndsWith("`", StringComparison.Ordinal)) { - if (!line.EndsWith("`", StringComparison.Ordinal)) - { - // A complete history record is guaranteed to start from the next line. - break; - } + // A complete history record is guaranteed to start from the next line. + break; } } + } - // Read lines in the streaming way, so it won't consume to much memory even if we have to - // read all lines from a large history file. - while (!sr.EndOfStream) - { - yield return sr.ReadLine(); - } + // Read lines in the streaming way, so it won't consume to much memory even if we have to + // read all lines from a large history file. + while (!sr.EndOfStream) + { + yield return sr.ReadLine(); } } @@ -529,13 +1116,13 @@ void UpdateHistoryFromFile(IEnumerable historyLines, bool fromDifferentS sb.Append(line); var l = sb.ToString(); var editItems = new List {EditItemInsertString.Create(l, 0)}; - MaybeAddToHistory(l, editItems, 1, fromDifferentSession, fromInitialRead); + MaybeAddToHistory(l, editItems, 1, null, fromDifferentSession, fromInitialRead); sb.Clear(); } else { var editItems = new List {EditItemInsertString.Create(line, 0)}; - MaybeAddToHistory(line, editItems, 1, fromDifferentSession, fromInitialRead); + MaybeAddToHistory(line, editItems, 1, null, fromDifferentSession, fromInitialRead); } } } @@ -808,172 +1395,899 @@ public static void AddToHistory(string command) } /// - /// Clears history in PSReadLine. This does not affect PowerShell history. + /// Add a command to the history with a specified location. /// - public static void ClearHistory(ConsoleKeyInfo? key = null, object arg = null) + internal static void AddToHistory(string command, string location) { - _singleton._history?.Clear(); - _singleton._recentHistory?.Clear(); - _singleton._currentHistoryIndex = 0; + command = command.Replace("\r\n", "\n"); + var editItems = new List {EditItemInsertString.Create(command, 0)}; + _singleton.MaybeAddToHistory(command, editItems, 1, location: location); } /// - /// Return a collection of history items. + /// Remove a specific command from history (both in-memory and SQLite if applicable). + /// Returns true if any items were removed. /// - public static HistoryItem[] GetHistoryItems() + public static bool RemoveHistoryItem(string commandLine) { - return _singleton._history.ToArray(); - } + if (string.IsNullOrEmpty(commandLine)) + return false; - enum HistoryMoveCursor { ToEnd, ToBeginning, DontMove } + bool removed = false; - private void UpdateFromHistory(HistoryMoveCursor moveCursor) - { - string line; - if (_currentHistoryIndex == _history.Count) - { - line = _savedCurrentLine.CommandLine; - _edits = new List(_savedCurrentLine._edits); - _undoEditIndex = _savedCurrentLine._undoEditIndex; - _editGroupStart = _savedCurrentLine._editGroupStart; - } - else + // Remove from in-memory history + var history = _singleton._history; + if (history != null) { - line = _history[_currentHistoryIndex].CommandLine; - _edits = new List(_history[_currentHistoryIndex]._edits); - _undoEditIndex = _history[_currentHistoryIndex]._undoEditIndex; - _editGroupStart = _history[_currentHistoryIndex]._editGroupStart; - } - _buffer.Clear(); - _buffer.Append(line); + var itemsToKeep = new List(); + for (int i = 0; i < history.Count; i++) + { + if (!string.Equals(history[i].CommandLine, commandLine, StringComparison.Ordinal)) + { + itemsToKeep.Add(history[i]); + } + else + { + removed = true; + } + } - switch (moveCursor) - { - case HistoryMoveCursor.ToEnd: - _current = Math.Max(0, _buffer.Length + ViEndOfLineFactor); - break; - case HistoryMoveCursor.ToBeginning: - _current = 0; - break; - default: - if (_current > _buffer.Length) + if (removed) + { + history.Clear(); + foreach (var item in itemsToKeep) { - _current = Math.Max(0, _buffer.Length + ViEndOfLineFactor); + history.Enqueue(item); } - break; + _singleton._currentHistoryIndex = history.Count; + } } - using var _ = _prediction.DisableScoped(); - Render(); + // Remove from SQLite database if using SQLite history + if (_singleton._options?.HistoryType == HistoryType.SQLite && + !string.IsNullOrEmpty(_singleton._options.HistorySavePath)) + { + removed |= _singleton.RemoveFromSQLiteHistory(commandLine); + } + + return removed; } - private void SaveCurrentLine() + private bool RemoveFromSQLiteHistory(string commandLine) { - // We're called before any history operation - so it's convenient - // to check if we need to load history from another sessions now. - MaybeReadHistoryFile(); - - _anyHistoryCommandCount += 1; - if (_savedCurrentLine.CommandLine == null) + try { - _savedCurrentLine.CommandLine = _buffer.ToString(); - _savedCurrentLine._edits = _edits; - _savedCurrentLine._undoEditIndex = _undoEditIndex; + string baseConnectionString = $"Data Source={_options.HistorySavePath}"; + var connectionString = new SqliteConnectionStringBuilder(baseConnectionString) + { + Mode = SqliteOpenMode.ReadWrite + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + // Find the command ID + using var findCmd = connection.CreateCommand(); + findCmd.CommandText = "SELECT Id FROM Commands WHERE CommandLine = @CommandLine"; + findCmd.Parameters.AddWithValue("@CommandLine", commandLine); + var commandIdObj = findCmd.ExecuteScalar(); + + if (commandIdObj == null) + return false; + + long commandId = Convert.ToInt64(commandIdObj); + + using var transaction = connection.BeginTransaction(); + + // Delete execution history entries first (foreign key constraint) + using var deleteEH = connection.CreateCommand(); + deleteEH.CommandText = "DELETE FROM ExecutionHistory WHERE CommandId = @CommandId"; + deleteEH.Parameters.AddWithValue("@CommandId", commandId); + deleteEH.ExecuteNonQuery(); + + // Delete the command itself + using var deleteCmd = connection.CreateCommand(); + deleteCmd.CommandText = "DELETE FROM Commands WHERE Id = @CommandId"; + deleteCmd.Parameters.AddWithValue("@CommandId", commandId); + int deletedRows = deleteCmd.ExecuteNonQuery(); + + transaction.Commit(); + return deletedRows > 0; + } + catch (Exception) + { + return false; + } + } + + /// + /// Remove a specific command from history scoped to a single location (both in-memory and SQLite if applicable). + /// In-memory items are removed only when both CommandLine and Location match; in SQLite the + /// ExecutionHistory row for the matching (Command, Location) pair is deleted, and the + /// Commands row is dropped only when no other location still references it. + /// Returns true if any items were removed. + /// + public static bool RemoveHistoryItemAtLocation(string commandLine, string location) + { + if (string.IsNullOrEmpty(commandLine) || string.IsNullOrEmpty(location)) + return false; + + bool removed = false; + + // Remove from in-memory history — only items whose Location matches. + var history = _singleton._history; + if (history != null) + { + var itemsToKeep = new List(); + for (int i = 0; i < history.Count; i++) + { + var item = history[i]; + if (string.Equals(item.CommandLine, commandLine, StringComparison.Ordinal) && + string.Equals(item.Location, location, StringComparison.Ordinal)) + { + removed = true; + } + else + { + itemsToKeep.Add(item); + } + } + + if (removed) + { + history.Clear(); + foreach (var item in itemsToKeep) + { + history.Enqueue(item); + } + _singleton._currentHistoryIndex = history.Count; + } + } + + // Remove from SQLite database if using SQLite history + if (_singleton._options?.HistoryType == HistoryType.SQLite && + !string.IsNullOrEmpty(_singleton._options.HistorySavePath)) + { + removed |= _singleton.RemoveFromSQLiteHistoryAtLocation(commandLine, location); + } + + return removed; + } + + private bool RemoveFromSQLiteHistoryAtLocation(string commandLine, string location) + { + try + { + string baseConnectionString = $"Data Source={_options.HistorySavePath}"; + var connectionString = new SqliteConnectionStringBuilder(baseConnectionString) + { + Mode = SqliteOpenMode.ReadWrite + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + // Find the command ID + using var findCmd = connection.CreateCommand(); + findCmd.CommandText = "SELECT Id FROM Commands WHERE CommandLine = @CommandLine"; + findCmd.Parameters.AddWithValue("@CommandLine", commandLine); + var commandIdObj = findCmd.ExecuteScalar(); + if (commandIdObj == null) + return false; + long commandId = Convert.ToInt64(commandIdObj); + + // Find the location ID. If the location doesn't exist in the DB, there's nothing to delete. + using var findLoc = connection.CreateCommand(); + findLoc.CommandText = "SELECT Id FROM Locations WHERE Path = @Path"; + findLoc.Parameters.AddWithValue("@Path", location); + var locationIdObj = findLoc.ExecuteScalar(); + if (locationIdObj == null) + return false; + long locationId = Convert.ToInt64(locationIdObj); + + using var transaction = connection.BeginTransaction(); + + // Delete the ExecutionHistory row for this (Command, Location) only. + using var deleteEH = connection.CreateCommand(); + deleteEH.CommandText = "DELETE FROM ExecutionHistory WHERE CommandId = @CommandId AND LocationId = @LocationId"; + deleteEH.Parameters.AddWithValue("@CommandId", commandId); + deleteEH.Parameters.AddWithValue("@LocationId", locationId); + int deletedRows = deleteEH.ExecuteNonQuery(); + + if (deletedRows == 0) + { + transaction.Rollback(); + return false; + } + + // If no other location still references this command, drop the Commands row too + // so it stops appearing in global queries. + using var orphanCheck = connection.CreateCommand(); + orphanCheck.CommandText = "SELECT COUNT(*) FROM ExecutionHistory WHERE CommandId = @CommandId"; + orphanCheck.Parameters.AddWithValue("@CommandId", commandId); + long remaining = Convert.ToInt64(orphanCheck.ExecuteScalar()); + if (remaining == 0) + { + using var deleteCmd = connection.CreateCommand(); + deleteCmd.CommandText = "DELETE FROM Commands WHERE Id = @CommandId"; + deleteCmd.Parameters.AddWithValue("@CommandId", commandId); + deleteCmd.ExecuteNonQuery(); + } + + transaction.Commit(); + return true; + } + catch (Exception) + { + return false; + } + } + + /// + /// Query per-location execution counts for all commands at the given location. + /// Returns a dictionary mapping CommandLine -> ExecutionCount for that location. + /// + private Dictionary GetLocationExecutionCounts(string location) + { + var counts = new Dictionary(StringComparer.Ordinal); + try + { + string baseConnectionString = $"Data Source={_options.HistorySavePath}"; + var connectionString = new SqliteConnectionStringBuilder(baseConnectionString) + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + SELECT c.CommandLine, eh.ExecutionCount + FROM ExecutionHistory eh + JOIN Commands c ON eh.CommandId = c.Id + JOIN Locations l ON eh.LocationId = l.Id + WHERE l.Path = @Location"; + cmd.Parameters.AddWithValue("@Location", location); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + counts[reader.GetString(0)] = reader.GetInt64(1); + } + } + catch (Exception) + { + // On failure, return empty — caller falls back to total ExecutionCount + } + return counts; + } + + /// + /// Clears history in PSReadLine. This does not affect PowerShell history. + /// + public static void ClearHistory(ConsoleKeyInfo? key = null, object arg = null) + { + _singleton._history?.Clear(); + _singleton._recentHistory?.Clear(); + _singleton._currentHistoryIndex = 0; + } + + /// + /// Return a collection of history items. + /// + public static HistoryItem[] GetHistoryItems() + { + return _singleton._history.ToArray(); + } + + enum HistoryMoveCursor { ToEnd, ToBeginning, DontMove } + + private void UpdateFromHistory(HistoryMoveCursor moveCursor) + { + string line; + if (_currentHistoryIndex == _history.Count) + { + line = _savedCurrentLine.CommandLine; + _edits = new List(_savedCurrentLine._edits); + _undoEditIndex = _savedCurrentLine._undoEditIndex; + _editGroupStart = _savedCurrentLine._editGroupStart; + } + else + { + line = _history[_currentHistoryIndex].CommandLine; + _edits = new List(_history[_currentHistoryIndex]._edits); + _undoEditIndex = _history[_currentHistoryIndex]._undoEditIndex; + _editGroupStart = _history[_currentHistoryIndex]._editGroupStart; + } + _buffer.Clear(); + _buffer.Append(line); + + switch (moveCursor) + { + case HistoryMoveCursor.ToEnd: + _current = Math.Max(0, _buffer.Length + ViEndOfLineFactor); + break; + case HistoryMoveCursor.ToBeginning: + _current = 0; + break; + default: + if (_current > _buffer.Length) + { + _current = Math.Max(0, _buffer.Length + ViEndOfLineFactor); + } + break; + } + + using var _ = _prediction.DisableScoped(); + Render(); + } + + private void SaveCurrentLine() + { + // We're called before any history operation - so it's convenient + // to check if we need to load history from another sessions now. + MaybeReadHistoryFile(); + + _anyHistoryCommandCount += 1; + if (_savedCurrentLine.CommandLine == null) + { + _savedCurrentLine.CommandLine = _buffer.ToString(); + _savedCurrentLine._edits = _edits; + _savedCurrentLine._undoEditIndex = _undoEditIndex; _savedCurrentLine._editGroupStart = _editGroupStart; } } - private void HistoryRecall(int direction) + private void HistoryRecall(int direction) + { + if (_recallHistoryCommandCount == 0 && LineIsMultiLine()) + { + MoveToLine(direction); + return; + } + + if (Options.HistoryNoDuplicates && _hashedHistory == null) + { + _hashedHistory = new Dictionary(); + } + + int count = Math.Abs(direction); + direction = direction < 0 ? -1 : +1; + int newHistoryIndex = _currentHistoryIndex; + while (count > 0) + { + newHistoryIndex += direction; + + if (newHistoryIndex < 0 || newHistoryIndex >= _history.Count) + { + break; + } + + if (_history[newHistoryIndex].FromOtherSession) + { + continue; + } + + if (Options.HistoryNoDuplicates) + { + var line = _history[newHistoryIndex].CommandLine; + if (!_hashedHistory.TryGetValue(line, out var index)) + { + _hashedHistory.Add(line, newHistoryIndex); + --count; + } + else if (newHistoryIndex == index) + { + --count; + } + } + else + { + --count; + } + } + _recallHistoryCommandCount += 1; + + if (newHistoryIndex >= 0 && newHistoryIndex <= _history.Count) + { + _currentHistoryIndex = newHistoryIndex; + var moveCursor = InViCommandMode() && !_options.HistorySearchCursorMovesToEnd + ? HistoryMoveCursor.ToBeginning + : HistoryMoveCursor.ToEnd; + UpdateFromHistory(moveCursor); + } + + // Show position indicator while navigating chronological history. + // Position 1 = newest navigable item; total = number of items reachable + // by Up/Down (i.e., excluding FromOtherSession entries, which HistoryRecall + // skips above). Counting raw _history slots would make the indicator jump + // by hundreds when many cross-session items sit between two in-session + // commands. + if (_history.Count > 0 && _currentHistoryIndex < _history.Count) + { + int navigableTotal = 0; + int navigableFromOldest = 0; + for (int i = 0; i < _history.Count; i++) + { + if (_history[i].FromOtherSession) + { + continue; + } + navigableTotal++; + if (i <= _currentHistoryIndex) + { + navigableFromOldest++; + } + } + + if (navigableTotal > 0 && navigableFromOldest > 0) + { + int positionFromNewest = navigableTotal - navigableFromOldest + 1; + ShowHistoryNavStatus(positionFromNewest, navigableTotal, locationMode: false); + } + } + } + + // Renders a small "[ pos/total]" status line below the prompt while + // the user is navigating history. Cleared by the ReadLine main loop once + // any non-history key is pressed. + private void ShowHistoryNavStatus(int position, int total, bool locationMode) + { + if (total <= 0) + { + return; + } + + // ⏱ (U+23F1, BMP, 1 char / 1 cell) for chronological recency-based recall, + // 📂 (U+1F4C2, surrogate pair = 2 chars / 2 cells) for location-filtered + // recall. Both fit the buffer-width math in Render.cs. + // Brackets stay in the status line's default color; inner text uses the + // ListPredictionColor (gold/yellow by default — matches F2 list metadata). + // When AccessibleHistoryDisplay is enabled, render plain-text labels so screen + // readers don't verbalize Unicode emoji names. ASCII labels keep cell-count == + // char-count, so the buffer-width math in GetStatusLineCount() still works. + var color = _options?._listPredictionColor ?? "\x1b[33m"; + bool accessible = _options?.AccessibleHistoryDisplay ?? false; + string innerText; + if (accessible) + { + innerText = locationMode + ? $"Location {position}/{total}" + : $"History {position}/{total}"; + } + else + { + innerText = locationMode + ? $"\uD83D\uDCC2 {position}/{total}" + : $"\u23F1 {position}/{total}"; + } + _statusLinePrompt = $"[{color}{innerText}\x1b[0m]"; + _statusBuffer.Clear(); + _statusIsErrorMessage = false; + _historyNavStatusActive = true; + RenderWithPredictionQueryPaused(); + } + + /// + /// Remove the currently displayed history item from history (both in-memory and SQLite if applicable). + /// Works when browsing history with Up/Down arrows or when an item is selected in the F2 list view. + /// + public static void RemoveFromHistory(ConsoleKeyInfo? key = null, object arg = null) + { + var history = _singleton._history; + if (history == null || history.Count == 0) + { + Ding(); + return; + } + + // Check if we're in the F2 list prediction view with a selected item. + // Handle this path separately — no history recall counters needed since + // we stay in the list view, and incrementing them would leave _hashedHistory + // null for the next HistoryRecall call (causing NullReferenceException). + if (_singleton._prediction.ActiveView is PredictionListView listView + && listView.HasActiveSuggestion + && listView.SelectedItemIndex >= 0) + { + string commandToRemove = listView.SelectedItemText; + if (commandToRemove == null) + { + Ding(); + return; + } + + RemoveHistoryItem(commandToRemove); + + if (!listView.RemoveSelectedItem()) + { + // List became empty — close the list view + RevertLine(); + } + else + { + // Re-render so the user sees the item disappear immediately. + ReplaceSelection(listView.SelectedItemText); + } + + return; + } + + // Normal history browsing path (Up/Down arrows). + // Signal to the main ReadLine loop that this is a history command, + // so it doesn't reset _currentHistoryIndex after we set it. + _singleton._recallHistoryCommandCount += 1; + _singleton._anyHistoryCommandCount += 1; + + string commandLine = null; + if (_singleton._currentHistoryIndex < history.Count) + { + commandLine = history[_singleton._currentHistoryIndex].CommandLine; + } + + if (commandLine == null) + { + Ding(); + return; + } + + // Save position before RemoveHistoryItem resets _currentHistoryIndex to Count + int savedIndex = _singleton._currentHistoryIndex; + + RemoveHistoryItem(commandLine); + + // In normal history browsing: advance to the next older item + // (same direction as Up arrow) so the user can keep deleting + // consecutive items without bouncing back to the top. + if (history.Count == 0) + { + _singleton._currentHistoryIndex = 0; + RevertLine(); + } + else + { + // Items below savedIndex didn't move, so the next older item + // is at savedIndex - 1. If we were at the oldest item already, + // show whatever is now at index 0 (the former next-newer item). + _singleton._currentHistoryIndex = Math.Max(savedIndex - 1, 0); + _singleton.UpdateFromHistory(HistoryMoveCursor.ToEnd); + } + } + + /// + /// Remove the currently displayed history item from history at the *current location only*. + /// In SQLite mode this deletes only the ExecutionHistory row for the current directory + /// (and the Commands row only if no other location still references it). In-memory items + /// run at other locations are preserved. In Text mode this falls back to a global removal because + /// per-location data isn't tracked. + /// + public static void RemoveFromHistoryAtCurrentLocation(ConsoleKeyInfo? key = null, object arg = null) + { + var history = _singleton._history; + if (history == null || history.Count == 0) + { + Ding(); + return; + } + + string currentLocation = _singleton.GetCurrentLocation(); + + // Text mode (or no current location available): fall back to the global removal so the user + // still gets a useful action. Per-location semantics require SQLite. + if (string.IsNullOrEmpty(currentLocation) || + _singleton._options?.HistoryType != HistoryType.SQLite) + { + RemoveFromHistory(key, arg); + return; + } + + // F2 list view path — same handling as RemoveFromHistory but scoped to current location. + if (_singleton._prediction.ActiveView is PredictionListView listView + && listView.HasActiveSuggestion + && listView.SelectedItemIndex >= 0) + { + string commandToRemove = listView.SelectedItemText; + if (commandToRemove == null) + { + Ding(); + return; + } + + if (!RemoveHistoryItemAtLocation(commandToRemove, currentLocation)) + { + // Nothing was removed (item isn't recorded at this location). Don't disturb the list. + Ding(); + return; + } + + if (!listView.RemoveSelectedItem()) + { + RevertLine(); + } + else + { + ReplaceSelection(listView.SelectedItemText); + } + + return; + } + + // Normal history browsing path. See RemoveFromHistory for the counter-increment rationale. + _singleton._recallHistoryCommandCount += 1; + _singleton._anyHistoryCommandCount += 1; + + string commandLine = null; + if (_singleton._currentHistoryIndex < history.Count) + { + commandLine = history[_singleton._currentHistoryIndex].CommandLine; + } + + if (commandLine == null) + { + Ding(); + return; + } + + int savedIndex = _singleton._currentHistoryIndex; + bool wasInLocationMode = _singleton._locationHistoryActive; + int savedLocationPos = _singleton._locationSortedPosition; + + if (!RemoveHistoryItemAtLocation(commandLine, currentLocation)) + { + // The displayed item wasn't run at this location, so location-scoped delete is a no-op. + // Ding to signal "nothing happened" without falling through to a destructive global delete. + Ding(); + return; + } + + if (history.Count == 0) + { + _singleton._currentHistoryIndex = 0; + _singleton._locationSortedIndices = null; + _singleton._locationSortedPosition = -1; + RevertLine(); + return; + } + + if (wasInLocationMode) + { + // RemoveHistoryItemAtLocation rebuilt _history (Clear + Enqueue), so every + // index in _locationSortedIndices is now stale. Rebuild the sorted list against + // the new _history and reposition to the next item in the location list (clamped). + // Bump _locationHistoryCommandCount so the main loop's sticky-mode teardown + // doesn't fire on the next key press. + _singleton._locationHistoryCommandCount += 1; + _singleton._locationSortedIndices = null; + _singleton.BuildLocationSortedIndices(currentLocation); + + if (_singleton._locationSortedIndices.Count == 0) + { + // No more items at this location — exit location mode and clear the line. + _singleton._locationSortedPosition = -1; + _singleton._locationHistoryActive = false; + _singleton._currentHistoryIndex = history.Count; + RevertLine(); + _singleton.ClearStatusMessage(render: true); + return; + } + + // The item at savedLocationPos was just removed; whatever was at savedLocationPos+1 + // now sits at savedLocationPos. Stay on that slot, clamped to the new end. + int newPos = Math.Min(Math.Max(savedLocationPos, 0), _singleton._locationSortedIndices.Count - 1); + _singleton._locationSortedPosition = newPos; + _singleton._currentHistoryIndex = _singleton._locationSortedIndices[newPos]; + _singleton.UpdateFromHistory(HistoryMoveCursor.ToEnd); + _singleton.ShowHistoryNavStatus(newPos + 1, _singleton._locationSortedIndices.Count, locationMode: true); + return; + } + + _singleton._currentHistoryIndex = Math.Max(savedIndex - 1, 0); + _singleton.UpdateFromHistory(HistoryMoveCursor.ToEnd); + } + + /// + /// Replace the current input with the 'previous' item from PSReadLine history. + /// + public static void PreviousHistory(ConsoleKeyInfo? key = null, object arg = null) + { + TryGetArgAsInt(arg, out var numericArg, -1); + if (numericArg > 0) + { + numericArg = -numericArg; + } + + if (UpdateListSelection(numericArg)) + { + return; + } + + _singleton.SaveCurrentLine(); + // Sticky location mode: if the user entered location-filtered navigation + // (Alt+Up), keep filtering by location even when they release Alt and + // press plain Up/Down. They exit by editing or doing any non-history op. + if (_singleton._locationHistoryActive) + { + _singleton.LocationHistoryRecall(numericArg); + } + else + { + _singleton.HistoryRecall(numericArg); + } + } + + /// + /// Replace the current input with the 'next' item from PSReadLine history. + /// + public static void NextHistory(ConsoleKeyInfo? key = null, object arg = null) + { + TryGetArgAsInt(arg, out var numericArg, +1); + if (UpdateListSelection(numericArg)) + { + return; + } + + _singleton.SaveCurrentLine(); + if (_singleton._locationHistoryActive) + { + _singleton.LocationHistoryRecall(numericArg); + } + else + { + _singleton.HistoryRecall(numericArg); + } + } + + /// + /// Replace the current input with the 'previous' item from PSReadLine history + /// that was executed from the same location (directory). + /// + public static void PreviousLocationHistory(ConsoleKeyInfo? key = null, object arg = null) + { + TryGetArgAsInt(arg, out var numericArg, -1); + if (numericArg > 0) + { + numericArg = -numericArg; + } + + if (UpdateListSelection(numericArg)) + { + return; + } + + _singleton.SaveCurrentLine(); + _singleton.LocationHistoryRecall(numericArg); + } + + /// + /// Replace the current input with the 'next' item from PSReadLine history + /// that was executed from the same location (directory). + /// + public static void NextLocationHistory(ConsoleKeyInfo? key = null, object arg = null) + { + TryGetArgAsInt(arg, out var numericArg, +1); + if (UpdateListSelection(numericArg)) + { + return; + } + + _singleton.SaveCurrentLine(); + _singleton.LocationHistoryRecall(numericArg); + } + + private string GetCurrentLocation() { - if (_recallHistoryCommandCount == 0 && LineIsMultiLine()) + return _testCurrentLocation ?? _engineIntrinsics?.SessionState?.Path?.CurrentLocation?.Path; + } + + // For unit testing: allows tests to simulate a current directory + internal static string _testCurrentLocation; + + private void LocationHistoryRecall(int direction) + { + if (_locationHistoryCommandCount == 0 && !_locationHistoryActive && LineIsMultiLine()) { MoveToLine(direction); return; } - if (Options.HistoryNoDuplicates && _recallHistoryCommandCount == 0) + var currentLocation = GetCurrentLocation(); + if (string.IsNullOrEmpty(currentLocation)) { - _hashedHistory = new Dictionary(); + // Fall back to normal recall if we can't determine location + HistoryRecall(direction); + return; + } + + // First entry into location mode (or after sorted list was cleared by exit): + // build a weighted index of location-matching history items. + // Ordering: location match (primary), then frequency DESC, then recency DESC. + if (_locationSortedIndices == null) + { + BuildLocationSortedIndices(currentLocation); + _locationSortedPosition = -1; } + _locationHistoryActive = true; + _locationHistoryCommandCount += 1; + + // Navigate: Alt+Up (direction < 0) advances forward through sorted list, + // Alt+Down (direction > 0) goes back. int count = Math.Abs(direction); - direction = direction < 0 ? -1 : +1; - int newHistoryIndex = _currentHistoryIndex; + int step = direction < 0 ? 1 : -1; + int newPosition = _locationSortedPosition; + while (count > 0) { - newHistoryIndex += direction; - if (newHistoryIndex < 0 || newHistoryIndex >= _history.Count) + newPosition += step; + if (newPosition < 0 || newPosition >= _locationSortedIndices.Count) { break; } - - if (_history[newHistoryIndex].FromOtherSession) - { - continue; - } - - if (Options.HistoryNoDuplicates) - { - var line = _history[newHistoryIndex].CommandLine; - if (!_hashedHistory.TryGetValue(line, out var index)) - { - _hashedHistory.Add(line, newHistoryIndex); - --count; - } - else if (newHistoryIndex == index) - { - --count; - } - } - else - { - --count; - } + --count; } - _recallHistoryCommandCount += 1; - if (newHistoryIndex >= 0 && newHistoryIndex <= _history.Count) + + if (newPosition >= 0 && newPosition < _locationSortedIndices.Count) { - _currentHistoryIndex = newHistoryIndex; + _locationSortedPosition = newPosition; + _currentHistoryIndex = _locationSortedIndices[newPosition]; var moveCursor = InViCommandMode() && !_options.HistorySearchCursorMovesToEnd ? HistoryMoveCursor.ToBeginning : HistoryMoveCursor.ToEnd; UpdateFromHistory(moveCursor); } + + // Show position indicator: [BOOK pos/total] (location-filtered). + ShowHistoryNavStatus(_locationSortedPosition + 1, _locationSortedIndices.Count, locationMode: true); } - /// - /// Replace the current input with the 'previous' item from PSReadLine history. - /// - public static void PreviousHistory(ConsoleKeyInfo? key = null, object arg = null) + // Builds _locationSortedIndices for the given location, applying the same + // dedup + frecency sort used by LocationHistoryRecall. Caller is responsible + // for resetting _locationSortedPosition. + private void BuildLocationSortedIndices(string currentLocation) { - TryGetArgAsInt(arg, out var numericArg, -1); - if (numericArg > 0) - { - numericArg = -numericArg; - } + var seen = new HashSet(StringComparer.Ordinal); + _locationSortedIndices = new List(); - if (UpdateListSelection(numericArg)) + for (int i = 0; i < _history.Count; i++) { - return; + if (string.Equals(_history[i].Location, currentLocation, StringComparison.OrdinalIgnoreCase)) + { + if (seen.Add(_history[i].CommandLine)) + { + _locationSortedIndices.Add(i); + } + } } - _singleton.SaveCurrentLine(); - _singleton.HistoryRecall(numericArg); - } - - /// - /// Replace the current input with the 'next' item from PSReadLine history. - /// - public static void NextHistory(ConsoleKeyInfo? key = null, object arg = null) - { - TryGetArgAsInt(arg, out var numericArg, +1); - if (UpdateListSelection(numericArg)) + // In SQLite mode, query per-location execution counts so that sorting + // reflects how often each command was run *in this directory* rather than + // the total across all locations (which inflates commands like "code ." + // that were run once here but many times elsewhere). + Dictionary localCounts = null; + if (_options.HistoryType == HistoryType.SQLite && !string.IsNullOrEmpty(_options.HistorySavePath)) { - return; + localCounts = GetLocationExecutionCounts(currentLocation); } - _singleton.SaveCurrentLine(); - _singleton.HistoryRecall(numericArg); + // Sort by per-location frequency DESC (SQLite) or total frequency DESC (Text), + // then by position DESC (more recent first). + _locationSortedIndices.Sort((a, b) => + { + long countA, countB; + if (localCounts != null) + { + localCounts.TryGetValue(_history[a].CommandLine, out countA); + localCounts.TryGetValue(_history[b].CommandLine, out countB); + } + else + { + countA = _history[a].ExecutionCount; + countB = _history[b].ExecutionCount; + } + int freqCmp = countB.CompareTo(countA); + if (freqCmp != 0) return freqCmp; + return b.CompareTo(a); + }); } private void HistorySearch(int direction) @@ -1302,5 +2616,201 @@ public static void ReverseSearchHistory(ConsoleKeyInfo? key = null, object arg = { _singleton.InteractiveHistorySearch(-1); } + + private void UpdateLocationHistoryDuringInteractiveSearch(string toMatch, int direction, string currentLocation, ref int searchFromPoint) + { + searchFromPoint += direction; + for (; searchFromPoint >= 0 && searchFromPoint < _history.Count; searchFromPoint += direction) + { + // Filter by location + if (!string.Equals(_history[searchFromPoint].Location, currentLocation, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var line = _history[searchFromPoint].CommandLine; + var startIndex = line.IndexOf(toMatch, Options.HistoryStringComparison); + if (startIndex >= 0) + { + if (Options.HistoryNoDuplicates) + { + if (!_hashedHistory.TryGetValue(line, out var index)) + { + _hashedHistory.Add(line, searchFromPoint); + } + else if (index != searchFromPoint) + { + continue; + } + } + _statusLinePrompt = direction > 0 ? _forwardLocationISearchPrompt : _backwardLocationISearchPrompt; + _current = startIndex; + _emphasisStart = startIndex; + _emphasisLength = toMatch.Length; + _currentHistoryIndex = searchFromPoint; + var moveCursor = Options.HistorySearchCursorMovesToEnd + ? HistoryMoveCursor.ToEnd + : HistoryMoveCursor.DontMove; + UpdateFromHistory(moveCursor); + return; + } + } + + if (searchFromPoint < 0) + searchFromPoint = -1; + else if (searchFromPoint >= _history.Count) + searchFromPoint = _history.Count; + + _emphasisStart = -1; + _emphasisLength = 0; + _statusLinePrompt = direction > 0 ? _failedForwardLocationISearchPrompt : _failedBackwardLocationISearchPrompt; + Render(); + } + + private void InteractiveLocationHistorySearchLoop(int direction, string currentLocation) + { + var searchFromPoint = _currentHistoryIndex; + var searchPositions = new Stack(); + searchPositions.Push(_currentHistoryIndex); + + if (Options.HistoryNoDuplicates) + { + _hashedHistory = new Dictionary(); + } + + var toMatch = new StringBuilder(64); + while (true) + { + var key = ReadKey(); + _dispatchTable.TryGetValue(key, out var handler); + var function = handler?.Action; + if (function == ReverseLocationSearchHistory) + { + UpdateLocationHistoryDuringInteractiveSearch(toMatch.ToString(), -1, currentLocation, ref searchFromPoint); + } + else if (function == ForwardLocationSearchHistory) + { + UpdateLocationHistoryDuringInteractiveSearch(toMatch.ToString(), +1, currentLocation, ref searchFromPoint); + } + else if (function == BackwardDeleteChar + || key == Keys.Backspace + || key == Keys.CtrlH) + { + if (toMatch.Length > 0) + { + toMatch.Remove(toMatch.Length - 1, 1); + _statusBuffer.Remove(_statusBuffer.Length - 2, 1); + searchPositions.Pop(); + searchFromPoint = _currentHistoryIndex = searchPositions.Peek(); + var moveCursor = Options.HistorySearchCursorMovesToEnd + ? HistoryMoveCursor.ToEnd + : HistoryMoveCursor.DontMove; + UpdateFromHistory(moveCursor); + + if (_hashedHistory != null) + { + foreach (var pair in _hashedHistory.ToArray()) + { + if (pair.Value < searchFromPoint) + { + _hashedHistory.Remove(pair.Key); + } + } + } + + var toMatchStr = toMatch.ToString(); + var startIndex = _buffer.ToString().IndexOf(toMatchStr, Options.HistoryStringComparison); + if (startIndex >= 0) + { + _statusLinePrompt = direction > 0 ? _forwardLocationISearchPrompt : _backwardLocationISearchPrompt; + _current = startIndex; + _emphasisStart = startIndex; + _emphasisLength = toMatch.Length; + Render(); + } + } + else + { + Ding(); + } + } + else if (key == Keys.Escape) + { + break; + } + else if (function == Abort) + { + GoToEndOfHistory(); + break; + } + else + { + char toAppend = key.KeyChar; + if (char.IsControl(toAppend)) + { + PrependQueuedKeys(key); + break; + } + toMatch.Append(toAppend); + _statusBuffer.Insert(_statusBuffer.Length - 1, toAppend); + + var toMatchStr = toMatch.ToString(); + var startIndex = _buffer.ToString().IndexOf(toMatchStr, Options.HistoryStringComparison); + if (startIndex < 0) + { + UpdateLocationHistoryDuringInteractiveSearch(toMatchStr, direction, currentLocation, ref searchFromPoint); + } + else + { + _current = startIndex; + _emphasisStart = startIndex; + _emphasisLength = toMatch.Length; + Render(); + } + searchPositions.Push(_currentHistoryIndex); + } + } + } + + private void InteractiveLocationHistorySearch(int direction) + { + var currentLocation = GetCurrentLocation(); + if (string.IsNullOrEmpty(currentLocation)) + { + // Fall back to regular interactive search if location unavailable + InteractiveHistorySearch(direction); + return; + } + + using var _ = _prediction.DisableScoped(); + SaveCurrentLine(); + + _statusLinePrompt = direction > 0 ? _forwardLocationISearchPrompt : _backwardLocationISearchPrompt; + _statusBuffer.Append("_"); + + Render(); + InteractiveLocationHistorySearchLoop(direction, currentLocation); + + _emphasisStart = -1; + _emphasisLength = 0; + + ClearStatusMessage(render: true); + } + + /// + /// Perform an incremental forward search through history, filtered to commands executed from the current location. + /// + public static void ForwardLocationSearchHistory(ConsoleKeyInfo? key = null, object arg = null) + { + _singleton.InteractiveLocationHistorySearch(+1); + } + + /// + /// Perform an incremental backward search through history, filtered to commands executed from the current location. + /// + public static void ReverseLocationSearchHistory(ConsoleKeyInfo? key = null, object arg = null) + { + _singleton.InteractiveLocationHistorySearch(-1); + } } } diff --git a/PSReadLine/KeyBindings.cs b/PSReadLine/KeyBindings.cs index 89564228..9b307af2 100644 --- a/PSReadLine/KeyBindings.cs +++ b/PSReadLine/KeyBindings.cs @@ -207,6 +207,7 @@ void SetDefaultWindowsBindings() { Keys.CtrlL, MakeKeyHandler(ClearScreen, "ClearScreen") }, { Keys.CtrlR, MakeKeyHandler(ReverseSearchHistory, "ReverseSearchHistory") }, { Keys.CtrlS, MakeKeyHandler(ForwardSearchHistory, "ForwardSearchHistory") }, + { Keys.CtrlAltR, MakeKeyHandler(ReverseLocationSearchHistory, "ReverseLocationSearchHistory") }, { Keys.CtrlV, MakeKeyHandler(Paste, "Paste") }, { Keys.ShiftInsert, MakeKeyHandler(Paste, "Paste") }, { Keys.CtrlX, MakeKeyHandler(Cut, "Cut") }, @@ -238,8 +239,11 @@ void SetDefaultWindowsBindings() { Keys.F4, MakeKeyHandler(ShowFullPredictionTooltip, "ShowFullPredictionTooltip") }, { Keys.F8, MakeKeyHandler(HistorySearchBackward, "HistorySearchBackward") }, { Keys.ShiftF8, MakeKeyHandler(HistorySearchForward, "HistorySearchForward") }, + { Keys.AltUpArrow, MakeKeyHandler(PreviousLocationHistory, "PreviousLocationHistory") }, + { Keys.AltDownArrow, MakeKeyHandler(NextLocationHistory, "NextLocationHistory") }, // Added for xtermjs-based terminals that send different key combinations. { Keys.AltD, MakeKeyHandler(KillWord, "KillWord") }, + { Keys.AltDelete, MakeKeyHandler(RemoveFromHistoryAtCurrentLocation, "RemoveFromHistoryAtCurrentLocation") }, { Keys.CtrlAt, MakeKeyHandler(MenuComplete, "MenuComplete") }, { Keys.CtrlW, MakeKeyHandler(BackwardKillWord, "BackwardKillWord") }, }; @@ -247,9 +251,10 @@ void SetDefaultWindowsBindings() // Some bindings are not available on certain platforms if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - _dispatchTable.Add(Keys.CtrlSpace, MakeKeyHandler(MenuComplete, "MenuComplete")); - _dispatchTable.Add(Keys.AltF7, MakeKeyHandler(ClearHistory, "ClearHistory")); - _dispatchTable.Add(Keys.CtrlDelete, MakeKeyHandler(KillWord, "KillWord")); + _dispatchTable.Add(Keys.CtrlSpace, MakeKeyHandler(MenuComplete, "MenuComplete")); + _dispatchTable.Add(Keys.AltF7, MakeKeyHandler(ClearHistory, "ClearHistory")); + _dispatchTable.Add(Keys.CtrlDelete, MakeKeyHandler(KillWord, "KillWord")); + _dispatchTable.Add(Keys.CtrlShiftDelete, MakeKeyHandler(RemoveFromHistory, "RemoveFromHistory")); _dispatchTable.Add(Keys.CtrlEnd, MakeKeyHandler(ForwardDeleteInput, "ForwardDeleteInput")); _dispatchTable.Add(Keys.CtrlH, MakeKeyHandler(BackwardDeleteChar,"BackwardDeleteChar")); @@ -279,6 +284,8 @@ void SetDefaultEmacsBindings() { Keys.DownArrow, MakeKeyHandler(NextHistory, "NextHistory") }, { Keys.AltLess, MakeKeyHandler(BeginningOfHistory, "BeginningOfHistory") }, { Keys.AltGreater, MakeKeyHandler(EndOfHistory, "EndOfHistory") }, + { Keys.AltUpArrow, MakeKeyHandler(PreviousLocationHistory, "PreviousLocationHistory") }, + { Keys.AltDownArrow, MakeKeyHandler(NextLocationHistory, "NextLocationHistory") }, { Keys.Home, MakeKeyHandler(BeginningOfLine, "BeginningOfLine") }, { Keys.End, MakeKeyHandler(EndOfLine, "EndOfLine") }, { Keys.ShiftHome, MakeKeyHandler(SelectBackwardsLine, "SelectBackwardsLine") }, @@ -301,6 +308,7 @@ void SetDefaultEmacsBindings() { Keys.CtrlP, MakeKeyHandler(PreviousHistory, "PreviousHistory") }, { Keys.CtrlR, MakeKeyHandler(ReverseSearchHistory, "ReverseSearchHistory") }, { Keys.CtrlS, MakeKeyHandler(ForwardSearchHistory, "ForwardSearchHistory") }, + { Keys.CtrlAltR, MakeKeyHandler(ReverseLocationSearchHistory, "ReverseLocationSearchHistory") }, { Keys.CtrlT, MakeKeyHandler(SwapCharacters, "SwapCharacters") }, { Keys.CtrlU, MakeKeyHandler(BackwardKillInput, "BackwardKillInput") }, { Keys.CtrlX, MakeKeyHandler(Chord, "ChordFirstKey") }, @@ -325,6 +333,7 @@ void SetDefaultEmacsBindings() { Keys.AltB, MakeKeyHandler(BackwardWord, "BackwardWord") }, { Keys.AltShiftB, MakeKeyHandler(SelectBackwardWord, "SelectBackwardWord") }, { Keys.AltD, MakeKeyHandler(KillWord, "KillWord") }, + { Keys.AltDelete, MakeKeyHandler(RemoveFromHistoryAtCurrentLocation, "RemoveFromHistoryAtCurrentLocation") }, { Keys.AltF, MakeKeyHandler(ForwardWord, "ForwardWord") }, { Keys.AltShiftF, MakeKeyHandler(SelectForwardWord, "SelectForwardWord") }, { Keys.AltR, MakeKeyHandler(RevertLine, "RevertLine") }, @@ -349,10 +358,11 @@ void SetDefaultEmacsBindings() // Some bindings are not available on certain platforms if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - _dispatchTable.Add(Keys.CtrlH, MakeKeyHandler(BackwardDeleteChar, "BackwardDeleteChar")); - _dispatchTable.Add(Keys.CtrlSpace, MakeKeyHandler(MenuComplete, "MenuComplete")); - _dispatchTable.Add(Keys.CtrlEnd, MakeKeyHandler(ScrollDisplayToCursor, "ScrollDisplayToCursor")); - _dispatchTable.Add(Keys.CtrlHome, MakeKeyHandler(ScrollDisplayTop, "ScrollDisplayTop")); + _dispatchTable.Add(Keys.CtrlH, MakeKeyHandler(BackwardDeleteChar, "BackwardDeleteChar")); + _dispatchTable.Add(Keys.CtrlSpace, MakeKeyHandler(MenuComplete, "MenuComplete")); + _dispatchTable.Add(Keys.CtrlEnd, MakeKeyHandler(ScrollDisplayToCursor, "ScrollDisplayToCursor")); + _dispatchTable.Add(Keys.CtrlHome, MakeKeyHandler(ScrollDisplayTop, "ScrollDisplayTop")); + _dispatchTable.Add(Keys.CtrlShiftDelete, MakeKeyHandler(RemoveFromHistory, "RemoveFromHistory")); // PageUp/PageDown and CtrlPageUp/CtrlPageDown bindings are supported on Windows only because they depend on the // API 'Console.SetWindowPosition', which throws 'PlatformNotSupportedException' on unix platforms. @@ -549,9 +559,15 @@ public static KeyHandlerGroup GetDisplayGrouping(string function) case nameof(HistorySearchBackward): case nameof(HistorySearchForward): case nameof(NextHistory): + case nameof(NextLocationHistory): case nameof(PreviousHistory): + case nameof(PreviousLocationHistory): case nameof(ReverseSearchHistory): + case nameof(ReverseLocationSearchHistory): + case nameof(ForwardLocationSearchHistory): case nameof(ViSearchHistoryBackward): + case nameof(RemoveFromHistory): + case nameof(RemoveFromHistoryAtCurrentLocation): return KeyHandlerGroup.History; case nameof(Complete): diff --git a/PSReadLine/Keys.cs b/PSReadLine/Keys.cs index 70cd80e4..bf72725c 100644 --- a/PSReadLine/Keys.cs +++ b/PSReadLine/Keys.cs @@ -457,6 +457,7 @@ internal static class Keys public static PSKeyInfo Space = Key(ConsoleKey.Spacebar); public static PSKeyInfo Backspace = Key(ConsoleKey.Backspace); public static PSKeyInfo Delete = Key(ConsoleKey.Delete); + public static PSKeyInfo AltDelete = Alt(ConsoleKey.Delete); public static PSKeyInfo DownArrow = Key(ConsoleKey.DownArrow); public static PSKeyInfo End = Key(ConsoleKey.End); public static PSKeyInfo Enter = Key(ConsoleKey.Enter); @@ -581,6 +582,7 @@ internal static class Keys public static PSKeyInfo CtrlUnderbar = Ctrl('_'); public static PSKeyInfo CtrlBackspace = Ctrl(ConsoleKey.Backspace); public static PSKeyInfo CtrlDelete = Ctrl(ConsoleKey.Delete); // !Linux + public static PSKeyInfo CtrlShiftDelete = CtrlShift(ConsoleKey.Delete); // !Linux public static PSKeyInfo CtrlEnd = Ctrl(ConsoleKey.End); // !Linux public static PSKeyInfo CtrlHome = Ctrl(ConsoleKey.Home); // !Linux public static PSKeyInfo CtrlPageUp = Ctrl(ConsoleKey.PageUp); // !Linux @@ -598,6 +600,8 @@ internal static class Keys public static PSKeyInfo ShiftPageDown = Shift(ConsoleKey.PageDown); public static PSKeyInfo ShiftLeftArrow = Shift(ConsoleKey.LeftArrow); public static PSKeyInfo ShiftRightArrow = Shift(ConsoleKey.RightArrow); + public static PSKeyInfo AltUpArrow = Alt(ConsoleKey.UpArrow); + public static PSKeyInfo AltDownArrow = Alt(ConsoleKey.DownArrow); public static PSKeyInfo ShiftUpArrow = Shift(ConsoleKey.UpArrow); public static PSKeyInfo ShiftDownArrow = Shift(ConsoleKey.DownArrow); public static PSKeyInfo ShiftTab = Shift(ConsoleKey.Tab); // !Linux, same as Tab @@ -608,6 +612,7 @@ internal static class Keys public static PSKeyInfo CtrlShiftLeftArrow = CtrlShift(ConsoleKey.LeftArrow); public static PSKeyInfo CtrlShiftRightArrow = CtrlShift(ConsoleKey.RightArrow); + public static PSKeyInfo CtrlAltR = CtrlAlt('r'); public static PSKeyInfo CtrlAltY = CtrlAlt('y'); public static PSKeyInfo CtrlAltRBracket = CtrlAlt(']'); public static PSKeyInfo CtrlAltQuestion = CtrlAlt('?'); diff --git a/PSReadLine/Options.cs b/PSReadLine/Options.cs index ffdf0241..1d633fb7 100644 --- a/PSReadLine/Options.cs +++ b/PSReadLine/Options.cs @@ -26,6 +26,33 @@ private void SetOptionsInternal(SetPSReadLineOption options) { Options.ContinuationPrompt = options.ContinuationPrompt; } + if (options._historyTypeSpecified) + { + Options.HistoryType = options.HistoryType; + if (Options.HistoryType is HistoryType.SQLite) + { + // HistorySavePath is now computed from HistoryType, so it already + // points at HistorySavePathSQLite after the type switch above. + if (!string.IsNullOrEmpty(Options.HistorySavePath) && !System.IO.File.Exists(Options.HistorySavePath)) + { + _historyFileMutex?.Dispose(); + _historyFileMutex = new Mutex(false, GetHistorySaveFileMutexName()); + InitializeSQLiteDatabase(migrateTextHistory: true); + _historyFileLastSavedSize = 0; + } + + // _history is null when Set-PSReadLineOption runs from the profile before + // the first ReadLine() call. In that case, skip loading here — ReadLine() + // initialization will create _history and load SQLite history itself. + // When _history already exists (interactive switch), reload immediately. + if (_singleton._history != null) + { + _singleton._history.Clear(); + _singleton._currentHistoryIndex = 0; + ReadSQLiteHistory(fromOtherSession: false); + } + } + } if (options._historyNoDuplicates.HasValue) { Options.HistoryNoDuplicates = options.HistoryNoDuplicates; @@ -122,12 +149,41 @@ private void SetOptionsInternal(SetPSReadLineOption options) } Options.ViModeChangeHandler = options.ViModeChangeHandler; } - if (options.HistorySavePath != null) + if (options.HistorySavePathText != null) { - Options.HistorySavePath = options.HistorySavePath; - _historyFileMutex?.Dispose(); - _historyFileMutex = new Mutex(false, GetHistorySaveFileMutexName()); - _historyFileLastSavedSize = 0; + Options.HistorySavePathText = options.HistorySavePathText; + + // If currently in Text mode, reset the mutex for the new active path. + if (Options.HistoryType is HistoryType.Text) + { + _historyFileMutex?.Dispose(); + _historyFileMutex = new Mutex(false, GetHistorySaveFileMutexName()); + _historyFileLastSavedSize = 0; + } + } + if (options.HistorySavePathSQLite != null) + { + Options.HistorySavePathSQLite = options.HistorySavePathSQLite; + + // If currently in SQLite mode, reconnect to the new database. + if (Options.HistoryType is HistoryType.SQLite) + { + _historyFileMutex?.Dispose(); + _historyFileMutex = new Mutex(false, GetHistorySaveFileMutexName()); + _historyFileLastSavedSize = 0; + + if (!System.IO.File.Exists(Options.HistorySavePath)) + { + InitializeSQLiteDatabase(); + } + + if (_singleton._history != null) + { + _singleton._history.Clear(); + _singleton._currentHistoryIndex = 0; + ReadSQLiteHistory(fromOtherSession: false); + } + } } if (options._ansiEscapeTimeout.HasValue) { @@ -189,6 +245,10 @@ private void SetOptionsInternal(SetPSReadLineOption options) { Options.ScreenReaderModeEnabled = options.EnableScreenReaderMode; } + if (options._accessibleHistoryDisplay.HasValue) + { + Options.AccessibleHistoryDisplay = options.AccessibleHistoryDisplay; + } } private void SetKeyHandlerInternal(string[] keys, Action handler, string briefDescription, string longDescription, ScriptBlock scriptBlock) diff --git a/PSReadLine/PSReadLine.csproj b/PSReadLine/PSReadLine.csproj index edb39c91..2718d0da 100644 --- a/PSReadLine/PSReadLine.csproj +++ b/PSReadLine/PSReadLine.csproj @@ -13,6 +13,7 @@ true false 9.0 + true @@ -20,7 +21,9 @@ contentFiles All + + @@ -30,4 +33,4 @@ - + \ No newline at end of file diff --git a/PSReadLine/PSReadLine.format.ps1xml b/PSReadLine/PSReadLine.format.ps1xml index 62867e76..e172fa36 100644 --- a/PSReadLine/PSReadLine.format.ps1xml +++ b/PSReadLine/PSReadLine.format.ps1xml @@ -90,6 +90,9 @@ $d = [Microsoft.PowerShell.KeyHandler]::GetGroupingDescription($_.Group) EditMode + + HistoryType + AddToHistoryHandler @@ -97,7 +100,10 @@ $d = [Microsoft.PowerShell.KeyHandler]::GetGroupingDescription($_.Group) HistoryNoDuplicates - HistorySavePath + HistorySavePathText + + + HistorySavePathSQLite HistorySaveStyle diff --git a/PSReadLine/PSReadLineResources.Designer.cs b/PSReadLine/PSReadLineResources.Designer.cs index ac672d3f..af4d4866 100644 --- a/PSReadLine/PSReadLineResources.Designer.cs +++ b/PSReadLine/PSReadLineResources.Designer.cs @@ -841,6 +841,15 @@ internal static string NextHistoryDescription { } } + /// + /// Looks up a localized string similar to Replace the input with the next item in the history that was executed from the current location. + /// + internal static string NextLocationHistoryDescription { + get { + return ResourceManager.GetString("NextLocationHistoryDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to Move the cursor to the next line if the input has multiple lines.. /// @@ -978,6 +987,33 @@ internal static string PreviousHistoryDescription { } } + /// + /// Looks up a localized string similar to Replace the input with the previous item in the history that was executed from the current location. + /// + internal static string PreviousLocationHistoryDescription { + get { + return ResourceManager.GetString("PreviousLocationHistoryDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Perform an incremental backward search through history, filtered to commands executed from the current location. + /// + internal static string ReverseLocationSearchHistoryDescription { + get { + return ResourceManager.GetString("ReverseLocationSearchHistoryDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Perform an incremental forward search through history, filtered to commands executed from the current location. + /// + internal static string ForwardLocationSearchHistoryDescription { + get { + return ResourceManager.GetString("ForwardLocationSearchHistoryDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to Move the cursor to the previous line if the input has multiple lines.. /// diff --git a/PSReadLine/PSReadLineResources.resx b/PSReadLine/PSReadLineResources.resx index e619e7c0..93451aa4 100644 --- a/PSReadLine/PSReadLineResources.resx +++ b/PSReadLine/PSReadLineResources.resx @@ -216,6 +216,9 @@ Replace the input with the next item in the history + + Replace the input with the next item in the history that was executed from the current location + Paste text from the system clipboard @@ -225,6 +228,15 @@ Replace the input with the previous item in the history + + Replace the input with the previous item in the history that was executed from the current location + + + Perform an incremental backward search through history, filtered to commands executed from the current location + + + Perform an incremental forward search through history, filtered to commands executed from the current location + Redo an undo diff --git a/PSReadLine/Prediction.Entry.cs b/PSReadLine/Prediction.Entry.cs index 643afa5d..026e00b6 100644 --- a/PSReadLine/Prediction.Entry.cs +++ b/PSReadLine/Prediction.Entry.cs @@ -50,6 +50,7 @@ private struct SuggestionEntry internal readonly string ToolTip; internal readonly string SuggestionText; internal readonly int InputMatchIndex; + internal readonly PSConsoleReadLine.HistoryItem HistoryItemRef; private string _listItemTextRegular; private string _listItemTextSelected; @@ -59,6 +60,19 @@ internal SuggestionEntry(string suggestion, int matchIndex) { } + internal SuggestionEntry(string suggestion, string tooltip, int matchIndex, PSConsoleReadLine.HistoryItem historyItem = null) + { + Source = HistorySource; + PredictorId = Guid.Empty; + PredictorSession = null; + SuggestionText = suggestion; + ToolTip = tooltip; + InputMatchIndex = matchIndex; + HistoryItemRef = historyItem; + + _listItemTextRegular = _listItemTextSelected = null; + } + internal SuggestionEntry(string source, Guid predictorId, uint? predictorSession, string suggestion, string tooltip, int matchIndex) { Source = source; @@ -67,6 +81,7 @@ internal SuggestionEntry(string source, Guid predictorId, uint? predictorSession SuggestionText = suggestion; ToolTip = tooltip; InputMatchIndex = matchIndex; + HistoryItemRef = null; _listItemTextRegular = _listItemTextSelected = null; } diff --git a/PSReadLine/Prediction.Views.cs b/PSReadLine/Prediction.Views.cs index a9145c43..ca8b872f 100644 --- a/PSReadLine/Prediction.Views.cs +++ b/PSReadLine/Prediction.Views.cs @@ -116,21 +116,62 @@ protected string GetOneHistorySuggestion(string text) /// /// User input. /// Maximum number of results to return. - protected List GetHistorySuggestions(string input, int count) + /// + /// Generate a tooltip string with statistics for a history item. + /// This returns a plain-text version used as the ToolTip value (non-null triggers tooltip rendering). + /// The actual colored rendering is done by . + /// + private static string FormatHistoryStatsTooltip(HistoryItem item) { - List results = null; - int remainingCount = count; + var sb = new StringBuilder(); + sb.Append("Runs: ").Append(item.ExecutionCount); + + if (item.StartTime != default) + { + var ago = DateTime.UtcNow - item.StartTime; + if (ago.TotalMinutes < 1) + sb.Append(" \u2502 Last: just now"); + else if (ago.TotalHours < 1) + sb.Append(" \u2502 Last: ").Append((int)ago.TotalMinutes).Append("m ago"); + else if (ago.TotalDays < 1) + sb.Append(" \u2502 Last: ").Append((int)ago.TotalHours).Append("h ago"); + else + sb.Append(" \u2502 Last: ").Append((int)ago.TotalDays).Append("d ago"); + } + if (!string.IsNullOrEmpty(item.Location) && !item.Location.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + { + sb.Append(" \u2502 Dir: ").Append(item.Location); + } + + return sb.ToString(); + } + + protected List GetHistorySuggestions(string input, int count) + { var history = _singleton._history; var comparison = _singleton._options.HistoryStringComparison; var comparer = _singleton._options.HistoryStringComparer; + bool isSQLite = _singleton._options.HistoryType is HistoryType.SQLite; _cacheHistorySet ??= new HashSet(comparer); _cacheHistoryList ??= new List(); + // In SQLite mode, use frecency-based ordering with optional location partitioning. + // In text mode, use existing recency-based logic (pure reverse-chronological). + if (isSQLite) + { + return GetHistorySuggestionsSQLite(input, count, history, comparison, comparer); + } + + // --- Text mode: original recency-based logic (unchanged) --- + List results = null; + int remainingCount = count; + for (int historyIndex = history.Count - 1; historyIndex >= 0; historyIndex--) { - var line = history[historyIndex].CommandLine.TrimEnd(); + var historyItem = history[historyIndex]; + var line = historyItem.CommandLine.TrimEnd(); // Skip the history command lines that are smaller in length than the user input, // or contain multiple logical lines. @@ -175,6 +216,245 @@ protected List GetHistorySuggestions(string input, int count) return results; } + /// + /// SQLite mode: collects matching history candidates, sorts by frecency + /// (frequency DESC, then recency DESC), and optionally partitions by location. + /// When plugins are active (few history slots), no location split is applied. + /// When plugins are not active, results are partitioned: top half from commands + /// matching the current directory, bottom half from global commands. + /// + private List GetHistorySuggestionsSQLite( + string input, int count, + HistoryQueue history, + StringComparison comparison, + StringComparer comparer) + { + // Collect all matching candidates with their history index (for recency sorting). + var prefixMatches = new List<(int historyIndex, HistoryItem item, string line)>(); + var substringMatches = new List<(int historyIndex, HistoryItem item, string line)>(); + var seen = new HashSet(comparer); + + for (int historyIndex = history.Count - 1; historyIndex >= 0; historyIndex--) + { + var historyItem = history[historyIndex]; + var line = historyItem.CommandLine.TrimEnd(); + + if (line.Length <= input.Length || seen.Contains(line) || line.IndexOf('\n') != -1) + { + continue; + } + + int matchIndex = line.IndexOf(input, comparison); + if (matchIndex == -1) + { + continue; + } + + seen.Add(line); + + if (matchIndex == 0) + { + prefixMatches.Add((historyIndex, historyItem, line)); + } + else + { + substringMatches.Add((historyIndex, historyItem, line)); + } + } + + _cacheHistorySet.Clear(); + _cacheHistoryList.Clear(); + + if (prefixMatches.Count == 0 && substringMatches.Count == 0) + { + return null; + } + + // Frecency comparer: frequency DESC (ExecutionCount), then recency DESC (historyIndex). + static int FrecencyCompare( + (int historyIndex, HistoryItem item, string line) a, + (int historyIndex, HistoryItem item, string line) b) + { + int freqCmp = b.item.ExecutionCount.CompareTo(a.item.ExecutionCount); + if (freqCmp != 0) return freqCmp; + return b.historyIndex.CompareTo(a.historyIndex); + } + + // Determine whether to apply location partitioning. + // With plugins active, history gets at most 3 slots — too few to split. + bool partitionByLocation = !UsePlugin; + string currentLocation = partitionByLocation ? _singleton.GetCurrentLocation() : null; + partitionByLocation = partitionByLocation + && !string.IsNullOrEmpty(currentLocation) + && !currentLocation.Equals("Unknown", StringComparison.OrdinalIgnoreCase); + + var results = new List(capacity: count); + + if (partitionByLocation) + { + // Query per-location execution counts so local sorting reflects how + // often each command was run *in this directory*, not globally. + Dictionary localCounts = null; + if (_singleton._options.HistoryType == HistoryType.SQLite + && !string.IsNullOrEmpty(_singleton._options.HistorySavePath)) + { + localCounts = _singleton.GetLocationExecutionCounts(currentLocation); + } + + int LocalFrecencyCompare( + (int historyIndex, HistoryItem item, string line) a, + (int historyIndex, HistoryItem item, string line) b) + { + long countA, countB; + if (localCounts != null) + { + localCounts.TryGetValue(a.item.CommandLine, out countA); + localCounts.TryGetValue(b.item.CommandLine, out countB); + } + else + { + countA = a.item.ExecutionCount; + countB = b.item.ExecutionCount; + } + int freqCmp = countB.CompareTo(countA); + if (freqCmp != 0) return freqCmp; + return b.historyIndex.CompareTo(a.historyIndex); + } + + // Split prefix matches into local (matching current dir) and global. + var localPrefix = new List<(int historyIndex, HistoryItem item, string line)>(); + var globalPrefix = new List<(int historyIndex, HistoryItem item, string line)>(); + + foreach (var m in prefixMatches) + { + if (string.Equals(m.item.Location, currentLocation, StringComparison.OrdinalIgnoreCase)) + localPrefix.Add(m); + else + globalPrefix.Add(m); + } + + localPrefix.Sort(LocalFrecencyCompare); + globalPrefix.Sort(FrecencyCompare); + + // Top half for local, bottom half for global. Backfill if local has fewer. + int halfCount = count / 2; + int localSlots = Math.Min(halfCount, localPrefix.Count); + int globalSlots = count - localSlots; + + // Add local prefix matches (top half). + for (int i = 0; i < localSlots; i++) + { + var m = localPrefix[i]; + results.Add(MakeSQLiteEntry(m.item, m.line, matchIndex: 0)); + } + + // Track how many local items were added for dedup. + var addedLines = new HashSet(comparer); + foreach (var entry in results) + { + addedLines.Add(entry.SuggestionText); + } + + // Add global prefix matches (bottom half), excluding already-added items. + int globalAdded = 0; + for (int i = 0; i < globalPrefix.Count && globalAdded < globalSlots; i++) + { + var m = globalPrefix[i]; + if (!addedLines.Contains(m.line)) + { + results.Add(MakeSQLiteEntry(m.item, m.line, matchIndex: 0)); + addedLines.Add(m.line); + globalAdded++; + } + } + + // If still room, backfill from remaining local prefix matches. + for (int i = localSlots; i < localPrefix.Count && results.Count < count; i++) + { + var m = localPrefix[i]; + if (!addedLines.Contains(m.line)) + { + results.Add(MakeSQLiteEntry(m.item, m.line, matchIndex: 0)); + addedLines.Add(m.line); + } + } + + // Fill remaining slots with substring matches sorted by frecency, + // local first then global. + if (results.Count < count && substringMatches.Count > 0) + { + var localSub = new List<(int historyIndex, HistoryItem item, string line)>(); + var globalSub = new List<(int historyIndex, HistoryItem item, string line)>(); + + foreach (var m in substringMatches) + { + if (!addedLines.Contains(m.line)) + { + if (string.Equals(m.item.Location, currentLocation, StringComparison.OrdinalIgnoreCase)) + localSub.Add(m); + else + globalSub.Add(m); + } + } + + localSub.Sort(LocalFrecencyCompare); + globalSub.Sort(FrecencyCompare); + + int subRemaining = count - results.Count; + int subHalf = subRemaining / 2; + int localSubSlots = Math.Min(subHalf, localSub.Count); + + for (int i = 0; i < localSubSlots && results.Count < count; i++) + { + var m = localSub[i]; + results.Add(MakeSQLiteEntry(m.item, m.line, input.Length > 0 ? m.line.IndexOf(input, comparison) : 0)); + } + + for (int i = 0; i < globalSub.Count && results.Count < count; i++) + { + var m = globalSub[i]; + results.Add(MakeSQLiteEntry(m.item, m.line, input.Length > 0 ? m.line.IndexOf(input, comparison) : 0)); + } + + // Backfill from remaining local substring matches. + for (int i = localSubSlots; i < localSub.Count && results.Count < count; i++) + { + var m = localSub[i]; + results.Add(MakeSQLiteEntry(m.item, m.line, input.Length > 0 ? m.line.IndexOf(input, comparison) : 0)); + } + } + } + else + { + // No location partitioning: sort all candidates by frecency. + prefixMatches.Sort(FrecencyCompare); + substringMatches.Sort(FrecencyCompare); + + for (int i = 0; i < prefixMatches.Count && results.Count < count; i++) + { + var m = prefixMatches[i]; + results.Add(MakeSQLiteEntry(m.item, m.line, matchIndex: 0)); + } + + for (int i = 0; i < substringMatches.Count && results.Count < count; i++) + { + var m = substringMatches[i]; + results.Add(MakeSQLiteEntry(m.item, m.line, input.Length > 0 ? m.line.IndexOf(input, comparison) : 0)); + } + } + + return results.Count > 0 ? results : null; + } + + /// + /// Creates a SuggestionEntry for a SQLite history item with stats tooltip. + /// + private static SuggestionEntry MakeSQLiteEntry(HistoryItem item, string line, int matchIndex) + { + string tooltip = FormatHistoryStatsTooltip(item); + return new SuggestionEntry(line, tooltip, matchIndex, item); + } + /// /// Calls to the prediction API for suggestion results. /// @@ -686,6 +966,16 @@ internal override void RenderSuggestion(List consoleBufferLines, _tooltipHeight = 0; } + // Clamp view window to the current list size. + // After item removal, _listViewEnd may exceed _listItems.Count when + // _selectedIndex is -1 (original input) and the recalculation above was skipped. + if (_listViewEnd > _listItems.Count) + { + _listViewEnd = _listItems.Count; + _listViewTop = Math.Max(0, _listViewEnd - _maxViewHeight); + _listViewHeight = _listViewEnd - _listViewTop; + } + for (int i = _listViewTop; i < _listViewEnd; i++) { bool itemSelected = i == _selectedIndex; @@ -700,7 +990,14 @@ internal override void RenderSuggestion(List consoleBufferLines, if (_singleton._options.ShowToolTips && itemSelected && !string.IsNullOrWhiteSpace(entry.ToolTip)) { - _tooltipHeight = RenderTooltip(entry.ToolTip, consoleBufferLines, ref currentLogicalLine); + if (entry.HistoryItemRef != null) + { + _tooltipHeight = RenderHistoryStatsTooltip(entry.HistoryItemRef, consoleBufferLines, ref currentLogicalLine); + } + else + { + _tooltipHeight = RenderTooltip(entry.ToolTip, consoleBufferLines, ref currentLogicalLine); + } } } } @@ -1057,6 +1354,69 @@ private int RenderTooltip(string tooltip, List consoleBufferLines return _maxTooltipHeight - (linesLeft > 0 ? linesLeft : 0); } + /// + /// Render a colored history stats tooltip for a history item. + /// Uses icons with short labels for accessibility, values in the highlight color, separators dimmed. + /// When is enabled, + /// emoji icons are omitted so screen readers read only the human-readable labels. + /// + private int RenderHistoryStatsTooltip(HistoryItem item, List consoleBufferLines, ref int currentLogicalLine) + { + string tooltipColor = _singleton._options._listPredictionTooltipColor; + string dimItalicStyle = tooltipColor + "\x1b[2;3m"; + // Icons must NOT be italic (causes emoji to lean/slant). + // Use explicit italic-off (\x1b[23m] to cancel italic inherited from tooltipColor. + const string italicOff = "\x1b[23m"; + string valueStyle = _singleton._options._listPredictionColor; + bool accessible = _singleton._options.AccessibleHistoryDisplay; + + var buff = NextBufferLine(consoleBufferLines, ref currentLogicalLine); + buff.Append(' ', 6); + + // ⟳ Runs N + buff.Append(dimItalicStyle).Append(italicOff); + if (!accessible) buff.Append("\u27f3 "); + buff.Append("\x1b[3m").Append("Runs ") + .Append(VTColorUtils.AnsiReset).Append(valueStyle).Append(item.ExecutionCount) + .Append(VTColorUtils.AnsiReset); + + // ⏱ Last relative-time + if (item.StartTime != default) + { + var ago = DateTime.UtcNow - item.StartTime; + string relativeTime; + if (ago.TotalMinutes < 1) + relativeTime = "just now"; + else if (ago.TotalHours < 1) + relativeTime = $"{(int)ago.TotalMinutes}m ago"; + else if (ago.TotalDays < 1) + relativeTime = $"{(int)ago.TotalHours}h ago"; + else if (ago.TotalDays < 30) + relativeTime = $"{(int)ago.TotalDays}d ago"; + else + relativeTime = item.StartTime.ToLocalTime().ToString("MMM d"); + + buff.Append(dimItalicStyle).Append(" \u2502 ").Append(italicOff); + if (!accessible) buff.Append("\u23f1 "); + buff.Append("\x1b[3m").Append("Last ") + .Append(VTColorUtils.AnsiReset).Append(valueStyle).Append(relativeTime) + .Append(VTColorUtils.AnsiReset); + } + + // 📂 Dir path + if (!string.IsNullOrEmpty(item.Location) && !item.Location.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + { + buff.Append(dimItalicStyle).Append(" \u2502 ").Append(italicOff); + if (!accessible) buff.Append("\U0001F4C2 "); + buff.Append("\x1b[3m").Append("Dir ") + .Append(VTColorUtils.AnsiReset).Append(valueStyle).Append(item.Location) + .Append(VTColorUtils.AnsiReset); + } + + buff.Append(VTColorUtils.AnsiReset); + return 1; + } + /// /// Trigger the feedback about a suggestion was accepted. /// @@ -1117,6 +1477,94 @@ internal override void Reset() _warnAboutSize = _checkOnHeight = _updatePending = _renderFromSelected = false; } + /// + /// Remove the currently selected item from the list view and re-query + /// history suggestions so the list repopulates to full capacity. + /// + /// True if the list still has items; false if it became empty (caller should close the view). + internal bool RemoveSelectedItem() + { + if (_listItems == null || _selectedIndex < 0 || _selectedIndex >= _listItems.Count) + return false; + + int savedPosition = _selectedIndex; + + // Collect non-history (plugin) items to preserve them. + List pluginItems = null; + for (int i = 0; i < _listItems.Count; i++) + { + if (_listItems[i].Source != SuggestionEntry.HistorySource) + { + pluginItems ??= new List(); + pluginItems.Add(_listItems[i]); + } + } + + // Rebuild the list from scratch — the deleted command is already + // gone from _history, so fresh results naturally exclude it. + _listItems.Clear(); + _sources?.Clear(); + + if (UseHistory) + { + var freshHistory = GetHistorySuggestions(_inputText, HistoryMaxCount); + if (freshHistory != null) + { + _listItems.AddRange(freshHistory); + } + } + + if (pluginItems != null) + { + _listItems.AddRange(pluginItems); + } + + if (_listItems.Count == 0) + { + Reset(); + return false; + } + + // Rebuild _sources to reflect the updated indices. + RebuildSources(); + + // Restore selection at the same position, or move to the last item. + _selectedIndex = Math.Min(savedPosition, _listItems.Count - 1); + + // Re-initialize view window from the selected item. + _listViewTop = 0; + _listViewEnd = Math.Min(_listItems.Count, _maxViewHeight); + _listViewHeight = _listViewEnd - _listViewTop; + _tooltipHeight = 0; + _renderFromSelected = true; + _updatePending = true; + return true; + } + + /// + /// Rebuild the list from the current . + /// + private void RebuildSources() + { + _sources ??= new List(); + _sources.Clear(); + + if (_listItems == null || _listItems.Count == 0) + return; + + int prevEndIndex = -1; + int segStart = 0; + for (int i = 1; i <= _listItems.Count; i++) + { + if (i == _listItems.Count || _listItems[i].Source != _listItems[segStart].Source) + { + _sources.Add(new SourceInfo(_listItems[segStart].Source, i - 1, prevEndIndex)); + prevEndIndex = i - 1; + segStart = i; + } + } + } + /// /// Update the index of the selected item based on . /// diff --git a/PSReadLine/Prediction.cs b/PSReadLine/Prediction.cs index bd1448b9..179c01d5 100644 --- a/PSReadLine/Prediction.cs +++ b/PSReadLine/Prediction.cs @@ -57,6 +57,11 @@ private static void UpdatePredictionClient(Runspace runspace, EngineIntrinsics e } } + private static string GetCurrentLocation(EngineIntrinsics engineIntrinsics) + { + return engineIntrinsics?.SessionState?.Path?.CurrentLocation?.Path ?? "Unknown"; + } + // Stub helper methods so prediction can be mocked [ExcludeFromCodeCoverage] Task> IPSConsoleReadLineMockableMethods.PredictInputAsync(Ast ast, Token[] tokens) diff --git a/PSReadLine/ReadLine.cs b/PSReadLine/ReadLine.cs index da890bbb..67787907 100644 --- a/PSReadLine/ReadLine.cs +++ b/PSReadLine/ReadLine.cs @@ -544,6 +544,7 @@ private string InputLoop() var tabCommandCount = _tabCommandCount; var searchHistoryCommandCount = _searchHistoryCommandCount; var recallHistoryCommandCount = _recallHistoryCommandCount; + var locationHistoryCommandCount = _locationHistoryCommandCount; var anyHistoryCommandCount = _anyHistoryCommandCount; var yankLastArgCommandCount = _yankLastArgCommandCount; var visualSelectionCommandCount = _visualSelectionCommandCount; @@ -567,7 +568,15 @@ private string InputLoop() if (_inputAccepted) { _acceptedCommandLine = _buffer.ToString(); - MaybeAddToHistory(_acceptedCommandLine, _edits, _undoEditIndex); + if (_options.HistoryType == HistoryType.SQLite) + { + string location = _engineIntrinsics?.SessionState?.Path?.CurrentLocation?.Path; + MaybeAddToHistory(_acceptedCommandLine, _edits, _undoEditIndex, location); + } + else + { + MaybeAddToHistory(_acceptedCommandLine, _edits, _undoEditIndex); + } _prediction.OnCommandLineAccepted(_acceptedCommandLine); return _acceptedCommandLine; @@ -610,6 +619,15 @@ private string InputLoop() { _recallHistoryCommandCount = 0; } + if (locationHistoryCommandCount == _locationHistoryCommandCount) + { + // Reset only the per-keystroke counter. Keep _locationSortedIndices / + // _locationSortedPosition / _locationHistoryActive alive so plain + // Up/Down can stay in sticky location mode after the user releases Alt. + // Full teardown happens below in the anyHistoryCommandCount branch + // when the user actually does a non-history operation. + _locationHistoryCommandCount = 0; + } if (anyHistoryCommandCount == _anyHistoryCommandCount) { if (_anyHistoryCommandCount > 0) @@ -617,6 +635,16 @@ private string InputLoop() ClearSavedCurrentLine(); _hashedHistory = null; _currentHistoryIndex = _history.Count; + // User did something other than history navigation — exit sticky + // location mode and clear the position indicator. + _locationHistoryActive = false; + _locationSortedIndices = null; + _locationSortedPosition = -1; + if (_historyNavStatusActive) + { + _historyNavStatusActive = false; + ClearStatusMessage(render: true); + } } _anyHistoryCommandCount = 0; } @@ -812,6 +840,18 @@ private void Initialize(Runspace runspace, EngineIntrinsics engineIntrinsics) _yankLastArgCommandCount = 0; _tabCommandCount = 0; _recallHistoryCommandCount = 0; + _locationHistoryCommandCount = 0; + _locationSortedIndices = null; + _locationSortedPosition = -1; + _locationHistoryActive = false; + // Clear any leftover history-nav status indicator from the previous + // ReadLine() invocation so it doesn't shift cursor/render math. + if (_historyNavStatusActive) + { + _statusLinePrompt = null; + _statusBuffer.Clear(); + _historyNavStatusActive = false; + } _anyHistoryCommandCount = 0; _visualSelectionCommandCount = 0; _hashedHistory = null; @@ -915,11 +955,16 @@ private void DelayedOneTimeInitialize() { } - if (readHistoryFile) + if (readHistoryFile && _options.HistoryType == HistoryType.Text) { ReadHistoryFile(); } + if (readHistoryFile && _options.HistoryType == HistoryType.SQLite) + { + ReadSQLiteHistory(fromOtherSession: false); + } + _killIndex = -1; // So first add indexes 0. _killRing = new List(Options.MaximumKillRingCount); diff --git a/README.md b/README.md index d1ab3d11..beccaf82 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ Get-PSReadLineKeyHandler There are many configuration options, see the options to `Set-PSReadLineOption`. `PSReadLine` has help for its cmdlets as well as an `about_PSReadLine` topic - see those topics for more detailed help. +For the new SQLite-backed history available in PSReadLine 3.0+ — including the location-aware key chords (`Alt+Up` / `Alt+Down`, `Ctrl+Alt+R`, `Alt+Delete`), the F2 stats tooltip, and the `-AccessibleHistoryDisplay` accessibility option — see [docs/sqlite-history.md](docs/sqlite-history.md). + To set your own custom keybindings, use the cmdlet `Set-PSReadLineKeyHandler`. For example, for a better history experience, try: diff --git a/docs/sqlite-history.md b/docs/sqlite-history.md new file mode 100644 index 00000000..3a18203d --- /dev/null +++ b/docs/sqlite-history.md @@ -0,0 +1,347 @@ +# SQLite History (PSReadLine 3.0+) + +PSReadLine 3.0 introduces a SQLite-backed command history as an alternative to +the traditional text-file history. The text history remains the default; SQLite +mode unlocks **per-directory recall**, **frequency-aware suggestions**, **fast +indexed queries**, and **richer F2 list view metadata**. + +| | | +|---|---| +| **Module version** | `3.0.0` | +| **Required PowerShell** | `7.4` (LTS) or later | +| **Target framework** | `net8.0` | +| **Backwards compatibility** | Users on PowerShell < 7.4 must stay on PSReadLine 2.x | + +> ⚠️ **Breaking change.** PSReadLine 3.0 drops support for `netstandard2.0` and +> for PowerShell 5.1 / 7.0 / 7.2 because `Microsoft.Data.Sqlite` requires +> .NET 6+ APIs (`NativeLibrary.SetDllImportResolver`, populated `runtimeTargets` +> in `deps.json`) for cross-platform native deployment. + +--- + +## Quick start + +```powershell +# One-time switch to SQLite (also auto-migrates your existing text history) +Set-PSReadLineOption -HistoryType SQLite + +# Verify +(Get-PSReadLineOption).HistoryType # SQLite +(Get-PSReadLineOption).HistorySavePath # …\PSReadLine\_history.db +``` + +To make it permanent, add `Set-PSReadLineOption -HistoryType SQLite` to your +PowerShell profile (`$PROFILE`). + +To revert to text history at any time: + +```powershell +Set-PSReadLineOption -HistoryType Text +``` + +The `.db` file is preserved when switching back; switching to SQLite again +later picks up where you left off. + +--- + +## Configuration options + +Set via `Set-PSReadLineOption`, inspect via `Get-PSReadLineOption`. + +| Option | Type | Default | Purpose | +|---|---|---|---| +| `-HistoryType` | `HistoryType` enum | `Text` | `Text` | `SQLite`. Switches the active history backend. | +| `-HistorySavePathText` | `string` | platform-default `.txt` | Path to the text history file. Set independently of the active mode. | +| `-HistorySavePathSQLite` | `string` | platform-default `.db` | Path to the SQLite database. Set independently of the active mode. | +| `-AccessibleHistoryDisplay` | `switch` | `false` (or `true` if a screen reader is detected at startup) | Replaces emoji icons in the F2 stats tooltip and history navigation indicator with plain text labels (`Runs`, `Last`, `Dir`, `History`, `Location`). | + +The read-only computed property `(Get-PSReadLineOption).HistorySavePath` always +returns the path of the *currently active* backend — it's `HistorySavePathText` +when `HistoryType=Text`, `HistorySavePathSQLite` when `HistoryType=SQLite`. + +### `AddToHistoryOption.SQLite` + +Custom `AddToHistoryHandler` script blocks may now return the new +`AddToHistoryOption.SQLite` value alongside the existing `SkipAdding`, +`MemoryOnly`, and `MemoryAndFile` options. When SQLite mode is active, the +default handler treats it interchangeably with `MemoryAndFile`. + +### Default file paths + +| Platform | Path | +|---|---| +| Windows | `%APPDATA%\Microsoft\Windows\PowerShell\PSReadLine\_history.db` | +| Linux/macOS (XDG) | `$XDG_DATA_HOME/powershell/PSReadLine/_history.db` | +| Linux/macOS (HOME) | `~/.local/share/powershell/PSReadLine/_history.db` | +| Fallback | `/dev/null` | + +`` is the PowerShell host name (typically `ConsoleHost`). The text history +file uses the same path with a `.txt` extension. + +--- + +## Key chords + +The SQLite feature adds five new key bindings (and re-binds `Alt+Delete`). +All are available in **Windows** and **Emacs** key modes; **Vi** mode is +unchanged. + +### Windows mode (default) + +| Chord | Action | Notes | +|---|---|---| +| `Alt+UpArrow` | `PreviousLocationHistory` | Recall the previous command run from the **current directory**, sorted by frequency × recency. Falls back to chronological recall if the location is unknown. | +| `Alt+DownArrow` | `NextLocationHistory` | Forward direction of `PreviousLocationHistory`. | +| `Alt+Delete` | `RemoveFromHistoryAtCurrentLocation` | Delete the currently displayed history item from the **current directory only**. Other locations that ran the same command are preserved. Also works in the F2 list. **Previously bound to `KillWord`.** | +| `Ctrl+Shift+Delete` | `RemoveFromHistory` | Delete the currently displayed history item from **all locations**. Windows-only. | +| `Alt+F7` | `ClearHistory` | (Pre-existing.) Now also drops every row from the SQLite database. Windows-only. | + +`KillWord` remains available on `Alt+D` and `Ctrl+Delete` (Windows). + +### Emacs mode + +| Chord | Action | Notes | +|---|---|---| +| `Alt+UpArrow` | `PreviousLocationHistory` | Same as Windows mode. | +| `Alt+DownArrow` | `NextLocationHistory` | Same as Windows mode. | +| `Alt+Delete` | `RemoveFromHistoryAtCurrentLocation` | Same as Windows mode. | +| `Ctrl+Alt+R` | `ReverseLocationSearchHistory` | Incremental backward search restricted to commands run from the current directory. (`Ctrl+R` continues to search all history.) | +| `Ctrl+Shift+Delete` | `RemoveFromHistory` | Same as Windows mode. Windows-only. | + +### Up / Down arrow (`PreviousHistory` / `NextHistory`) + +The plain Up / Down arrow recall is **chronological** in both modes — same +behavior as text mode. SQLite-side deduplication via `ROW_NUMBER()` means an +identical command appears only once even if executed many times. + +### F2 (`SwitchPredictionView`) + +Pre-existing key. In SQLite mode, the F2 list view now shows a colored +**stats tooltip** (`Runs`, `Last`, `Dir`) for the selected entry, and the +top half of the list is filtered to commands run from the current directory. +See [F2 list view enhancements](#f2-list-view-enhancements). + +### Known terminal collision + +`Alt+UpArrow` / `Alt+DownArrow` collide with **VS Code's** integrated terminal +selection mode. Use Windows Terminal, iTerm2, or a standalone `pwsh.exe` +window for these chords. They work fine everywhere else. + +--- + +## Public methods (programmatic API) + +These `[Microsoft.PowerShell.PSConsoleReadLine]` static methods can be invoked +from scripts or bound to custom keys via `Set-PSReadLineKeyHandler -Function`. + +| Method | Description | +|---|---| +| `RemoveHistoryItem(string commandLine)` → `bool` | Removes every occurrence of the given command from in-memory history and (when in SQLite mode) drops the matching `Commands` row plus all its `ExecutionHistory` entries. Returns `true` if anything was removed. | +| `RemoveHistoryItemAtLocation(string commandLine, string location)` → `bool` | Like `RemoveHistoryItem` but scoped to a single directory. In SQLite mode, only the matching `(Command, Location)` row in `ExecutionHistory` is deleted; the `Commands` row is dropped only when no other location still references it. | +| `RemoveFromHistory()` | Key-handler entry point. Removes the currently displayed history item globally and advances to the next older item. Also handles deletion when an item is selected in the F2 list view. | +| `RemoveFromHistoryAtCurrentLocation()` | Key-handler entry point. Removes the current item from the current location only (in SQLite mode). In text mode, falls back to a global remove because per-location data isn't tracked. | +| `PreviousLocationHistory()` / `NextLocationHistory()` | Key-handler entry points for `Alt+Up` / `Alt+Down` location-aware recall. | +| `ReverseLocationSearchHistory()` / `ForwardLocationSearchHistory()` | Incremental search filtered to the current directory. | +| `ClearHistory()` | Pre-existing. Now also wipes the SQLite database when in SQLite mode. | + +--- + +## F2 list view enhancements + +### Stats tooltip + +When `HistoryType=SQLite`, selecting a history item in the F2 prediction list +view shows a colored stats line below the selection: + +``` +↻ Runs 47 │ ⏱ Last 2m ago │ 📂 Dir ~/repos/PSReadline +``` + +| Field | Source | +|---|---| +| `Runs` | `SUM(ExecutionCount)` across all locations — the total number of times this command has been executed anywhere. | +| `Last` | Relative time derived from `StartTime` (`just now` / `Nm ago` / `Nh ago` / `Nd ago` / `MMM d`). | +| `Dir` | `Location` — the directory where this command was last executed. Omitted when location is unknown. | + +The tooltip uses the existing `ShowToolTips` infrastructure (default `true`) +and the `ListPredictionTooltip` color. In text history mode the tooltip +remains `null` (no per-command stats are tracked). + +### Ordering + +| Position | Sort key | Notes | +|---|---|---| +| Top half | **Per-location** frecency (`ExecutionCount` at this directory + recency) | Filtered to commands matching `$PWD`. | +| Bottom half | **Total** frecency (sum across all locations + recency) | Excludes items already shown in the top half. | +| Backfill | Bottom-half items | Used when fewer than half-capacity local matches exist. | +| Plugins active (3 slots) | Total frecency only | Too few slots to split into local / global partitions. | + +In **text mode** the F2 list ordering is unchanged — pure recency. + +--- + +## History navigation indicator + +While you're stepping through history with Up / Down or `Alt+Up` / `Alt+Down`, +PSReadLine renders a small status indicator below the prompt: + +| Mode | Indicator | Source | +|---|---|---| +| Chronological recall | `[⏱ 3/15]` | `Up` / `Down`, `Ctrl+P` / `Ctrl+N` | +| Location-filtered recall | `[📂 2/5]` | `Alt+Up` / `Alt+Down` | + +The indicator is cleared as soon as you press a non-history key (typing +characters, Enter, Escape, etc.). The position counter only counts +**navigable** items — cross-session entries marked `FromOtherSession` are +skipped, so the count won't jump unexpectedly. + +This indicator is rendered in both Text and SQLite modes whenever the +respective recall functions are invoked. + +--- + +## Accessibility + +`-AccessibleHistoryDisplay` swaps the emoji icons used by the SQLite history +UX for plain-text labels so screen readers don't verbalize Unicode character +names ("clockwise gapped circle arrow", "card index dividers"). + +```powershell +Set-PSReadLineOption -AccessibleHistoryDisplay +``` + +| Surface | Default rendering | With `-AccessibleHistoryDisplay` | +|---|---|---| +| F2 stats tooltip | `↻ Runs 47 │ ⏱ Last 2m ago │ 📂 Dir ` | `Runs 47 │ Last 2m ago │ Dir ` | +| Chronological nav indicator | `[⏱ 3/15]` | `[History 3/15]` | +| Location-filtered nav indicator | `[📂 2/5]` | `[Location 2/5]` | + +### Default value + +The option is **seeded once at startup** from the value of +`ScreenReaderModeEnabled`, so users running with a screen reader active get +plain-text labels automatically. + +After construction the two options are independent — toggling +`-EnableScreenReaderMode` later does **not** auto-flip +`-AccessibleHistoryDisplay`. This is intentional: it lets sighted users +opt in to plain text (e.g. on terminals without emoji-font support) without +touching the broader screen-reader rendering mode. + +--- + +## Migration from text history + +When you switch to SQLite mode for the first time and the configured +`HistorySavePathSQLite` file does **not** exist, PSReadLine automatically +imports your existing text history (`HistorySavePathText`) into the new +database. + +### How timestamps are assigned + +Migrated lines have **no original timestamps** in the text file, so PSReadLine +assigns synthetic ones such that: + +1. All migrated entries are stamped **older than "now"**, so any new SQLite + entries sort after them. +2. The original chronological order in the `.txt` file is preserved. + +Concretely: the oldest line gets the earliest timestamp; each subsequent line +gets one minute later; the newest text-history line is still at least one +minute older than the first command you run after the switch. + +### What happens to the `.txt` file + +The text file is **not** deleted or modified — migration only reads from it. +You can switch back to text history at any time and resume using it. If you +want to keep both backends in sync, switch to SQLite mode after each session; +the most recent commands are picked up from the text file on each migration. + +To migrate explicitly later (e.g. after editing the text file by hand), point +`HistorySavePathSQLite` at a path that doesn't exist yet and switch modes: + +```powershell +Set-PSReadLineOption -HistorySavePathSQLite "$HOME\new-history.db" +Set-PSReadLineOption -HistoryType SQLite # triggers migration +``` + +--- + +## Text vs SQLite — feature comparison + +| | Text (default) | SQLite | +|---|---|---| +| Backend | Plain text file | Indexed SQLite database | +| Up / Down recall | Chronological | Chronological (DB-side dedup) | +| `Alt+Up` / `Alt+Down` | Same as Up / Down | **Per-directory** frequency × recency | +| F2 list ordering | Pure recency | Local frecency + global frecency split | +| F2 stats tooltip | Not shown | Runs / Last / Dir | +| History navigation indicator | Shown | Shown | +| `Alt+Delete` removal scope | Global (no per-directory data) | Current directory only | +| `Ctrl+Shift+Delete` removal scope | Global | Global (all directories) | +| `Ctrl+Alt+R` (Emacs) | All-history search | Current-directory search | +| Cross-session merging | Append-on-save | Incremental DB reads | +| Lookup complexity | O(n) linear scan | O(log n) indexed | +| Human-readable on disk | Yes | No (binary `.db`) | +| Native dependency | None | `e_sqlite3` (auto-deployed per RID) | + +--- + +## Database schema (for advanced users) + +The SQLite database uses a normalized three-table schema. You can query it +directly with the `sqlite3` CLI or any SQLite client. + +| Table | Columns (key fields) | +|---|---| +| `Commands` | `Id`, `CommandLine`, `CommandHash` (indexed) | +| `Locations` | `Id`, `Path` (normalized) | +| `ExecutionHistory` | `Id`, `CommandId` (FK), `LocationId` (FK), `StartTime` (Unix seconds), `LastExecuted` (Unix seconds, indexed), `ElapsedTime`, `ExecutionCount` (indexed) | + +A convenience view `HistoryView` joins the three tables and exposes +denormalized `CommandLine`, `Location`, `LastExecuted`, and `ExecutionCount` +columns, e.g.: + +```sql +SELECT CommandLine, Location, datetime(LastExecuted, 'unixepoch'), ExecutionCount +FROM HistoryView +ORDER BY LastExecuted DESC +LIMIT 20; +``` + +Timestamps are stored as **Unix seconds** (`ToUnixTimeSeconds()`). Use +`datetime(col, 'unixepoch')` in SQL queries — do **not** treat them as .NET ticks. + +--- + +## Limitations and known issues + +1. **Same command at different directories, back-to-back.** `HistoryNoDuplicates` + (default `true`) drops a new entry whose `CommandLine` exactly matches the + *previous in-memory item*, regardless of `Location`. Typing `git status` + in `~/repos/A`, then `cd ~/repos/B`, then `git status` again will record + only the first occurrence. + +2. **Cross-session live merge.** When multiple `pwsh` sessions are open at the + same time, each session sees commands from other sessions only on its own + incremental read. Items from other sessions are flagged `FromOtherSession` + and are skipped by `Up` / `Down` recall (so you don't accidentally rerun a + colleague's command from a different terminal). + +3. **VS Code terminal `Alt+Up` collision.** As noted above, VS Code intercepts + `Alt+Up` for terminal-selection mode. Use a different host or rebind. + +4. **PowerShell < 7.4 unsupported.** Trying to load PSReadLine 3.0 on an + older PowerShell will fail at module-import time. Stay on PSReadLine 2.x + if you need 5.1 / 7.0 / 7.2 support. + +--- + +## Related skill notes + +The implementation details (recall ordering algorithm, native library +deployment, framework-migration rationale, F2 list rendering pitfalls) are +documented separately in the maintainer-facing skill files under +`~/.copilot/skills/sqlite-history/` — see `SKILL.md` and the four +`references/*.md` files for architecture, schema-and-history logic, +API surface, and testing patterns. diff --git a/nuget.config b/nuget.config index a10ce9b3..a079cf1b 100644 --- a/nuget.config +++ b/nuget.config @@ -1,8 +1,7 @@ - - + diff --git a/test/HistoryTest.cs b/test/HistoryTest.cs index ad6b95f0..8066d10e 100644 --- a/test/HistoryTest.cs +++ b/test/HistoryTest.cs @@ -40,13 +40,14 @@ public void ParallelHistorySaving() TestSetup(KeyMode.Cmd); string historySavingFile = Path.GetTempFileName(); - var options = new SetPSReadLineOption { + var options = new SetPSReadLineOption + { HistorySaveStyle = HistorySaveStyle.SaveIncrementally, MaximumHistoryCount = 3, }; typeof(SetPSReadLineOption) - .GetField("_historySavePath", BindingFlags.Instance | BindingFlags.NonPublic) + .GetField("_historySavePathText", BindingFlags.Instance | BindingFlags.NonPublic) .SetValue(options, historySavingFile); PSConsoleReadLine.SetOptions(options); @@ -98,7 +99,7 @@ public void SensitiveHistoryDefaultBehavior_One() Test("", Keys(_.UpArrow, _.DownArrow)); var options = PSConsoleReadLine.GetOptions(); - var oldHistoryFilePath = options.HistorySavePath; + var oldHistoryFilePath = options.HistorySavePathText; var oldHistorySaveStyle = options.HistorySaveStyle; // AddToHistoryHandler should be set to the default handler. @@ -135,7 +136,7 @@ public void SensitiveHistoryDefaultBehavior_One() try { - options.HistorySavePath = newHistoryFilePath; + options.HistorySavePathText = newHistoryFilePath; options.HistorySaveStyle = newHistorySaveStyle; SetHistory(expectedHistoryItems); @@ -157,7 +158,7 @@ public void SensitiveHistoryDefaultBehavior_One() } finally { - options.HistorySavePath = oldHistoryFilePath; + options.HistorySavePathText = oldHistoryFilePath; options.HistorySaveStyle = oldHistorySaveStyle; File.Delete(newHistoryFilePath); } @@ -172,7 +173,7 @@ public void SensitiveHistoryDefaultBehavior_Two() SetHistory(); var options = PSConsoleReadLine.GetOptions(); - var oldHistoryFilePath = options.HistorySavePath; + var oldHistoryFilePath = options.HistorySavePathText; var oldHistorySaveStyle = options.HistorySaveStyle; // AddToHistoryHandler should be set to the default handler. @@ -252,7 +253,7 @@ public void SensitiveHistoryDefaultBehavior_Two() try { - options.HistorySavePath = newHistoryFilePath; + options.HistorySavePathText = newHistoryFilePath; options.HistorySaveStyle = newHistorySaveStyle; SetHistory(expectedHistoryItems); @@ -274,7 +275,7 @@ public void SensitiveHistoryDefaultBehavior_Two() } finally { - options.HistorySavePath = oldHistoryFilePath; + options.HistorySavePathText = oldHistoryFilePath; options.HistorySaveStyle = oldHistorySaveStyle; File.Delete(newHistoryFilePath); } @@ -290,7 +291,7 @@ public void SensitiveHistoryOptionalBehavior() Test("", Keys(_.UpArrow, _.DownArrow)); var options = PSConsoleReadLine.GetOptions(); - var oldHistoryFilePath = options.HistorySavePath; + var oldHistoryFilePath = options.HistorySavePathText; var oldHistorySaveStyle = options.HistorySaveStyle; // AddToHistoryHandler should be set to the default handler. @@ -327,7 +328,7 @@ public void SensitiveHistoryOptionalBehavior() try { - options.HistorySavePath = newHistoryFilePath; + options.HistorySavePathText = newHistoryFilePath; options.HistorySaveStyle = newHistorySaveStyle; // @@ -416,7 +417,7 @@ public void SensitiveHistoryOptionalBehavior() } finally { - options.HistorySavePath = oldHistoryFilePath; + options.HistorySavePathText = oldHistoryFilePath; options.HistorySaveStyle = oldHistorySaveStyle; options.AddToHistoryHandler = PSConsoleReadLineOptions.DefaultAddToHistoryHandler; File.Delete(newHistoryFilePath); @@ -433,7 +434,7 @@ public void SensitiveHistoryOptionalBehaviorWithScriptBlock() Test("", Keys(_.UpArrow, _.DownArrow)); var options = PSConsoleReadLine.GetOptions(); - var oldHistoryFilePath = options.HistorySavePath; + var oldHistoryFilePath = options.HistorySavePathText; var oldHistorySaveStyle = options.HistorySaveStyle; // AddToHistoryHandler should be set to the default handler. @@ -480,7 +481,7 @@ public void SensitiveHistoryOptionalBehaviorWithScriptBlock() try { - options.HistorySavePath = newHistoryFilePath; + options.HistorySavePathText = newHistoryFilePath; options.HistorySaveStyle = newHistorySaveStyle; // @@ -569,7 +570,7 @@ public void SensitiveHistoryOptionalBehaviorWithScriptBlock() } finally { - options.HistorySavePath = oldHistoryFilePath; + options.HistorySavePathText = oldHistoryFilePath; options.HistorySaveStyle = oldHistorySaveStyle; options.AddToHistoryHandler = PSConsoleReadLineOptions.DefaultAddToHistoryHandler; File.Delete(newHistoryFilePath); @@ -756,48 +757,53 @@ public void SearchHistory() SetHistory(); Test(" ", Keys(' ', _.UpArrow, _.DownArrow)); - PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistorySearchCursorMovesToEnd = false}); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistorySearchCursorMovesToEnd = false }); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); SetHistory("dosomething", "ps p*", "dir", "echo zzz"); Test("dosomething", Keys( "d", - _.UpArrow, CheckThat(() => { + _.UpArrow, CheckThat(() => + { AssertScreenIs(1, emphasisColors, 'd', TokenClassification.Command, "ir"); AssertCursorLeftIs(1); }), - _.UpArrow, CheckThat(() => { + _.UpArrow, CheckThat(() => + { AssertScreenIs(1, emphasisColors, 'd', TokenClassification.Command, "osomething"); AssertCursorLeftIs(1); - }))); + }))); - PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistorySearchCursorMovesToEnd = true}); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistorySearchCursorMovesToEnd = true }); SetHistory("dosomething", "ps p*", "dir", "echo zzz"); Test("dosomething", Keys( "d", - _.UpArrow, CheckThat(() => { + _.UpArrow, CheckThat(() => + { AssertScreenIs(1, emphasisColors, 'd', TokenClassification.Command, "ir"); AssertCursorLeftIs(3); }), - _.UpArrow, CheckThat(() => { + _.UpArrow, CheckThat(() => + { AssertScreenIs(1, emphasisColors, 'd', TokenClassification.Command, "osomething"); AssertCursorLeftIs(11); }), - _.DownArrow, CheckThat(() => { + _.DownArrow, CheckThat(() => + { AssertScreenIs(1, emphasisColors, 'd', TokenClassification.Command, "ir"); AssertCursorLeftIs(3); }), - _.UpArrow, CheckThat(() => + _.UpArrow, CheckThat(() => { AssertScreenIs(1, emphasisColors, 'd', @@ -813,31 +819,34 @@ public void HistorySearchCursorMovesToEnd() new KeyHandler("UpArrow", PSConsoleReadLine.HistorySearchBackward), new KeyHandler("DownArrow", PSConsoleReadLine.HistorySearchForward)); - PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistorySearchCursorMovesToEnd = true}); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistorySearchCursorMovesToEnd = true }); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); SetHistory("dosomething", "ps p*", "dir", "echo zzz"); Test("dosomething", Keys( "d", - _.UpArrow, CheckThat(() => { + _.UpArrow, CheckThat(() => + { AssertScreenIs(1, emphasisColors, 'd', TokenClassification.Command, "ir"); AssertCursorLeftIs(3); }), - _.UpArrow, CheckThat(() => { + _.UpArrow, CheckThat(() => + { AssertScreenIs(1, emphasisColors, 'd', TokenClassification.Command, "osomething"); AssertCursorLeftIs(11); }), - _.DownArrow, CheckThat(() => { + _.DownArrow, CheckThat(() => + { AssertScreenIs(1, emphasisColors, 'd', TokenClassification.Command, "ir"); AssertCursorLeftIs(3); }), - _.UpArrow, CheckThat(() => + _.UpArrow, CheckThat(() => { AssertScreenIs(1, emphasisColors, 'd', @@ -1122,7 +1131,7 @@ public void InteractiveHistorySearch() public void AddToHistoryHandler() { TestSetup(KeyMode.Cmd); - PSConsoleReadLine.SetOptions(new SetPSReadLineOption {AddToHistoryHandler = s => s.StartsWith("z")}); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { AddToHistoryHandler = s => s.StartsWith("z") }); SetHistory("zzzz", "azzz"); Test("zzzz", Keys(_.UpArrow)); @@ -1132,14 +1141,14 @@ public void AddToHistoryHandler() public void HistoryNoDuplicates() { TestSetup(KeyMode.Cmd); - PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistoryNoDuplicates = false}); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = false }); SetHistory("zzzz", "aaaa", "bbbb", "bbbb", "cccc"); Assert.Equal(5, PSConsoleReadLine.GetHistoryItems().Length); Test("aaaa", Keys(Enumerable.Repeat(_.UpArrow, 4))); // Changing the option should affect existing history. - PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistoryNoDuplicates = true}); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = true }); Test("zzzz", Keys(Enumerable.Repeat(_.UpArrow, 4))); SetHistory("aaaa", "bbbb", "bbbb", "cccc"); @@ -1164,7 +1173,7 @@ public void HistorySearchNoDuplicates() new KeyHandler("UpArrow", PSConsoleReadLine.HistorySearchBackward), new KeyHandler("DownArrow", PSConsoleReadLine.HistorySearchForward)); - PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistoryNoDuplicates = true}); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = true }); SetHistory("0000", "echo aaaa", "1111", "echo bbbb", "2222", "echo bbbb", "3333", "echo cccc", "4444"); Test("echo aaaa", Keys("echo", Enumerable.Repeat(_.UpArrow, 3))); @@ -1180,7 +1189,7 @@ public void InteractiveHistorySearchNoDuplicates() { TestSetup(KeyMode.Emacs); - PSConsoleReadLine.SetOptions(new SetPSReadLineOption {HistoryNoDuplicates = true}); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = true }); SetHistory("0000", "echo aaaa", "1111", "echo bbbb", "2222", "echo bbbb", "3333", "echo cccc", "4444"); Test("echo aaaa", Keys( _.Ctrl_r, "echo", _.Ctrl_r, _.Ctrl_r)); @@ -1203,7 +1212,7 @@ public void HistoryCount() // There should be 4 items in history, the following should remove the // oldest history item. - PSConsoleReadLine.SetOptions(new SetPSReadLineOption {MaximumHistoryCount = 3}); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { MaximumHistoryCount = 3 }); Test("aaaa", Keys(Enumerable.Repeat(_.UpArrow, 4))); Test("zzzz", Keys("zzzz")); diff --git a/test/KeyInfo-en-US-linux.json b/test/KeyInfo-en-US-linux.json index c107b691..8f9c8867 100644 --- a/test/KeyInfo-en-US-linux.json +++ b/test/KeyInfo-en-US-linux.json @@ -881,6 +881,13 @@ "Modifiers": "0", "Investigate": false }, + { + "Key": "Alt+Delete", + "KeyChar": "\u0000", + "ConsoleKey": "Delete", + "Modifiers": "Alt", + "Investigate": false + }, { "Key": "Spacebar", "KeyChar": " ", diff --git a/test/KeyInfo-en-US-windows.json b/test/KeyInfo-en-US-windows.json index c0fcc65d..90785717 100644 --- a/test/KeyInfo-en-US-windows.json +++ b/test/KeyInfo-en-US-windows.json @@ -725,6 +725,12 @@ "ConsoleKey": "Delete", "Modifiers": "Control" }, + { + "Key": "Alt+Delete", + "KeyChar": "\u0000", + "ConsoleKey": "Delete", + "Modifiers": "Alt" + }, { "Key": "Ctrl+e", "KeyChar": "\u0005", diff --git a/test/KeyInfo-fr-FR-windows.json b/test/KeyInfo-fr-FR-windows.json index 0aad3a2b..39ee7b61 100644 --- a/test/KeyInfo-fr-FR-windows.json +++ b/test/KeyInfo-fr-FR-windows.json @@ -832,6 +832,13 @@ "Modifiers": "Control", "Investigate": false }, + { + "Key": "Alt+Delete", + "KeyChar": "\u0000", + "ConsoleKey": "Delete", + "Modifiers": "Alt", + "Investigate": false + }, { "Key": "Ctrl+e", "KeyChar": "\u0005", diff --git a/test/KeyInfo-pl-PL-linux.json b/test/KeyInfo-pl-PL-linux.json index 1fdaea89..d15fa9fd 100644 --- a/test/KeyInfo-pl-PL-linux.json +++ b/test/KeyInfo-pl-PL-linux.json @@ -860,6 +860,13 @@ "Modifiers": "Control", "Investigate": false }, + { + "Key": "Alt+Delete", + "KeyChar": "\u0000", + "ConsoleKey": "Delete", + "Modifiers": "Alt", + "Investigate": false + }, { "Key": "Shift+PageDown", "KeyChar": "~", diff --git a/test/KeyInfo-pl-PL-windows.json b/test/KeyInfo-pl-PL-windows.json index 3bbf757f..fb29627d 100644 --- a/test/KeyInfo-pl-PL-windows.json +++ b/test/KeyInfo-pl-PL-windows.json @@ -1168,6 +1168,13 @@ "Modifiers": "Control", "Investigate": false }, + { + "Key": "Alt+Delete", + "KeyChar": "\u0000", + "ConsoleKey": "Delete", + "Modifiers": "Alt", + "Investigate": false + }, { "Key": "\u003c", "KeyChar": "\u003c", diff --git a/test/KeyInfo-ru-RU-windows.json b/test/KeyInfo-ru-RU-windows.json index 13d6c4ae..7f8c6cb1 100644 --- a/test/KeyInfo-ru-RU-windows.json +++ b/test/KeyInfo-ru-RU-windows.json @@ -678,6 +678,13 @@ "Modifiers": "Control", "Investigate": false }, + { + "Key": "Alt+Delete", + "KeyChar": "\u0000", + "ConsoleKey": "Delete", + "Modifiers": "Alt", + "Investigate": false + }, { "Key": "a", "KeyChar": "a", diff --git a/test/KillYankTest.cs b/test/KillYankTest.cs index 381e7889..4ca2906c 100644 --- a/test/KillYankTest.cs +++ b/test/KillYankTest.cs @@ -21,6 +21,103 @@ public void KillWord() _.End, _.Ctrl_y)); // Yank 'abc' at end of line } + [SkippableFact] + public void AltDeleteBoundToRemoveFromHistory_Emacs() + { + TestSetup(KeyMode.Emacs); + + // Alt+Delete is bound to RemoveFromHistory in Emacs mode. + // After deletion it advances to the next older item automatically. + SetHistory("echo first", "echo second"); + Test("echo first", Keys( + _.UpArrow, // recall "echo second" + _.Alt_Delete)); // remove "echo second", auto-shows "echo first" + } + + [SkippableFact] + public void AltDeleteBoundToRemoveFromHistory_Windows() + { + TestSetup(KeyMode.Cmd); + + // Alt+Delete is bound to RemoveFromHistory in Windows mode. + // After deletion it advances to the next older item automatically. + SetHistory("echo first", "echo second"); + Test("echo first", Keys( + _.UpArrow, // recall "echo second" + _.Alt_Delete)); // remove "echo second", auto-shows "echo first" + } + + [SkippableFact] + public void AltDeleteAdvancesToNextOlderItem() + { + TestSetup(KeyMode.Cmd); + + // After Alt+Delete, the next older history item should be displayed + // so the user can continue navigating deeper into history. + SetHistory("cmd1", "cmd2", "cmd3", "cmd4"); + Test("cmd2", Keys( + _.UpArrow, // recall "cmd4" + CheckThat(() => AssertLineIs("cmd4")), + _.Alt_Delete, // delete "cmd4", shows "cmd3" + CheckThat(() => AssertLineIs("cmd3")), + _.UpArrow, // continue to "cmd2" + CheckThat(() => AssertLineIs("cmd2")) + )); + } + + [SkippableFact] + public void AltDeleteConsecutiveDeletes() + { + TestSetup(KeyMode.Cmd); + + // User should be able to press Alt+Delete multiple times in a row + // to delete consecutive history items, each time advancing to the + // next older item. + SetHistory("cmd1", "cmd2", "cmd3", "cmd4"); + Test("cmd1", Keys( + _.UpArrow, // recall "cmd4" + CheckThat(() => AssertLineIs("cmd4")), + _.Alt_Delete, // delete "cmd4", shows "cmd3" + CheckThat(() => AssertLineIs("cmd3")), + _.Alt_Delete, // delete "cmd3", shows "cmd2" + CheckThat(() => AssertLineIs("cmd2")), + _.Alt_Delete, // delete "cmd2", shows "cmd1" + CheckThat(() => AssertLineIs("cmd1")) + )); + } + + [SkippableFact] + public void AltDeleteLastRemainingItem() + { + TestSetup(KeyMode.Cmd); + + // Deleting the only remaining history item should revert to empty line. + SetHistory("only-item"); + Test("", Keys( + _.UpArrow, // recall "only-item" + CheckThat(() => AssertLineIs("only-item")), + _.Alt_Delete // delete it, reverts to empty + )); + } + + [SkippableFact] + public void AltDeleteOldestItem() + { + TestSetup(KeyMode.Cmd); + + // When at the oldest item, deleting it should show the next remaining + // item (which is now the new oldest, at index 0). + SetHistory("cmd1", "cmd2", "cmd3"); + Test("cmd2", Keys( + _.UpArrow, // "cmd3" + _.UpArrow, // "cmd2" + _.UpArrow, // "cmd1" (oldest) + CheckThat(() => AssertLineIs("cmd1")), + _.Alt_Delete, // delete "cmd1", shows "cmd2" (new oldest) + CheckThat(() => AssertLineIs("cmd2")) + )); + } + [SkippableFact] public void BackwardKillWord() { diff --git a/test/SQLiteHistoryTest.cs b/test/SQLiteHistoryTest.cs new file mode 100644 index 00000000..fb4089a9 --- /dev/null +++ b/test/SQLiteHistoryTest.cs @@ -0,0 +1,2055 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.Data.Sqlite; +using Microsoft.PowerShell; +using Xunit; + +namespace Test +{ + public partial class ReadLine + { + /// + /// Helper to configure SQLite history mode for tests. + /// Sets up a temp .db file, switches HistoryType to SQLite with SaveIncrementally, + /// and returns a disposable that restores original settings and cleans up. + /// + private SQLiteTestContext SetupSQLiteHistory() + { + var options = PSConsoleReadLine.GetOptions(); + var ctx = new SQLiteTestContext + { + OriginalHistorySavePathText = options.HistorySavePathText, + OriginalHistorySavePathSQLite = options.HistorySavePathSQLite, + OriginalHistorySaveStyle = options.HistorySaveStyle, + OriginalHistoryType = options.HistoryType, + TempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db"), + }; + + // Point HistorySavePathSQLite to our temp DB before switching mode, + // because SetOptionsInternal reads HistorySavePathSQLite when HistoryType is set to SQLite. + options.HistorySavePathSQLite = ctx.TempDbPath; + + // Point HistorySavePathText to a non-existent file so migration doesn't + // accidentally pull from the real production text history. + options.HistorySavePathText = Path.ChangeExtension(ctx.TempDbPath, ".txt"); + + // Switch to SQLite mode — this creates the DB and clears in-memory history. + var setOptions = new SetPSReadLineOption + { + HistoryType = HistoryType.SQLite, + HistorySaveStyle = HistorySaveStyle.SaveIncrementally, + }; + PSConsoleReadLine.SetOptions(setOptions); + + return ctx; + } + + /// + /// Holds the original settings so they can be restored after a SQLite test. + /// + private class SQLiteTestContext : IDisposable + { + public string OriginalHistorySavePathText; + public string OriginalHistorySavePathSQLite; + public HistorySaveStyle OriginalHistorySaveStyle; + public HistoryType OriginalHistoryType; + public string TempDbPath; + + public void Dispose() + { + var options = PSConsoleReadLine.GetOptions(); + + // Restore original settings + options.HistorySavePathSQLite = OriginalHistorySavePathSQLite; + options.HistorySavePathText = OriginalHistorySavePathText; + options.HistorySaveStyle = OriginalHistorySaveStyle; + options.HistoryType = OriginalHistoryType; + + PSConsoleReadLine.ClearHistory(); + + // Clean up temp DB file + try { if (File.Exists(TempDbPath)) File.Delete(TempDbPath); } + catch { /* best effort */ } + } + } + + /// + /// Helper to count rows in a SQLite table. + /// + private long CountSQLiteRows(string dbPath, string tableName) + { + var connectionString = new SqliteConnectionStringBuilder($"Data Source={dbPath}") + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT COUNT(*) FROM {tableName}"; + return (long)cmd.ExecuteScalar(); + } + + /// + /// Helper to query command lines from the SQLite database. + /// + private string[] QuerySQLiteCommandLines(string dbPath) + { + var connectionString = new SqliteConnectionStringBuilder($"Data Source={dbPath}") + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT CommandLine FROM HistoryView ORDER BY LastExecuted ASC"; + using var reader = cmd.ExecuteReader(); + var results = new System.Collections.Generic.List(); + while (reader.Read()) + { + results.Add(reader.GetString(0)); + } + return results.ToArray(); + } + + // ===================================================================== + // SQLite Database Persistence Tests + // ===================================================================== + + [SkippableFact] + public void SQLiteHistory_WritesToDatabase() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + + // Run commands that get saved to history + Test("echo hello", Keys("echo hello")); + Test("dir", Keys("dir")); + Test("ps", Keys("ps")); + + // Verify the database has the expected commands + var commands = QuerySQLiteCommandLines(ctx.TempDbPath); + Assert.Contains("echo hello", commands); + Assert.Contains("dir", commands); + Assert.Contains("ps", commands); + } + + [SkippableFact] + public void SQLiteHistory_DatabaseSchemaCreated() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + + // Verify the database file was created + Assert.True(File.Exists(ctx.TempDbPath), "SQLite database file should exist"); + + // Verify the schema by checking tables exist + var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}") + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + // Check all three tables exist + foreach (var table in new[] { "Commands", "Locations", "ExecutionHistory" }) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name=@Name"; + cmd.Parameters.AddWithValue("@Name", table); + Assert.NotNull(cmd.ExecuteScalar()); + } + + // Check the view exists + using var viewCmd = connection.CreateCommand(); + viewCmd.CommandText = "SELECT name FROM sqlite_master WHERE type='view' AND name='HistoryView'"; + Assert.NotNull(viewCmd.ExecuteScalar()); + } + + [SkippableFact] + public void SQLiteHistory_DeduplicatesCommands() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + + // Need to disable in-memory no-duplicates to ensure both reach SQLite + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = false }); + + // Run the same command multiple times + Test("echo hello", Keys("echo hello")); + Test("echo hello", Keys("echo hello")); + Test("echo hello", Keys("echo hello")); + + // The Commands table should only have one row for "echo hello" + long commandCount = CountSQLiteRows(ctx.TempDbPath, "Commands"); + Assert.Equal(1, commandCount); + + // But ExecutionHistory should track the execution count + var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}") + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT ExecutionCount FROM ExecutionHistory"; + var executionCount = Convert.ToInt64(cmd.ExecuteScalar()); + Assert.True(executionCount >= 3, $"ExecutionCount should be >= 3, was {executionCount}"); + } + + [SkippableFact] + public void SQLiteHistory_StoresLocation() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + + // Run a command - location defaults to "Unknown" in test harness + // because _engineIntrinsics is null + Test("echo hello", Keys("echo hello")); + + // Verify location was stored + long locationCount = CountSQLiteRows(ctx.TempDbPath, "Locations"); + Assert.True(locationCount >= 1, "Should have at least one location"); + } + + // ===================================================================== + // SQLite Migration Tests + // ===================================================================== + + [SkippableFact] + public void SQLiteHistory_MigratesFromTextFile() + { + TestSetup(KeyMode.Cmd); + + var options = PSConsoleReadLine.GetOptions(); + var originalHistorySavePathText = options.HistorySavePathText; + var originalHistorySavePathSQLite = options.HistorySavePathSQLite; + var originalHistorySaveStyle = options.HistorySaveStyle; + var originalHistoryType = options.HistoryType; + + var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db"); + var tempTxtPath = Path.ChangeExtension(tempDbPath, ".txt"); + + try + { + // Create a text history file with known content + File.WriteAllLines(tempTxtPath, new[] + { + "get-process", + "cd /tmp", + "echo hello" + }); + + // Point both paths so migration can find the text file + options.HistorySavePathText = tempTxtPath; + options.HistorySavePathSQLite = tempDbPath; + + var setOptions = new SetPSReadLineOption + { + HistoryType = HistoryType.SQLite, + HistorySaveStyle = HistorySaveStyle.SaveIncrementally, + }; + PSConsoleReadLine.SetOptions(setOptions); + + // The migration should have imported the text history + long commandCount = CountSQLiteRows(tempDbPath, "Commands"); + Assert.Equal(3, commandCount); + + // Verify the specific commands were migrated + var commands = QuerySQLiteCommandLines(tempDbPath); + Assert.Contains("get-process", commands); + Assert.Contains("cd /tmp", commands); + Assert.Contains("echo hello", commands); + } + finally + { + // Restore original settings + options.HistorySavePathSQLite = originalHistorySavePathSQLite; + options.HistorySavePathText = originalHistorySavePathText; + options.HistorySaveStyle = originalHistorySaveStyle; + options.HistoryType = originalHistoryType; + PSConsoleReadLine.ClearHistory(); + + try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { } + try { if (File.Exists(tempTxtPath)) File.Delete(tempTxtPath); } catch { } + } + } + + [SkippableFact] + public void SQLiteHistory_MigrationSkippedWhenNoTextFile() + { + TestSetup(KeyMode.Cmd); + + var options = PSConsoleReadLine.GetOptions(); + var originalHistorySavePathText = options.HistorySavePathText; + var originalHistorySavePathSQLite = options.HistorySavePathSQLite; + var originalHistorySaveStyle = options.HistorySaveStyle; + var originalHistoryType = options.HistoryType; + + // Use a temp path where no .txt file exists + var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db"); + + try + { + options.HistorySavePathSQLite = tempDbPath; + // Point text path to a non-existent file so migration won't import from the real history. + options.HistorySavePathText = Path.ChangeExtension(tempDbPath, ".txt"); + + var setOptions = new SetPSReadLineOption + { + HistoryType = HistoryType.SQLite, + HistorySaveStyle = HistorySaveStyle.SaveIncrementally, + }; + + // Should not throw even without a text file + Exception ex = Record.Exception(() => PSConsoleReadLine.SetOptions(setOptions)); + Assert.Null(ex); + + // Database should exist but with no command rows + Assert.True(File.Exists(tempDbPath)); + long commandCount = CountSQLiteRows(tempDbPath, "Commands"); + Assert.Equal(0, commandCount); + } + finally + { + options.HistorySavePathSQLite = originalHistorySavePathSQLite; + options.HistorySavePathText = originalHistorySavePathText; + options.HistorySaveStyle = originalHistorySaveStyle; + options.HistoryType = originalHistoryType; + PSConsoleReadLine.ClearHistory(); + + try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { } + } + } + + [SkippableFact] + public void SQLiteHistory_MigrationOnlyHappensOnce() + { + TestSetup(KeyMode.Cmd); + + var options = PSConsoleReadLine.GetOptions(); + var originalHistorySavePathText = options.HistorySavePathText; + var originalHistorySavePathSQLite = options.HistorySavePathSQLite; + var originalHistorySaveStyle = options.HistorySaveStyle; + var originalHistoryType = options.HistoryType; + + var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db"); + var tempTxtPath = Path.ChangeExtension(tempDbPath, ".txt"); + + try + { + // Create a text history file + File.WriteAllLines(tempTxtPath, new[] { "cmd1", "cmd2" }); + + // First switch to SQLite - should migrate + options.HistorySavePathText = tempTxtPath; + options.HistorySavePathSQLite = tempDbPath; + var setOptions = new SetPSReadLineOption + { + HistoryType = HistoryType.SQLite, + HistorySaveStyle = HistorySaveStyle.SaveIncrementally, + }; + PSConsoleReadLine.SetOptions(setOptions); + + long countAfterFirstMigration = CountSQLiteRows(tempDbPath, "Commands"); + Assert.Equal(2, countAfterFirstMigration); + + // Add another command to the text file (simulating continued text use) + File.AppendAllText(tempTxtPath, "cmd3\n"); + + // Switch back to Text, then back to SQLite again + // Since the DB already exists, migration should NOT run again + options.HistoryType = originalHistoryType; + PSConsoleReadLine.ClearHistory(); + + PSConsoleReadLine.SetOptions(setOptions); + + long countAfterSecondSwitch = CountSQLiteRows(tempDbPath, "Commands"); + // Should still be 2, not 3 — the migration didn't re-run + Assert.Equal(2, countAfterSecondSwitch); + } + finally + { + options.HistorySavePathSQLite = originalHistorySavePathSQLite; + options.HistorySavePathText = originalHistorySavePathText; + options.HistorySaveStyle = originalHistorySaveStyle; + options.HistoryType = originalHistoryType; + PSConsoleReadLine.ClearHistory(); + + try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { } + try { if (File.Exists(tempTxtPath)) File.Delete(tempTxtPath); } catch { } + } + } + + // ===================================================================== + // SQLite Location-Aware History Recall (new feature) + // ===================================================================== + + [SkippableFact] + public void SQLiteHistory_LocationIsStoredPerCommand() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + + // Run commands — in test harness, Location defaults to "Unknown" + Test("echo hello", Keys("echo hello")); + Test("dir", Keys("dir")); + + // Verify that different commands share the same location ("Unknown") + long locationCount = CountSQLiteRows(ctx.TempDbPath, "Locations"); + Assert.Equal(1, locationCount); + + // Verify both commands appear in ExecutionHistory + long ehCount = CountSQLiteRows(ctx.TempDbPath, "ExecutionHistory"); + Assert.Equal(2, ehCount); + } + + // ===================================================================== + // Type Acceptance Test + // ===================================================================== + + [SkippableFact] + public void SQLiteHistory_SetPSReadLineOptionAcceptsSQLite() + { + TestSetup(KeyMode.Cmd); + + var psrlOptions = PSConsoleReadLine.GetOptions(); + var originalSqlitePath = psrlOptions.HistorySavePathSQLite; + var originalHistorySavePathText = psrlOptions.HistorySavePathText; + var originalHistoryType = psrlOptions.HistoryType; + var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db"); + + try + { + psrlOptions.HistorySavePathSQLite = tempDbPath; + + var optionsType = typeof(SetPSReadLineOption); + var historyTypeProperty = optionsType.GetProperty("HistoryType"); + Assert.NotNull(historyTypeProperty); + + var options = new SetPSReadLineOption(); + historyTypeProperty.SetValue(options, HistoryType.SQLite); + + Exception ex = Record.Exception(() => PSConsoleReadLine.SetOptions(options)); + Assert.Null(ex); + + Assert.Equal(HistoryType.SQLite, historyTypeProperty.GetValue(options)); + + // Restore to default + historyTypeProperty.SetValue(options, HistoryType.Text); + PSConsoleReadLine.SetOptions(options); + } + finally + { + psrlOptions.HistorySavePathSQLite = originalSqlitePath; + psrlOptions.HistorySavePathText = originalHistorySavePathText; + psrlOptions.HistoryType = originalHistoryType; + PSConsoleReadLine.ClearHistory(); + try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { } + } + } + + // ===================================================================== + // Location-Based History Recall Tests + // ===================================================================== + + /// + /// Helper to add history items with specific locations. + /// Uses the internal AddToHistory(string, string) overload via reflection. + /// + private void SetHistoryWithLocations(params (string command, string location)[] items) + { + PSConsoleReadLine.ClearHistory(); + foreach (var (command, location) in items) + { + typeof(PSConsoleReadLine) + .GetMethod("AddToHistory", BindingFlags.Static | BindingFlags.NonPublic, + null, new[] { typeof(string), typeof(string) }, null) + .Invoke(null, new object[] { command, location }); + } + } + + /// + /// Sets the mock current location for test purposes. + /// + private IDisposable SetTestLocation(string location) + { + typeof(PSConsoleReadLine) + .GetField("_testCurrentLocation", BindingFlags.Static | BindingFlags.NonPublic) + .SetValue(null, location); + return new TestLocationGuard(); + } + + private class TestLocationGuard : IDisposable + { + public void Dispose() + { + typeof(PSConsoleReadLine) + .GetField("_testCurrentLocation", BindingFlags.Static | BindingFlags.NonPublic) + .SetValue(null, null); + } + } + + [SkippableFact] + public void SQLiteHistory_LocationRecall_MultipleItemsSameLocation() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory), + new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory)); + + using var location = SetTestLocation(@"C:\Projects\MyRepo"); + + // Add 8 commands at the target location and 3 at another location + SetHistoryWithLocations( + ("git status", @"C:\Projects\MyRepo"), + ("dotnet build", @"C:\Projects\MyRepo"), + ("ls -la", @"C:\Other"), + ("git log --oneline", @"C:\Projects\MyRepo"), + ("dotnet test", @"C:\Projects\MyRepo"), + ("cd ..", @"C:\Other"), + ("git diff", @"C:\Projects\MyRepo"), + ("dotnet publish", @"C:\Projects\MyRepo"), + ("pwd", @"C:\Other"), + ("git push", @"C:\Projects\MyRepo"), + ("git pull --rebase", @"C:\Projects\MyRepo") + ); + + // Alt+Up should walk through ALL 8 commands at C:\Projects\MyRepo, + // most recent first, skipping non-matching locations. + // We verify all 8 are reachable, then navigate back and submit. + Test("dotnet build", Keys( + _.UpArrow, CheckThat(() => AssertLineIs("git pull --rebase")), + _.UpArrow, CheckThat(() => AssertLineIs("git push")), + _.UpArrow, CheckThat(() => AssertLineIs("dotnet publish")), + _.UpArrow, CheckThat(() => AssertLineIs("git diff")), + _.UpArrow, CheckThat(() => AssertLineIs("dotnet test")), + _.UpArrow, CheckThat(() => AssertLineIs("git log --oneline")), + _.UpArrow, CheckThat(() => AssertLineIs("dotnet build")), + _.UpArrow, CheckThat(() => AssertLineIs("git status")), + // Should stay at the oldest item when pressing Up again + _.UpArrow, CheckThat(() => AssertLineIs("git status")), + // Navigate forward to verify Down also works correctly + _.DownArrow, CheckThat(() => AssertLineIs("dotnet build")) + )); + } + + [SkippableFact] + public void SQLiteHistory_LocationRecall_NoLocationFallsBackToNormalRecall() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory), + new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory)); + + // Do NOT set test location - _testCurrentLocation is null, _engineIntrinsics is null + // This should fall back to normal HistoryRecall + + SetHistory("cmd1", "cmd2", "cmd3"); + + // PreviousLocationHistory falls back to normal HistoryRecall when + // no location is available, so all 3 items should be reachable. + Test("cmd1", Keys( + _.UpArrow, CheckThat(() => AssertLineIs("cmd3")), + _.UpArrow, CheckThat(() => AssertLineIs("cmd2")), + _.UpArrow, CheckThat(() => AssertLineIs("cmd1")) + )); + } + + [SkippableFact] + public void SQLiteHistory_LocationRecall_CaseInsensitivePaths() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory), + new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory)); + + using var location = SetTestLocation(@"C:\PROJECTS\myrepo"); + + // Add commands with different path casings - should all match + SetHistoryWithLocations( + ("git status", @"c:\projects\myrepo"), + ("git log", @"C:\Projects\MyRepo"), + ("git diff", @"C:\PROJECTS\MYREPO"), + ("unrelated", @"C:\Other") + ); + + // All three git commands should be accessible via location recall + // regardless of path casing + // All three git commands should be accessible via location recall + // regardless of path casing + Test("git status", Keys( + _.UpArrow, CheckThat(() => AssertLineIs("git diff")), + _.UpArrow, CheckThat(() => AssertLineIs("git log")), + _.UpArrow, CheckThat(() => AssertLineIs("git status")), + // Should stay at the oldest when pressing Up again + _.UpArrow, CheckThat(() => AssertLineIs("git status")) + )); + } + + [SkippableFact] + public void SQLiteHistory_LocationRecall_DifferentLocationsFiltered() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory), + new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory)); + + using var location = SetTestLocation(@"C:\Projects\RepoA"); + + SetHistoryWithLocations( + ("cmd-a1", @"C:\Projects\RepoA"), + ("cmd-b1", @"C:\Projects\RepoB"), + ("cmd-a2", @"C:\Projects\RepoA"), + ("cmd-b2", @"C:\Projects\RepoB"), + ("cmd-a3", @"C:\Projects\RepoA") + ); + + // Only commands from RepoA should appear + // Only commands from RepoA should appear + Test("cmd-a1", Keys( + _.UpArrow, CheckThat(() => AssertLineIs("cmd-a3")), + _.UpArrow, CheckThat(() => AssertLineIs("cmd-a2")), + _.UpArrow, CheckThat(() => AssertLineIs("cmd-a1")), + // Should stay at the oldest when pressing Up again + _.UpArrow, CheckThat(() => AssertLineIs("cmd-a1")) + )); + } + + // ===================================================================== + // Frequency-Weighted History Ordering Tests + // ===================================================================== + + [SkippableFact] + public void SQLiteHistory_FrequentCommandRanksHigher() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + + // Disable in-memory dedup so all writes reach SQLite + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = false }); + + // Run an erroneous command once + Test("git log --one-line", Keys("git log --one-line")); + + // Run the correct command many times to boost its ExecutionCount + for (int i = 0; i < 10; i++) + { + Test("git log --oneline", Keys("git log --oneline")); + } + + // Verify ExecutionCount in the database + var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}") + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + // Check ExecutionCount for the correct command + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +SELECT ExecutionCount FROM HistoryView +WHERE CommandLine = 'git log --oneline'"; + var correctCount = Convert.ToInt64(cmd.ExecuteScalar()); + + using var cmd2 = connection.CreateCommand(); + cmd2.CommandText = @" +SELECT ExecutionCount FROM HistoryView +WHERE CommandLine = 'git log --one-line'"; + var wrongCount = Convert.ToInt64(cmd2.ExecuteScalar()); + + Assert.True(correctCount >= 10, + $"Expected 'git log --oneline' ExecutionCount >= 10, got {correctCount}"); + Assert.True(wrongCount <= 1, + $"Expected 'git log --one-line' ExecutionCount <= 1, got {wrongCount}"); + + // Verify weighted ordering: the frequent command should appear first + // (highest weighted score) so it's found first during backward search + using var orderCmd = connection.CreateCommand(); + orderCmd.CommandText = @" +SELECT CommandLine FROM HistoryView +WHERE CommandLine IN ('git log --oneline', 'git log --one-line') +ORDER BY (LastExecuted + MIN(ExecutionCount, 100) * 1800) DESC"; + using var reader = orderCmd.ExecuteReader(); + reader.Read(); + string firstResult = reader.GetString(0); + Assert.Equal("git log --oneline", firstResult); + } + + [SkippableFact] + public void SQLiteHistory_ExecutionCountStoredOnHistoryItem() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + + // Disable in-memory dedup + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistoryNoDuplicates = false }); + + // Run a command 5 times + for (int i = 0; i < 5; i++) + { + Test("echo repeated", Keys("echo repeated")); + } + + // Verify ExecutionCount is stored in the database + var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}") + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +SELECT ExecutionCount FROM HistoryView +WHERE CommandLine = 'echo repeated'"; + var count = Convert.ToInt64(cmd.ExecuteScalar()); + Assert.True(count >= 5, $"Expected ExecutionCount >= 5, got {count}"); + } + + [SkippableFact] + public void SQLiteHistory_WeightedOrderPreservesChronologyForSingleUse() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + + // When all commands have ExecutionCount=1, ordering should be purely chronological + Test("cmd1", Keys("cmd1")); + Test("cmd2", Keys("cmd2")); + Test("cmd3", Keys("cmd3")); + + // Verify chronological order in DB + var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}") + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +SELECT CommandLine FROM HistoryView +ORDER BY (LastExecuted + MIN(ExecutionCount, 100) * 1800) ASC"; + using var reader = cmd.ExecuteReader(); + + var results = new System.Collections.Generic.List(); + while (reader.Read()) + results.Add(reader.GetString(0)); + + Assert.Equal("cmd1", results[0]); + Assert.Equal("cmd2", results[1]); + Assert.Equal("cmd3", results[2]); + } + + // ===================================================================== + // History Item Removal Tests + // ===================================================================== + + [SkippableFact] + public void SQLiteHistory_RemoveHistoryItem_FromMemory() + { + TestSetup(KeyMode.Cmd); + + SetHistory("good-command", "bad-typo", "another-good"); + + // Remove the typo from memory + bool removed = PSConsoleReadLine.RemoveHistoryItem("bad-typo"); + Assert.True(removed, "RemoveHistoryItem should return true"); + + var items = PSConsoleReadLine.GetHistoryItems(); + Assert.Equal(2, items.Length); + Assert.DoesNotContain(items, i => i.CommandLine == "bad-typo"); + Assert.Contains(items, i => i.CommandLine == "good-command"); + Assert.Contains(items, i => i.CommandLine == "another-good"); + } + + [SkippableFact] + public void SQLiteHistory_RemoveHistoryItem_FromSQLite() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + + // Add commands + Test("git log --oneline", Keys("git log --oneline")); + Test("git log --one-line", Keys("git log --one-line")); + Test("git status", Keys("git status")); + + // Verify all three are in the database + var commandsBefore = QuerySQLiteCommandLines(ctx.TempDbPath); + Assert.Contains("git log --one-line", commandsBefore); + + // Remove the erroneous command + bool removed = PSConsoleReadLine.RemoveHistoryItem("git log --one-line"); + Assert.True(removed, "RemoveHistoryItem should return true for SQLite removal"); + + // Verify it's removed from the database + var commandsAfter = QuerySQLiteCommandLines(ctx.TempDbPath); + Assert.DoesNotContain("git log --one-line", commandsAfter); + Assert.Contains("git log --oneline", commandsAfter); + Assert.Contains("git status", commandsAfter); + + // Verify it's removed from memory too + var historyItems = PSConsoleReadLine.GetHistoryItems(); + Assert.DoesNotContain(historyItems, i => i.CommandLine == "git log --one-line"); + } + + [SkippableFact] + public void SQLiteHistory_RemoveHistoryItem_NonExistent() + { + TestSetup(KeyMode.Cmd); + + SetHistory("cmd1", "cmd2"); + + // Removing a non-existent command should return false + bool removed = PSConsoleReadLine.RemoveHistoryItem("does-not-exist"); + Assert.False(removed); + + // History should be unchanged + var items = PSConsoleReadLine.GetHistoryItems(); + Assert.Equal(2, items.Length); + } + + [SkippableFact] + public void SQLiteHistory_RemoveHistoryItem_NullOrEmpty() + { + TestSetup(KeyMode.Cmd); + + SetHistory("cmd1"); + + Assert.False(PSConsoleReadLine.RemoveHistoryItem(null)); + Assert.False(PSConsoleReadLine.RemoveHistoryItem("")); + + // History should be unchanged + var items = PSConsoleReadLine.GetHistoryItems(); + Assert.Single(items); + } + + [SkippableFact] + public void SQLiteHistory_RemoveHistoryItem_ThenRecallWorks() + { + TestSetup(KeyMode.Cmd); + + SetHistory("cmd1", "bad-command", "cmd3"); + + // Remove the bad command + PSConsoleReadLine.RemoveHistoryItem("bad-command"); + + // History recall should skip the removed item: + // Up → cmd3, Up → cmd1 (no bad-command in between) + Test("cmd1", Keys( + _.UpArrow, CheckThat(() => AssertLineIs("cmd3")), + _.UpArrow, CheckThat(() => AssertLineIs("cmd1")) + )); + } + + [SkippableFact] + public void SQLiteHistory_RemoveHistoryItem_SQLitePersistence() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + + // Add and then remove a command + Test("keep-this", Keys("keep-this")); + Test("remove-this", Keys("remove-this")); + + PSConsoleReadLine.RemoveHistoryItem("remove-this"); + + // Verify the ExecutionHistory entry is also removed (not just Commands) + var connectionString = new SqliteConnectionStringBuilder($"Data Source={ctx.TempDbPath}") + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var ehCmd = connection.CreateCommand(); + ehCmd.CommandText = @" +SELECT COUNT(*) FROM ExecutionHistory eh +JOIN Commands c ON eh.CommandId = c.Id +WHERE c.CommandLine = 'remove-this'"; + long ehCount = (long)ehCmd.ExecuteScalar(); + Assert.Equal(0, ehCount); + + // But the kept command should still exist + using var keepCmd = connection.CreateCommand(); + keepCmd.CommandText = @" +SELECT COUNT(*) FROM ExecutionHistory eh +JOIN Commands c ON eh.CommandId = c.Id +WHERE c.CommandLine = 'keep-this'"; + long keepCount = (long)keepCmd.ExecuteScalar(); + Assert.True(keepCount >= 1); + } + + [SkippableFact] + public void SQLiteHistory_MigrationTimestampsAreChronologicalAndOlderThanNow() + { + TestSetup(KeyMode.Cmd); + + var options = PSConsoleReadLine.GetOptions(); + var originalHistorySavePathText = options.HistorySavePathText; + var originalHistorySavePathSQLite = options.HistorySavePathSQLite; + var originalHistorySaveStyle = options.HistorySaveStyle; + var originalHistoryType = options.HistoryType; + + var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db"); + var tempTxtPath = Path.ChangeExtension(tempDbPath, ".txt"); + + try + { + // Create a text history file with known content (oldest first) + File.WriteAllLines(tempTxtPath, new[] + { + "cmd-oldest", + "cmd-middle", + "cmd-newest" + }); + + var beforeMigration = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + options.HistorySavePathText = tempTxtPath; + options.HistorySavePathSQLite = tempDbPath; + + var setOptions = new SetPSReadLineOption + { + HistoryType = HistoryType.SQLite, + HistorySaveStyle = HistorySaveStyle.SaveIncrementally, + }; + PSConsoleReadLine.SetOptions(setOptions); + + // Query timestamps from the database in insertion order + var connectionString = new SqliteConnectionStringBuilder($"Data Source={tempDbPath}") + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT CommandLine, LastExecuted FROM HistoryView ORDER BY LastExecuted ASC"; + using var reader = cmd.ExecuteReader(); + + var entries = new System.Collections.Generic.List<(string command, long lastExecuted)>(); + while (reader.Read()) + { + entries.Add((reader.GetString(0), reader.GetInt64(1))); + } + + Assert.Equal(3, entries.Count); + + // 1. Chronological order preserved: oldest text line has smallest timestamp + Assert.Equal("cmd-oldest", entries[0].command); + Assert.Equal("cmd-middle", entries[1].command); + Assert.Equal("cmd-newest", entries[2].command); + + // 2. Timestamps are strictly increasing + Assert.True(entries[0].lastExecuted < entries[1].lastExecuted, + "oldest should have smaller timestamp than middle"); + Assert.True(entries[1].lastExecuted < entries[2].lastExecuted, + "middle should have smaller timestamp than newest"); + + // 3. All migrated timestamps are older than "now" + foreach (var entry in entries) + { + Assert.True(entry.lastExecuted < beforeMigration, + $"Migrated entry '{entry.command}' has timestamp {entry.lastExecuted} which is not older than migration time {beforeMigration}"); + } + } + finally + { + options.HistorySavePathSQLite = originalHistorySavePathSQLite; + options.HistorySavePathText = originalHistorySavePathText; + options.HistorySaveStyle = originalHistorySaveStyle; + options.HistoryType = originalHistoryType; + PSConsoleReadLine.ClearHistory(); + + try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { } + try { if (File.Exists(tempTxtPath)) File.Delete(tempTxtPath); } catch { } + } + } + + [SkippableFact] + public void SQLiteHistory_MigratedTextHistoryOlderThanNewSQLiteEntries() + { + TestSetup(KeyMode.Cmd); + + var options = PSConsoleReadLine.GetOptions(); + var originalHistorySavePathText = options.HistorySavePathText; + var originalHistorySavePathSQLite = options.HistorySavePathSQLite; + var originalHistorySaveStyle = options.HistorySaveStyle; + var originalHistoryType = options.HistoryType; + + var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db"); + var tempTxtPath = Path.ChangeExtension(tempDbPath, ".txt"); + + try + { + // Create text history (these are "old" commands) + File.WriteAllLines(tempTxtPath, new[] + { + "old-cmd-1", + "old-cmd-2" + }); + + options.HistorySavePathText = tempTxtPath; + options.HistorySavePathSQLite = tempDbPath; + + var setOptions = new SetPSReadLineOption + { + HistoryType = HistoryType.SQLite, + HistorySaveStyle = HistorySaveStyle.SaveIncrementally, + }; + PSConsoleReadLine.SetOptions(setOptions); + + // Now add a new command via the normal path (simulates running a command after migration) + Test("new-cmd-after-migration", Keys("new-cmd-after-migration")); + + // Query all entries ordered by LastExecuted + var connectionString = new SqliteConnectionStringBuilder($"Data Source={tempDbPath}") + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT CommandLine, LastExecuted FROM HistoryView ORDER BY LastExecuted ASC"; + using var reader = cmd.ExecuteReader(); + + var entries = new System.Collections.Generic.List<(string command, long lastExecuted)>(); + while (reader.Read()) + { + entries.Add((reader.GetString(0), reader.GetInt64(1))); + } + + Assert.Equal(3, entries.Count); + + // Migrated items should be first (oldest), new item should be last (newest) + Assert.Equal("old-cmd-1", entries[0].command); + Assert.Equal("old-cmd-2", entries[1].command); + Assert.Equal("new-cmd-after-migration", entries[2].command); + + // The new entry's timestamp must be strictly greater than all migrated ones + Assert.True(entries[2].lastExecuted > entries[1].lastExecuted, + "New SQLite entry must be newer than migrated text history"); + Assert.True(entries[2].lastExecuted > entries[0].lastExecuted, + "New SQLite entry must be newer than migrated text history"); + } + finally + { + options.HistorySavePathSQLite = originalHistorySavePathSQLite; + options.HistorySavePathText = originalHistorySavePathText; + options.HistorySaveStyle = originalHistorySaveStyle; + options.HistoryType = originalHistoryType; + PSConsoleReadLine.ClearHistory(); + + try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { } + try { if (File.Exists(tempTxtPath)) File.Delete(tempTxtPath); } catch { } + } + } + + [SkippableFact] + public void SQLiteHistory_UpArrowShowsNewestFirstAfterMigration() + { + TestSetup(KeyMode.Cmd); + + var options = PSConsoleReadLine.GetOptions(); + var originalHistorySavePathText = options.HistorySavePathText; + var originalHistorySavePathSQLite = options.HistorySavePathSQLite; + var originalHistorySaveStyle = options.HistorySaveStyle; + var originalHistoryType = options.HistoryType; + + var tempDbPath = Path.Combine(Path.GetTempPath(), $"PSReadLineTest_{Guid.NewGuid():N}.db"); + var tempTxtPath = Path.ChangeExtension(tempDbPath, ".txt"); + + try + { + // Create text history (oldest first in the file) + File.WriteAllLines(tempTxtPath, new[] + { + "old-text-cmd-1", + "old-text-cmd-2", + "old-text-cmd-3" + }); + + options.HistorySavePathText = tempTxtPath; + options.HistorySavePathSQLite = tempDbPath; + + var setOptions = new SetPSReadLineOption + { + HistoryType = HistoryType.SQLite, + HistorySaveStyle = HistorySaveStyle.SaveIncrementally, + }; + PSConsoleReadLine.SetOptions(setOptions); + + // Add a new entry after migration + Test("new-sqlite-cmd", Keys("new-sqlite-cmd")); + + // Up arrow should show entries newest-first: + // new-sqlite-cmd → old-text-cmd-3 → old-text-cmd-2 → old-text-cmd-1 + Test("old-text-cmd-1", Keys( + _.UpArrow, CheckThat(() => AssertLineIs("new-sqlite-cmd")), + _.UpArrow, CheckThat(() => AssertLineIs("old-text-cmd-3")), + _.UpArrow, CheckThat(() => AssertLineIs("old-text-cmd-2")), + _.UpArrow, CheckThat(() => AssertLineIs("old-text-cmd-1")) + )); + } + finally + { + options.HistorySavePathSQLite = originalHistorySavePathSQLite; + options.HistorySavePathText = originalHistorySavePathText; + options.HistorySaveStyle = originalHistorySaveStyle; + options.HistoryType = originalHistoryType; + PSConsoleReadLine.ClearHistory(); + + try { if (File.Exists(tempDbPath)) File.Delete(tempDbPath); } catch { } + try { if (File.Exists(tempTxtPath)) File.Delete(tempTxtPath); } catch { } + } + } + + // ===================================================================== + // Location-Scoped History Removal Tests (RemoveHistoryItemAtLocation + // and the Alt+Delete -> RemoveFromHistoryAtCurrentLocation handler) + // ===================================================================== + + /// + /// Helper to count ExecutionHistory rows for a given (CommandLine, Location) pair. + /// + private long CountExecutionHistoryAt(string dbPath, string commandLine, string location) + { + var connectionString = new SqliteConnectionStringBuilder($"Data Source={dbPath}") + { + Mode = SqliteOpenMode.ReadOnly + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +SELECT COUNT(*) FROM ExecutionHistory eh +JOIN Commands c ON eh.CommandId = c.Id +JOIN Locations l ON eh.LocationId = l.Id +WHERE c.CommandLine = @CommandLine AND l.Path = @Location"; + cmd.Parameters.AddWithValue("@CommandLine", commandLine); + cmd.Parameters.AddWithValue("@Location", location); + return (long)cmd.ExecuteScalar(); + } + + [SkippableFact] + public void SQLiteHistory_RemoveHistoryItemAtLocation_RemovesOnlyMatchingLocation() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + using var loc = SetTestLocation(@"C:\Projects\A"); + + // Same command run at two locations. An interleaved different command + // is required so HistoryNoDuplicates (on by default) doesn't skip the + // second "git status" entry as a consecutive dup. + SetHistoryWithLocations( + ("git status", @"C:\Projects\A"), + ("unrelated", @"C:\Projects\A"), + ("git status", @"C:\Projects\B")); + + // Sanity: both ExecutionHistory rows exist. + Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\A")); + Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\B")); + + bool removed = PSConsoleReadLine.RemoveHistoryItemAtLocation("git status", @"C:\Projects\A"); + Assert.True(removed); + + // Only the A-location row should be gone; B-location row stays. + Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\A")); + Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\B")); + + // Commands row must still exist because location B still references it. + var commands = QuerySQLiteCommandLines(ctx.TempDbPath); + Assert.Contains("git status", commands); + } + + [SkippableFact] + public void SQLiteHistory_RemoveHistoryItemAtLocation_DropsOrphanedCommandRow() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + using var loc = SetTestLocation(@"C:\Only"); + + // Command run at exactly one location. + SetHistoryWithLocations(("only-here", @"C:\Only")); + Assert.Contains("only-here", QuerySQLiteCommandLines(ctx.TempDbPath)); + + bool removed = PSConsoleReadLine.RemoveHistoryItemAtLocation("only-here", @"C:\Only"); + Assert.True(removed); + + // Both ExecutionHistory and Commands rows should be gone (orphan cleanup). + Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "only-here", @"C:\Only")); + Assert.DoesNotContain("only-here", QuerySQLiteCommandLines(ctx.TempDbPath)); + } + + [SkippableFact] + public void SQLiteHistory_RemoveHistoryItemAtLocation_InMemoryRespectsLocation() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + using var loc = SetTestLocation(@"C:\Projects\A"); + + // Two in-memory items with the same command line but different locations. + // Interleave a different command so HistoryNoDuplicates doesn't skip + // the second "git pull" as a consecutive dup of the first. + SetHistoryWithLocations( + ("git pull", @"C:\Projects\A"), + ("sep", @"C:\Projects\A"), + ("git pull", @"C:\Projects\B")); + + PSConsoleReadLine.RemoveHistoryItemAtLocation("git pull", @"C:\Projects\A"); + + // The B-location "git pull" should still be in memory; the + // separator "sep" stays too. The A-location "git pull" is gone. + var items = PSConsoleReadLine.GetHistoryItems(); + Assert.Equal(2, items.Length); + var pullItem = Assert.Single(items, i => i.CommandLine == "git pull"); + Assert.Equal(@"C:\Projects\B", pullItem.Location); + } + + [SkippableFact] + public void SQLiteHistory_RemoveHistoryItemAtLocation_NoMatchReturnsFalse() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + using var loc = SetTestLocation(@"C:\Projects\A"); + + SetHistoryWithLocations(("git status", @"C:\Projects\A")); + + // Wrong location — should be a no-op. + bool removed = PSConsoleReadLine.RemoveHistoryItemAtLocation("git status", @"C:\Nowhere"); + Assert.False(removed); + + // Original row still intact. + Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\A")); + Assert.Single(PSConsoleReadLine.GetHistoryItems()); + } + + [SkippableFact] + public void SQLiteHistory_RemoveHistoryItemAtLocation_NullOrEmpty() + { + TestSetup(KeyMode.Cmd); + + SetHistory("cmd1"); + + Assert.False(PSConsoleReadLine.RemoveHistoryItemAtLocation(null, "loc")); + Assert.False(PSConsoleReadLine.RemoveHistoryItemAtLocation("", "loc")); + Assert.False(PSConsoleReadLine.RemoveHistoryItemAtLocation("cmd1", null)); + Assert.False(PSConsoleReadLine.RemoveHistoryItemAtLocation("cmd1", "")); + + Assert.Single(PSConsoleReadLine.GetHistoryItems()); + } + + [SkippableFact] + public void SQLiteHistory_AltDelete_RemovesAtCurrentLocationOnly() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + using var loc = SetTestLocation(@"C:\Projects\A"); + + // Same command at current location and elsewhere; an interleaved entry + // is needed so HistoryNoDuplicates doesn't drop the second "git status". + // Up arrow surfaces the most-recently-added item first ("git status" at A). + SetHistoryWithLocations( + ("git status", @"C:\Projects\B"), + ("sep", @"C:\Projects\B"), + ("git status", @"C:\Projects\A")); + + // Up arrow surfaces "git status" (the A-location entry, most recent). + // Alt+Delete should remove only the A entry. After deletion, the + // separator "sep" is now the most recent in-memory item; the B-location + // "git status" is still further back. RemoveFromHistoryAtCurrentLocation + // advances to savedIndex-1 which is now the "sep" entry. + Test("sep", Keys( + _.UpArrow, + CheckThat(() => AssertLineIs("git status")), + _.Alt_Delete, + CheckThat(() => AssertLineIs("sep")) + )); + + // DB: A-row deleted, B-row preserved, Commands row still present. + Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\A")); + Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\B")); + Assert.Contains("git status", QuerySQLiteCommandLines(ctx.TempDbPath)); + } + + [SkippableFact] + public void SQLiteHistory_AltDelete_DingsWhenItemNotAtCurrentLocation() + { + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + using var loc = SetTestLocation(@"C:\Projects\Current"); + + // Item exists in history but was run at a different location. + SetHistoryWithLocations(("git status", @"C:\Projects\Other")); + + // Up arrow shows it; Alt+Delete should NOT delete (not run here), + // and the line should remain unchanged. + Test("git status", Keys( + _.UpArrow, + CheckThat(() => AssertLineIs("git status")), + _.Alt_Delete, + CheckThat(() => AssertLineIs("git status")) + )); + + // DB row at the other location must still exist. + Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\Other")); + Assert.Contains("git status", QuerySQLiteCommandLines(ctx.TempDbPath)); + } + + [SkippableFact] + public void SQLiteHistory_AltDelete_FallsBackToGlobalInTextMode() + { + // Text mode: no SQLite, no current location concept. + // Alt+Delete must still do something useful — falls back to global removal. + TestSetup(KeyMode.Cmd); + + SetHistory("cmd1", "cmd2", "cmd3"); + + Test("cmd1", Keys( + _.UpArrow, // recall cmd3 + CheckThat(() => AssertLineIs("cmd3")), + _.Alt_Delete, // global fallback removes cmd3 + CheckThat(() => AssertLineIs("cmd2")), + _.Alt_Delete, // and cmd2 + CheckThat(() => AssertLineIs("cmd1")) + )); + } + + [SkippableFact] + public void SQLiteHistory_GlobalRemove_StillWipesAllLocations() + { + // The global RemoveHistoryItem path (used by Ctrl+Shift+Delete) must + // continue to wipe every (Command, Location) pair — regression check. + TestSetup(KeyMode.Cmd); + using var ctx = SetupSQLiteHistory(); + using var loc = SetTestLocation(@"C:\Projects\A"); + + SetHistoryWithLocations( + ("git status", @"C:\Projects\A"), + ("sep1", @"C:\Projects\A"), + ("git status", @"C:\Projects\B"), + ("sep2", @"C:\Projects\B"), + ("git status", @"C:\Projects\C")); + + bool removed = PSConsoleReadLine.RemoveHistoryItem("git status"); + Assert.True(removed); + + Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\A")); + Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\B")); + Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "git status", @"C:\Projects\C")); + Assert.DoesNotContain("git status", QuerySQLiteCommandLines(ctx.TempDbPath)); + } + + // ===================================================================== + // Sticky Location Mode Tests + // + // When the user enters location-filtered navigation via Alt+Up + // (PreviousLocationHistory), the sorted list and current position should + // remain "sticky" so that plain Up / Down (PreviousHistory / NextHistory) + // continue to navigate the same location-filtered set, instead of dropping + // the user back into raw chronological history at an unrelated index. + // + // The tests below bind: + // UpArrow -> PreviousLocationHistory (simulates Alt+Up "enter mode") + // DownArrow -> NextLocationHistory (simulates Alt+Down) + // Ctrl+P -> PreviousHistory (simulates plain Up after Alt release) + // Ctrl+N -> NextHistory (simulates plain Down after Alt release) + // Ctrl+G -> CancelLine (used to break the sticky chain) + // ===================================================================== + + [SkippableFact] + public void SQLiteHistory_LocationRecall_StickyMode_PlainUpContinuesLocationList() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory), + new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory), + new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory), + new KeyHandler("Ctrl+n", PSConsoleReadLine.NextHistory)); + + using var loc = SetTestLocation(@"C:\Projects\Sticky"); + + SetHistoryWithLocations( + ("loc-cmd-1", @"C:\Projects\Sticky"), + ("other-1", @"C:\Other"), + ("loc-cmd-2", @"C:\Projects\Sticky"), + ("other-2", @"C:\Other"), + ("loc-cmd-3", @"C:\Projects\Sticky")); + + // UpArrow (location mode) lands on newest local entry, then plain + // Ctrl+P (regular Up) should KEEP filtering by location instead of + // jumping to "other-2" or some unrelated chronological neighbor. + Test("loc-cmd-3", Keys( + _.UpArrow, CheckThat(() => AssertLineIs("loc-cmd-3")), + _.Ctrl_p, CheckThat(() => AssertLineIs("loc-cmd-2")), + _.Ctrl_p, CheckThat(() => AssertLineIs("loc-cmd-1")), + // Plain Ctrl+N (Down) also stays in sticky location mode. + _.Ctrl_n, CheckThat(() => AssertLineIs("loc-cmd-2")), + _.Ctrl_n, CheckThat(() => AssertLineIs("loc-cmd-3")) + )); + } + + [SkippableFact] + public void SQLiteHistory_LocationRecall_StickyMode_ClearedOnNonHistoryAction() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory), + new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory), + new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory), + new KeyHandler("Ctrl+n", PSConsoleReadLine.NextHistory)); + + using var loc = SetTestLocation(@"C:\Projects\Sticky"); + + SetHistoryWithLocations( + ("loc-cmd-1", @"C:\Projects\Sticky"), + ("other-1", @"C:\Other"), + ("loc-cmd-2", @"C:\Projects\Sticky"), + ("other-2", @"C:\Other")); + + // Enter location mode, then perform a non-history action (typing a + // character). After that, plain Ctrl+P should walk regular + // chronological history (which includes "other-*" entries), + // NOT the location-filtered list. + // After 'x' is typed the main loop's anyHistoryCommandCount reset branch + // fires (no history command was issued for that key), which exits sticky + // mode AND resets _currentHistoryIndex back to _history.Count. The next + // Ctrl+P therefore replaces the buffer with the most-recent chronological + // entry ("other-2") — losing the typed 'x'. That's the same behavior the + // text-mode HistoryRecall has always had after editing a recalled line. + Test("other-2", Keys( + _.UpArrow, CheckThat(() => AssertLineIs("loc-cmd-2")), + 'x', CheckThat(() => AssertLineIs("loc-cmd-2x")), + _.Ctrl_p, CheckThat(() => AssertLineIs("other-2")) + )); + } + + [SkippableFact] + public void SQLiteHistory_LocationRecall_StickyMode_AltUpStillAdvances() + { + // After plain Up has been used inside sticky mode, pressing Alt+Up + // (PreviousLocationHistory) again should keep advancing through the + // same sorted location list — not rebuild it from scratch. + TestSetup(KeyMode.Cmd, + new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory), + new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory), + new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory), + new KeyHandler("Ctrl+n", PSConsoleReadLine.NextHistory)); + + using var loc = SetTestLocation(@"C:\Projects\Sticky"); + + SetHistoryWithLocations( + ("loc-a", @"C:\Projects\Sticky"), + ("nope", @"C:\Other"), + ("loc-b", @"C:\Projects\Sticky"), + ("loc-c", @"C:\Projects\Sticky")); + + Test("loc-b", Keys( + _.UpArrow, CheckThat(() => AssertLineIs("loc-c")), + _.Ctrl_p, CheckThat(() => AssertLineIs("loc-b")), + _.UpArrow, CheckThat(() => AssertLineIs("loc-a")), + _.Ctrl_n, CheckThat(() => AssertLineIs("loc-b")) + )); + } + + [SkippableFact] + public void SQLiteHistory_LocationRecall_StickyMode_NotEnteredWhenJustPlainUp() + { + // Plain Up alone (without ever pressing Alt+Up) must not behave like + // location mode — it walks raw chronological history including items + // from other directories. + TestSetup(KeyMode.Cmd, + new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory), + new KeyHandler("Ctrl+n", PSConsoleReadLine.NextHistory)); + + using var loc = SetTestLocation(@"C:\Projects\Sticky"); + + SetHistoryWithLocations( + ("loc-1", @"C:\Projects\Sticky"), + ("other-1", @"C:\Other"), + ("loc-2", @"C:\Projects\Sticky"), + ("other-2", @"C:\Other")); + + Test("loc-1", Keys( + _.Ctrl_p, CheckThat(() => AssertLineIs("other-2")), + _.Ctrl_p, CheckThat(() => AssertLineIs("loc-2")), + _.Ctrl_p, CheckThat(() => AssertLineIs("other-1")), + _.Ctrl_p, CheckThat(() => AssertLineIs("loc-1")) + )); + } + + // ===================================================================== + // History Navigation Position Indicator Tests + // + // The "[BOOK pos/total]" indicator is rendered into _statusLinePrompt + // while the user is navigating history (chronological or location-mode). + // It is cleared once the user does any non-history action. + // ===================================================================== + + private static string GetStatusLinePromptForTest() + { + var fld = typeof(PSConsoleReadLine).GetField( + "_statusLinePrompt", + BindingFlags.Instance | BindingFlags.NonPublic); + var singletonFld = typeof(PSConsoleReadLine).GetField( + "_singleton", + BindingFlags.Static | BindingFlags.NonPublic); + var singleton = singletonFld.GetValue(null); + return (string)fld.GetValue(singleton); + } + + private static string ExpectedNavStatus(int pos, int total, bool locationMode) + { + // Mirror ShowHistoryNavStatus: brackets default-colored, inner uses ListPredictionColor. + var color = (string)typeof(PSConsoleReadLineOptions) + .GetField("_listPredictionColor", BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(PSConsoleReadLine.GetOptions()); + var inner = locationMode + ? $"\uD83D\uDCC2 {pos}/{total}" + : $"\u23F1 {pos}/{total}"; + return $"[{color}{inner}\x1b[0m]"; + } + + [SkippableFact] + public void SQLiteHistory_HistoryNavStatus_ShownDuringChronologicalRecall() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory), + new KeyHandler("Ctrl+n", PSConsoleReadLine.NextHistory)); + + SetHistory("a", "b", "c"); + + Test("a", Keys( + _.Ctrl_p, CheckThat(() => AssertLineIs("c")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 3, false), GetStatusLinePromptForTest())), + _.Ctrl_p, CheckThat(() => AssertLineIs("b")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(2, 3, false), GetStatusLinePromptForTest())), + _.Ctrl_p, CheckThat(() => AssertLineIs("a")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(3, 3, false), GetStatusLinePromptForTest())) + )); + } + + [SkippableFact] + public void SQLiteHistory_HistoryNavStatus_ShownDuringLocationRecallWithLocLabel() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory), + new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory)); + + using var loc = SetTestLocation(@"C:\Projects\Status"); + + SetHistoryWithLocations( + ("local-1", @"C:\Projects\Status"), + ("other", @"C:\Other"), + ("local-2", @"C:\Projects\Status"), + ("local-3", @"C:\Projects\Status")); + + Test("local-1", Keys( + _.UpArrow, + CheckThat(() => AssertLineIs("local-3")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 3, true), GetStatusLinePromptForTest())), + _.UpArrow, + CheckThat(() => AssertLineIs("local-2")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(2, 3, true), GetStatusLinePromptForTest())), + _.UpArrow, + CheckThat(() => AssertLineIs("local-1")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(3, 3, true), GetStatusLinePromptForTest())) + )); + } + + [SkippableFact] + public void SQLiteHistory_HistoryNavStatus_ClearedAfterEditing() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory)); + + SetHistory("alpha", "bravo"); + + Test("bravox", Keys( + _.Ctrl_p, + CheckThat(() => AssertLineIs("bravo")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 2, false), GetStatusLinePromptForTest())), + 'x', + // Typing a character is a non-history action: the indicator must clear. + CheckThat(() => Assert.Null(GetStatusLinePromptForTest())) + )); + } + + /// + /// Marks the in-memory _history items at the given indices as FromOtherSession + /// so they get skipped by HistoryRecall (mirroring cross-session SQLite items). + /// + private static void MarkHistoryItemsFromOtherSession(params int[] indices) + { + var singletonFld = typeof(PSConsoleReadLine).GetField( + "_singleton", BindingFlags.Static | BindingFlags.NonPublic); + var singleton = singletonFld.GetValue(null); + var historyFld = typeof(PSConsoleReadLine).GetField( + "_history", BindingFlags.Instance | BindingFlags.NonPublic); + var history = historyFld.GetValue(singleton); + // HistoryQueue exposes an indexer; use reflection to call it. + var indexer = history.GetType().GetProperty("Item"); + var historyItemType = typeof(PSConsoleReadLine).GetNestedType( + "HistoryItem", BindingFlags.Public | BindingFlags.NonPublic); + var fromOtherProp = historyItemType.GetProperty( + "FromOtherSession", BindingFlags.Public | BindingFlags.Instance); + foreach (var i in indices) + { + var item = indexer.GetValue(history, new object[] { i }); + fromOtherProp.SetValue(item, true); + } + } + + [SkippableFact] + public void SQLiteHistory_HistoryNavStatus_CountsOnlyNavigableItems() { + // Regression test: previously the indicator used the raw _history slot, + // so cross-session items between two in-session items caused the + // displayed position to "jump" by hundreds even though Up only moved + // by one navigable item. + TestSetup(KeyMode.Cmd, + new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory)); + + // History layout (oldest -> newest): + // [0] mine-old (this session) + // [1] other-1 (other session, skipped) + // [2] other-2 (other session, skipped) + // [3] other-3 (other session, skipped) + // [4] mine-new (this session) + // Navigable total = 2. Up #1 lands on "mine-new" => 1/2. + // Up #2 must skip the three other-session items and land on + // "mine-old" => 2/2 (NOT 5/5 or 4/5). + SetHistory("mine-old", "other-1", "other-2", "other-3", "mine-new"); + MarkHistoryItemsFromOtherSession(1, 2, 3); + + Test("", Keys( + _.Ctrl_p, + CheckThat(() => AssertLineIs("mine-new")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 2, false), GetStatusLinePromptForTest())), + _.Ctrl_p, + CheckThat(() => AssertLineIs("mine-old")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(2, 2, false), GetStatusLinePromptForTest())), + _.Escape + )); + } + + [SkippableFact] + public void SQLiteHistory_AltDelete_InLocationMode_RefreshesIndicatorAndAdvances() + { + // Regression test: previously Alt+Delete while navigating location-filtered + // history left _locationSortedIndices stale (built against pre-deletion + // _history) and never updated _locationSortedPosition. Result was the + // indicator stuck at e.g. 1/3 instead of 1/2, and the next Alt+Up landed + // on the wrong item because the cached indices were shifted. + TestSetup(KeyMode.Cmd, + new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory), + new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory)); + using var ctx = SetupSQLiteHistory(); + using var loc = SetTestLocation(@"C:\Projects\AltDel"); + + // Three unique commands at the current location, equal frequency. + // Sort = recency DESC: pos 0 = "gamma", pos 1 = "beta", pos 2 = "alpha". + SetHistoryWithLocations( + ("alpha", @"C:\Projects\AltDel"), + ("beta", @"C:\Projects\AltDel"), + ("gamma", @"C:\Projects\AltDel")); + + Test("beta", Keys( + _.UpArrow, // pos 0 -> "gamma" (1/3) + CheckThat(() => AssertLineIs("gamma")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 3, true), GetStatusLinePromptForTest())), + _.Alt_Delete, // remove "gamma" + // Indicator must refresh: 1/2, line must advance to next location item. + CheckThat(() => AssertLineIs("beta")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 2, true), GetStatusLinePromptForTest())), + // Subsequent Alt+Up must use the rebuilt sorted list, not the stale one. + _.UpArrow, // advance to next older + CheckThat(() => AssertLineIs("alpha")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(2, 2, true), GetStatusLinePromptForTest())), + _.DownArrow, // back to "beta" + CheckThat(() => AssertLineIs("beta")), + CheckThat(() => Assert.Equal(ExpectedNavStatus(1, 2, true), GetStatusLinePromptForTest())) + )); + + // DB sanity: "gamma" gone at this location, others intact. + Assert.Equal(0, CountExecutionHistoryAt(ctx.TempDbPath, "gamma", @"C:\Projects\AltDel")); + Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "beta", @"C:\Projects\AltDel")); + Assert.Equal(1, CountExecutionHistoryAt(ctx.TempDbPath, "alpha", @"C:\Projects\AltDel")); + } + + // ===================================================================== + // AccessibleHistoryDisplay option + // + // The SQLite-history-awareness UX uses emojis (⟳, ⏱, 📂) in two places: + // 1) The F2 list-view stats tooltip rendered by RenderHistoryStatsTooltip + // 2) The in-prompt navigation indicator rendered by ShowHistoryNavStatus + // + // Screen readers verbalize emoji as Unicode names ("clockwise gapped circle + // arrow", "card index dividers"). The AccessibleHistoryDisplay option, when + // enabled, swaps emoji for plain-text labels: + // * Tooltip: "⟳ Runs N | ⏱ Last 2m ago | 📂 Dir " + // -> "Runs N | Last 2m ago | Dir " + // * Indicator: "[⏱ 3/15]" / "[📂 2/5]" + // -> "[History 3/15]" / "[Location 2/5]" + // + // Default value is seeded once at construction from ScreenReaderModeEnabled + // and is independent thereafter. + // ===================================================================== + + // Emoji code points used by the SQLite history awareness UX. + private const string TooltipRunsEmoji = "\u27f3"; // ⟳ + private const string TooltipLastEmoji = "\u23f1"; // ⏱ + private const string TooltipDirEmoji = "\U0001F4C2"; // 📂 + private const string NavStatusChronoEmoji = "\u23F1"; // ⏱ (BMP, used by ShowHistoryNavStatus) + private const string NavStatusLocEmoji = "\uD83D\uDCC2"; // 📂 (surrogate pair) + + /// + /// Renders the F2 stats tooltip into a fresh buffer and returns the resulting + /// line as a string. RenderHistoryStatsTooltip is a private method on the + /// nested PredictionListView class — invoke it via reflection. + /// + private static string CaptureRenderedHistoryStatsTooltip(string commandLine, int executionCount, DateTime startTime, string location) + { + // Build a HistoryItem with the requested fields. Setters are internal so + // we go through reflection to keep this resilient. + var historyItemType = typeof(PSConsoleReadLine).GetNestedType( + "HistoryItem", BindingFlags.Public | BindingFlags.NonPublic); + var historyItem = Activator.CreateInstance(historyItemType); + historyItemType.GetProperty("CommandLine").SetValue(historyItem, commandLine); + historyItemType.GetProperty("ExecutionCount").SetValue(historyItem, executionCount); + historyItemType.GetProperty("StartTime").SetValue(historyItem, startTime); + historyItemType.GetProperty("Location").SetValue(historyItem, location); + + // Get the singleton and instantiate PredictionListView via its internal ctor. + var singletonFld = typeof(PSConsoleReadLine).GetField( + "_singleton", BindingFlags.Static | BindingFlags.NonPublic); + var singleton = singletonFld.GetValue(null); + var listViewType = typeof(PSConsoleReadLine).GetNestedType( + "PredictionListView", BindingFlags.NonPublic); + var listView = Activator.CreateInstance( + listViewType, + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.CreateInstance, + binder: null, + args: new[] { singleton }, + culture: null); + + var renderMethod = listViewType.GetMethod( + "RenderHistoryStatsTooltip", + BindingFlags.Instance | BindingFlags.NonPublic); + + // NextBufferLine pre-increments `current`, then creates a new StringBuilder + // when current == consoleBufferLines.Count. Start with an empty list and + // current = -1 so the first call advances to index 0 and writes there. + var buffer = new System.Collections.Generic.List(); + object[] args = new object[] { historyItem, buffer, -1 }; + renderMethod.Invoke(listView, args); + + return buffer.Count > 0 ? buffer[0].ToString() : string.Empty; + } + + /// Mirror ExpectedNavStatus for the accessible-display variant. + private static string ExpectedAccessibleNavStatus(int pos, int total, bool locationMode) + { + var color = (string)typeof(PSConsoleReadLineOptions) + .GetField("_listPredictionColor", BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(PSConsoleReadLine.GetOptions()); + var inner = locationMode + ? $"Location {pos}/{total}" + : $"History {pos}/{total}"; + return $"[{color}{inner}\x1b[0m]"; + } + + [SkippableFact] + public void AccessibleHistoryDisplay_DefaultMatchesScreenReaderModeAtConstruction() + { + // Construct a fresh options object directly — the constructor seeds + // AccessibleHistoryDisplay from the screen-reader detection result. + // Verify the seeded value matches whatever ScreenReaderModeEnabled was set to. + var opts = new PSConsoleReadLineOptions("AccessibleDefaultHost", usingLegacyConsole: false); + Assert.Equal(opts.ScreenReaderModeEnabled, opts.AccessibleHistoryDisplay); + } + + [SkippableFact] + public void AccessibleHistoryDisplay_RoundTripsViaSetPSReadLineOption() + { + TestSetup(KeyMode.Cmd); + var originalAccessibleValue = PSConsoleReadLine.GetOptions().AccessibleHistoryDisplay; + try + { + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { AccessibleHistoryDisplay = true }); + Assert.True(PSConsoleReadLine.GetOptions().AccessibleHistoryDisplay); + + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { AccessibleHistoryDisplay = false }); + Assert.False(PSConsoleReadLine.GetOptions().AccessibleHistoryDisplay); + } + finally + { + PSConsoleReadLine.GetOptions().AccessibleHistoryDisplay = originalAccessibleValue; + } + } + + [SkippableFact] + public void AccessibleHistoryDisplay_IndependentFromScreenReaderModeAfterInit() + { + // Toggling EnableScreenReaderMode after construction must NOT auto-flip + // AccessibleHistoryDisplay. Per design, the flag is only seeded once at + // construction and is independent thereafter. + TestSetup(KeyMode.Cmd); + var opts = PSConsoleReadLine.GetOptions(); + var originalAccessible = opts.AccessibleHistoryDisplay; + var originalScreenReader = opts.ScreenReaderModeEnabled; + + try + { + // Force AccessibleHistoryDisplay to a known-false state. + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { AccessibleHistoryDisplay = false }); + Assert.False(opts.AccessibleHistoryDisplay); + + // Flip the screen-reader option — accessible flag must NOT follow. + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { EnableScreenReaderMode = true }); + Assert.True(opts.ScreenReaderModeEnabled); + Assert.False(opts.AccessibleHistoryDisplay); + + // And the reverse — flipping screen reader off must not auto-disable. + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { AccessibleHistoryDisplay = true }); + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { EnableScreenReaderMode = false }); + Assert.False(opts.ScreenReaderModeEnabled); + Assert.True(opts.AccessibleHistoryDisplay); + } + finally + { + opts.AccessibleHistoryDisplay = originalAccessible; + opts.ScreenReaderModeEnabled = originalScreenReader; + } + } + + [SkippableFact] + public void AccessibleHistoryDisplay_TooltipOmitsEmojiWhenEnabled() + { + TestSetup(KeyMode.Cmd); + var opts = PSConsoleReadLine.GetOptions(); + var original = opts.AccessibleHistoryDisplay; + try + { + opts.AccessibleHistoryDisplay = true; + var rendered = CaptureRenderedHistoryStatsTooltip( + commandLine: "git status", + executionCount: 47, + startTime: DateTime.UtcNow.AddMinutes(-2), + location: @"C:\repos\PSReadline"); + + // Emoji icons must be absent. + Assert.DoesNotContain(TooltipRunsEmoji, rendered); + Assert.DoesNotContain(TooltipLastEmoji, rendered); + Assert.DoesNotContain(TooltipDirEmoji, rendered); + + // Plain-text labels and value must remain. + Assert.Contains("Runs ", rendered); + Assert.Contains("47", rendered); + Assert.Contains("Last ", rendered); + Assert.Contains("Dir ", rendered); + Assert.Contains(@"C:\repos\PSReadline", rendered); + } + finally + { + opts.AccessibleHistoryDisplay = original; + } + } + + [SkippableFact] + public void AccessibleHistoryDisplay_TooltipKeepsEmojiWhenDisabled() + { + // Regression: with the option off, the existing emoji-decorated tooltip + // must be unchanged. + TestSetup(KeyMode.Cmd); + var opts = PSConsoleReadLine.GetOptions(); + var original = opts.AccessibleHistoryDisplay; + try + { + opts.AccessibleHistoryDisplay = false; + var rendered = CaptureRenderedHistoryStatsTooltip( + commandLine: "git status", + executionCount: 5, + startTime: DateTime.UtcNow.AddMinutes(-3), + location: @"C:\repos\PSReadline"); + + Assert.Contains(TooltipRunsEmoji, rendered); + Assert.Contains(TooltipLastEmoji, rendered); + Assert.Contains(TooltipDirEmoji, rendered); + Assert.Contains("Runs ", rendered); + Assert.Contains("Last ", rendered); + Assert.Contains("Dir ", rendered); + } + finally + { + opts.AccessibleHistoryDisplay = original; + } + } + + [SkippableFact] + public void AccessibleHistoryDisplay_TooltipOmitsAbsentFieldsWithoutStraySeparators() + { + // Edge case: an item with no StartTime and no Location must not leave + // separator characters or stray emoji in the rendered output. + TestSetup(KeyMode.Cmd); + var opts = PSConsoleReadLine.GetOptions(); + var original = opts.AccessibleHistoryDisplay; + try + { + opts.AccessibleHistoryDisplay = true; + var rendered = CaptureRenderedHistoryStatsTooltip( + commandLine: "alone", + executionCount: 1, + startTime: default, + location: null); + + Assert.Contains("Runs ", rendered); + Assert.Contains("1", rendered); + // No Last / Dir labels. + Assert.DoesNotContain("Last ", rendered); + Assert.DoesNotContain("Dir ", rendered); + // No separator characters between sections. + Assert.DoesNotContain("\u2502", rendered); + // No emoji at all. + Assert.DoesNotContain(TooltipRunsEmoji, rendered); + Assert.DoesNotContain(TooltipLastEmoji, rendered); + Assert.DoesNotContain(TooltipDirEmoji, rendered); + } + finally + { + opts.AccessibleHistoryDisplay = original; + } + } + + [SkippableFact] + public void AccessibleHistoryDisplay_TooltipOmitsLocationWhenUnknown() + { + // "Unknown" location entries (legacy text-history migrations) must not + // produce a Dir segment regardless of AccessibleHistoryDisplay. + TestSetup(KeyMode.Cmd); + var opts = PSConsoleReadLine.GetOptions(); + var original = opts.AccessibleHistoryDisplay; + try + { + opts.AccessibleHistoryDisplay = true; + var rendered = CaptureRenderedHistoryStatsTooltip( + commandLine: "legacy", + executionCount: 3, + startTime: DateTime.UtcNow.AddHours(-2), + location: "Unknown"); + + Assert.Contains("Runs ", rendered); + Assert.Contains("Last ", rendered); + Assert.DoesNotContain("Dir ", rendered); + } + finally + { + opts.AccessibleHistoryDisplay = original; + } + } + + [SkippableFact] + public void AccessibleHistoryDisplay_NavStatusUsesTextLabelsForChronologicalRecall() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory)); + + var opts = PSConsoleReadLine.GetOptions(); + var original = opts.AccessibleHistoryDisplay; + try + { + opts.AccessibleHistoryDisplay = true; + + SetHistory("a", "b", "c"); + + Test("", Keys( + _.Ctrl_p, + CheckThat(() => AssertLineIs("c")), + CheckThat(() => Assert.Equal( + ExpectedAccessibleNavStatus(1, 3, locationMode: false), + GetStatusLinePromptForTest())), + // Status line must NOT contain the chronological emoji. + CheckThat(() => Assert.DoesNotContain(NavStatusChronoEmoji, GetStatusLinePromptForTest())), + _.Escape + )); + } + finally + { + opts.AccessibleHistoryDisplay = original; + } + } + + [SkippableFact] + public void AccessibleHistoryDisplay_NavStatusUsesTextLabelsForLocationRecall() + { + TestSetup(KeyMode.Cmd, + new KeyHandler("UpArrow", PSConsoleReadLine.PreviousLocationHistory), + new KeyHandler("DownArrow", PSConsoleReadLine.NextLocationHistory)); + + var opts = PSConsoleReadLine.GetOptions(); + var original = opts.AccessibleHistoryDisplay; + try + { + opts.AccessibleHistoryDisplay = true; + + using var loc = SetTestLocation(@"C:\Projects\Accessible"); + + SetHistoryWithLocations( + ("local-1", @"C:\Projects\Accessible"), + ("other", @"C:\Other"), + ("local-2", @"C:\Projects\Accessible")); + + Test("", Keys( + _.UpArrow, + CheckThat(() => AssertLineIs("local-2")), + CheckThat(() => Assert.Equal( + ExpectedAccessibleNavStatus(1, 2, locationMode: true), + GetStatusLinePromptForTest())), + // Status line must NOT contain the location emoji surrogate pair. + CheckThat(() => Assert.DoesNotContain(NavStatusLocEmoji, GetStatusLinePromptForTest())), + _.Escape + )); + } + finally + { + opts.AccessibleHistoryDisplay = original; + } + } + + [SkippableFact] + public void AccessibleHistoryDisplay_NavStatusKeepsEmojiWhenDisabled() + { + // Regression: with the option off, the existing emoji indicator must + // continue to render as before. + TestSetup(KeyMode.Cmd, + new KeyHandler("Ctrl+p", PSConsoleReadLine.PreviousHistory)); + + var opts = PSConsoleReadLine.GetOptions(); + var original = opts.AccessibleHistoryDisplay; + try + { + opts.AccessibleHistoryDisplay = false; + + SetHistory("a", "b"); + + Test("", Keys( + _.Ctrl_p, + CheckThat(() => AssertLineIs("b")), + CheckThat(() => Assert.Contains(NavStatusChronoEmoji, GetStatusLinePromptForTest())), + CheckThat(() => Assert.DoesNotContain("History ", GetStatusLinePromptForTest())), + _.Escape + )); + } + finally + { + opts.AccessibleHistoryDisplay = original; + } + } + } +}