UNPKG

chrome-devtools-frontend

Version:
225 lines (191 loc) • 6.74 kB
// 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)); } }