| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import type * as Common from '../../../core/common/common.js'; |
| |
| export class AutocompleteHistory { |
| static #historySize = 300; |
| |
| #setting: Common.Settings.Setting<string[]>; |
| |
| /** |
| * The data mirrors the setting. We have the mirror for 2 reasons: |
| * 1) The setting is size limited |
| * 2) We track the user's current input, even though it's not committed yet. |
| */ |
| #data: string[] = []; |
| |
| /** 1-based entry in the history stack. */ |
| #historyOffset = 1; |
| #uncommittedIsTop = false; |
| |
| /** |
| * Tracks session-local edits made to history entries during navigation. |
| * Maps history index to edited text. Cleared when a new command is committed. |
| */ |
| #editedEntries = new Map<number, string>(); |
| |
| /** |
| * The prefix used for filtering history during navigation (zsh-style). |
| * Set on first navigation when user has typed something. |
| */ |
| #searchPrefix = ''; |
| |
| /** |
| * Stack of history indices visited during filtered navigation. |
| * Used to navigate forward through the same filtered entries. |
| */ |
| #filteredIndices: number[] = []; |
| |
| /** |
| * Creates a new settings-backed history. The class assumes it has sole |
| * ownership of the setting. |
| */ |
| constructor(setting: Common.Settings.Setting<string[]>) { |
| this.#setting = setting; |
| this.#data = this.#setting.get(); |
| } |
| |
| clear(): void { |
| this.#data = []; |
| this.#setting.set([]); |
| this.#historyOffset = 1; |
| this.#editedEntries.clear(); |
| this.#searchPrefix = ''; |
| this.#filteredIndices = []; |
| } |
| |
| length(): number { |
| return this.#data.length; |
| } |
| |
| /** |
| * Pushes a committed text into the history. |
| */ |
| pushHistoryItem(text: string): void { |
| if (this.#uncommittedIsTop) { |
| this.#data.pop(); |
| this.#uncommittedIsTop = false; |
| } |
| |
| this.#historyOffset = 1; |
| this.#editedEntries.clear(); |
| this.#searchPrefix = ''; |
| this.#filteredIndices = []; |
| if (text !== this.#currentHistoryItem()) { |
| this.#data.push(text); |
| } |
| this.#store(); |
| } |
| |
| /** |
| * Pushes the current (uncommitted) text into the history. |
| */ |
| #pushCurrentText(currentText: string): void { |
| if (this.#uncommittedIsTop) { |
| this.#data.pop(); |
| } // Throw away obsolete uncommitted text. |
| this.#uncommittedIsTop = true; |
| this.#data.push(currentText); |
| } |
| |
| previous(currentText?: string): string|undefined { |
| if (this.#historyOffset > this.#data.length) { |
| return undefined; |
| } |
| currentText = currentText ?? ''; |
| if (this.#historyOffset === 1) { |
| this.#pushCurrentText(currentText); |
| |
| this.#filteredIndices = []; |
| this.#searchPrefix = currentText; |
| } else { |
| // Save edits made to history entries at non-uncommitted positions. |
| // #saveCurrentEdit() compares against #currentHistoryItem() and no-ops for |
| // pure navigation. |
| this.#saveCurrentEdit(currentText); |
| } |
| |
| // If no prefix filter, use simple sequential navigation |
| if (this.#searchPrefix.length === 0) { |
| ++this.#historyOffset; |
| return this.#currentHistoryItem(); |
| } |
| |
| // Find the next matching entry for prefix-filtered navigation |
| const result = this.#findNextMatch(); |
| |
| if (!result) { |
| // On initial navigation with a non-empty prefix and no matches, fall back to |
| // unfiltered history navigation. |
| if (this.#historyOffset === 1) { |
| this.#searchPrefix = ''; |
| ++this.#historyOffset; |
| return this.#currentHistoryItem(); |
| } |
| return undefined; |
| } |
| |
| this.#filteredIndices.push(result.index); |
| this.#historyOffset = this.#data.length - result.index; |
| return result.value; |
| } |
| |
| /** |
| * Finds the next history entry that matches the search prefix. |
| * Returns undefined if no more matches are found. |
| */ |
| #findNextMatch(): {index: number, value: string}|undefined { |
| // Start searching from the current position |
| const startIndex = this.#data.length - this.#historyOffset - 1; |
| |
| for (let i = startIndex; i >= 0; --i) { |
| const storedValue = this.#data[i]; |
| if (storedValue.startsWith(this.#searchPrefix)) { |
| const value = this.#editedEntries.get(i) ?? storedValue; |
| return {index: i, value}; |
| } |
| } |
| return undefined; |
| } |
| |
| /** |
| * Saves the current text as an edit if it differs from the current history item |
| * (which may already have edits from a previous navigation). |
| * Only saves non-empty edits to avoid issues with navigation-only calls. |
| */ |
| #saveCurrentEdit(text: string): void { |
| const index = this.#data.length - this.#historyOffset; |
| const currentValue = this.#currentHistoryItem(); |
| if (text === currentValue) { |
| return; |
| } |
| const original = this.#data[index]; |
| if (text !== original && text.length > 0) { |
| this.#editedEntries.set(index, text); |
| } else { |
| // Remove edit if text was restored to original (or emptied) |
| this.#editedEntries.delete(index); |
| } |
| } |
| |
| next(currentText?: string): string|undefined { |
| if (this.#historyOffset === 1) { |
| return undefined; |
| } |
| |
| currentText = currentText ?? this.#currentHistoryItem() ?? ''; |
| |
| this.#saveCurrentEdit(currentText); |
| |
| // If no prefix filter was used, use simple sequential navigation |
| if (this.#searchPrefix.length === 0) { |
| --this.#historyOffset; |
| return this.#currentHistoryItem(); |
| } |
| |
| // Pop the current position from the filtered indices stack |
| this.#filteredIndices.pop(); |
| |
| if (this.#filteredIndices.length === 0) { |
| // No more filtered entries - return to the uncommitted text |
| this.#historyOffset = 1; |
| return this.#currentHistoryItem(); |
| } |
| |
| // Move to the previous filtered position |
| const prevIndex = this.#filteredIndices[this.#filteredIndices.length - 1]; |
| this.#historyOffset = this.#data.length - prevIndex; |
| return this.#currentHistoryItem(); |
| } |
| |
| /** Returns a de-duplicated list of history entries that start with the specified prefix */ |
| matchingEntries(prefix: string, limit = 50): Set<string> { |
| const result = new Set<string>(); |
| for (let i = this.#data.length - 1; i >= 0 && result.size < limit; --i) { |
| const entry = this.#data[i]; |
| if (entry.startsWith(prefix)) { |
| result.add(entry); |
| } |
| } |
| return result; |
| } |
| |
| #currentHistoryItem(): string|undefined { |
| const index = this.#data.length - this.#historyOffset; |
| // Return edited version if available, otherwise return original |
| return this.#editedEntries.get(index) ?? this.#data[index]; |
| } |
| |
| #store(): void { |
| this.#setting.set(this.#data.slice(-AutocompleteHistory.#historySize)); |
| } |
| } |