diff --git a/.vscode/launch.json b/.vscode/launch.json index c0100623..dd313207 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,7 @@ "-NoProfile", "-NoExit", "-Command", - "Import-Module '${workspaceFolder}/PSReadLine/bin/Debug/netstandard2.0/PSReadLine.psd1'" + "Import-Module '${workspaceFolder}/PSReadLine/bin/Debug/netstandard2.0/win-x64/PSReadLine.psd1'" ], "console": "integratedTerminal", "justMyCode": false, diff --git a/PSReadLine/Cmdlets.cs b/PSReadLine/Cmdlets.cs index 7fecf4b4..e6b18a95 100644 --- a/PSReadLine/Cmdlets.cs +++ b/PSReadLine/Cmdlets.cs @@ -61,7 +61,14 @@ public enum AddToHistoryOption { SkipAdding, MemoryOnly, - MemoryAndFile + MemoryAndFile, + SQLite + } + + public enum HistoryType + { + Text, + SQLite } public enum PredictionSource @@ -106,6 +113,12 @@ public class PSConsoleReadLineOptions public const string DefaultContinuationPrompt = ">> "; + /// + /// The default history format is text. The SQLite is experimental. + /// It is not recommended to use it for production yet. + /// + public const HistoryType DefaultHistoryType = HistoryType.Text; + /// /// The maximum number of commands to store in the history. /// @@ -201,6 +214,7 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole) { ResetColors(); EditMode = DefaultEditMode; + HistoryType = DefaultHistoryType; ContinuationPrompt = DefaultContinuationPrompt; ContinuationPromptColor = Console.ForegroundColor; ExtraPromptLineCount = DefaultExtraPromptLineCount; @@ -235,6 +249,13 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole) "PowerShell", "PSReadLine", historyFileName); + SqliteHistorySavePath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Microsoft", + "Windows", + "PowerShell", + "PSReadLine", + hostName + "_history.db"); } else { @@ -336,6 +357,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. @@ -375,6 +397,11 @@ public object ContinuationPromptColor /// The path to the saved history. /// public string HistorySavePath { get; set; } + + /// + /// The path to the SQLite history database. + /// + public string SqliteHistorySavePath { get; set; } public HistorySaveStyle HistorySaveStyle { get; set; } /// @@ -656,6 +683,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 { diff --git a/PSReadLine/History.cs b/PSReadLine/History.cs index c1490a23..be4e1822 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,11 @@ public class HistoryItem /// public bool FromHistoryFile { get; internal set; } + /// + /// The location where the command was run, if available. + /// + public string Location { get; internal set; } + internal bool _saved; internal bool _sensitive; internal List _edits; @@ -155,6 +162,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 +209,209 @@ private AddToHistoryOption GetAddToHistoryOption(string line, bool fromHistoryFi return AddToHistoryOption.MemoryAndFile; } + private void InitializeSQLiteDatabase() + { + 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(); + + // Migrate existing text file history if it exists + // 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) + { + // Check if text history file exists + string textHistoryPath = _options.HistorySavePath.Replace(".sqlite", ".txt"); + if (!File.Exists(textHistoryPath)) + { + // Try default history path pattern + textHistoryPath = Path.ChangeExtension(_options.HistorySavePath, ".txt"); + if (!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(), + StartTime = DateTime.UtcNow.AddMinutes(-historyItems.Count), // Approximate timestamps + ApproximateElapsedTime = TimeSpan.Zero, + Location = "Unknown" + }); + sb.Clear(); + } + else + { + historyItems.Add(new HistoryItem + { + CommandLine = line, + StartTime = DateTime.UtcNow.AddMinutes(-historyItems.Count), // Approximate timestamps + ApproximateElapsedTime = TimeSpan.Zero, + Location = "Unknown" + }); + } + } + + // 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 +427,7 @@ private string MaybeAddToHistory( _undoEditIndex = undoEditIndex, _editGroupStart = -1, _saved = fromHistoryFile, + Location = location ?? _engineIntrinsics?.SessionState?.Path?.CurrentLocation?.Path ?? "Unknown", FromOtherSession = fromDifferentSession, FromHistoryFile = fromInitialRead, }; @@ -277,7 +489,141 @@ 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) +RETURNING Id"; + insertCommand.Parameters.AddWithValue("@CommandLine", commandLine); + insertCommand.Parameters.AddWithValue("@CommandHash", commandHash); + + return Convert.ToInt64(insertCommand.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) +RETURNING Id"; + insertCommand.Parameters.AddWithValue("@Path", location); + + return Convert.ToInt64(insertCommand.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(); + } + + 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() @@ -411,6 +757,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 +780,184 @@ 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 + command.CommandText = @" +SELECT CommandLine, StartTime, ElapsedTime, Location +FROM HistoryView +WHERE Id > @LastId +ORDER BY 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), + FromHistoryFile = true, + FromOtherSession = 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 + }; + + // Use the view for easy querying + command.CommandText = @" +SELECT CommandLine, StartTime, ElapsedTime, Location +FROM HistoryView +ORDER BY 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), + FromHistoryFile = true, + FromOtherSession = fromOtherSession, + _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 +972,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 +1038,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); } } } @@ -902,6 +1411,7 @@ private void HistoryRecall(int direction) while (count > 0) { newHistoryIndex += direction; + if (newHistoryIndex < 0 || newHistoryIndex >= _history.Count) { break; @@ -931,7 +1441,18 @@ private void HistoryRecall(int direction) } } _recallHistoryCommandCount += 1; - if (newHistoryIndex >= 0 && newHistoryIndex <= _history.Count) + + // For SQLite/location-filtered history, allow returning to the current line + if (_options.HistoryType == HistoryType.SQLite && + (newHistoryIndex < 0 || newHistoryIndex >= _history.Count)) + { + _currentHistoryIndex = _history.Count; + var moveCursor = InViCommandMode() && !_options.HistorySearchCursorMovesToEnd + ? HistoryMoveCursor.ToBeginning + : HistoryMoveCursor.ToEnd; + UpdateFromHistory(moveCursor); + } + else if (newHistoryIndex >= 0 && newHistoryIndex <= _history.Count) { _currentHistoryIndex = newHistoryIndex; var moveCursor = InViCommandMode() && !_options.HistorySearchCursorMovesToEnd diff --git a/PSReadLine/Options.cs b/PSReadLine/Options.cs index 7485154b..45c58227 100644 --- a/PSReadLine/Options.cs +++ b/PSReadLine/Options.cs @@ -26,6 +26,27 @@ private void SetOptionsInternal(SetPSReadLineOption options) { Options.ContinuationPrompt = options.ContinuationPrompt; } + if (options._historyTypeSpecified) + { + Options.HistoryType = options.HistoryType; + if (Options.HistoryType is HistoryType.SQLite) + { + Options.HistorySavePath = Options.SqliteHistorySavePath; + // Check if the SQLite history file exists + if (!string.IsNullOrEmpty(Options.HistorySavePath) && !System.IO.File.Exists(Options.HistorySavePath)) + { + _historyFileMutex?.Dispose(); + _historyFileMutex = new Mutex(false, GetHistorySaveFileMutexName()); + InitializeSQLiteDatabase(); + _historyFileLastSavedSize = 0; + } + // For now remove all text history + _singleton._history?.Clear(); + _singleton._currentHistoryIndex = 0; + + ReadSQLiteHistory(fromOtherSession: false); + } + } if (options._historyNoDuplicates.HasValue) { Options.HistoryNoDuplicates = options.HistoryNoDuplicates; @@ -127,7 +148,7 @@ private void SetOptionsInternal(SetPSReadLineOption options) Options.HistorySavePath = options.HistorySavePath; _historyFileMutex?.Dispose(); _historyFileMutex = new Mutex(false, GetHistorySaveFileMutexName()); - _historyFileLastSavedSize = 0; + _historyFileLastSavedSize = 0; } if (options._ansiEscapeTimeout.HasValue) { diff --git a/PSReadLine/PSReadLine.csproj b/PSReadLine/PSReadLine.csproj index 1c8090f8..22ce78b2 100644 --- a/PSReadLine/PSReadLine.csproj +++ b/PSReadLine/PSReadLine.csproj @@ -13,12 +13,21 @@ true false 9.0 + true - - - + + + + + + + + + + + @@ -28,5 +37,6 @@ + - + \ No newline at end of file diff --git a/PSReadLine/PSReadLine.format.ps1xml b/PSReadLine/PSReadLine.format.ps1xml index 24f70799..f86c7a25 100644 --- a/PSReadLine/PSReadLine.format.ps1xml +++ b/PSReadLine/PSReadLine.format.ps1xml @@ -90,6 +90,9 @@ $d = [Microsoft.PowerShell.KeyHandler]::GetGroupingDescription($_.Group) EditMode + + HistoryType + AddToHistoryHandler 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 d810a328..411484ff 100644 --- a/PSReadLine/ReadLine.cs +++ b/PSReadLine/ReadLine.cs @@ -536,7 +536,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; @@ -884,7 +892,7 @@ private void DelayedOneTimeInitialize() { } - if (readHistoryFile) + if (readHistoryFile && _options.HistoryType == HistoryType.Text) { ReadHistoryFile(); } 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..d400d604 100644 --- a/test/HistoryTest.cs +++ b/test/HistoryTest.cs @@ -40,7 +40,8 @@ public void ParallelHistorySaving() TestSetup(KeyMode.Cmd); string historySavingFile = Path.GetTempFileName(); - var options = new SetPSReadLineOption { + var options = new SetPSReadLineOption + { HistorySaveStyle = HistorySaveStyle.SaveIncrementally, MaximumHistoryCount = 3, }; @@ -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")); @@ -1212,5 +1221,30 @@ public void HistoryCount() Test("cccc", Keys("cccc")); Test("aaaa", Keys(Enumerable.Repeat(_.UpArrow, 4))); } + + [SkippableFact] + public void SetPSReadLineOption_HistoryType_AcceptsSQLite() + { + var method = typeof(PSConsoleReadLine).GetMethod("SetOptions"); + Assert.NotNull(method); + + 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)); + + // Set the history type back to the default. + historyTypeProperty.SetValue(options, HistoryType.Text); + Assert.Equal(HistoryType.Text, historyTypeProperty.GetValue(options)); + } + + } }