chrome-devtools-frontend
Version:
Chrome DevTools UI
686 lines (598 loc) โข 25.9 kB
text/typescript
// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */
import * as Common from '../../../core/common/common.js';
import type * as Host from '../../../core/host/host.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as TextUtils from '../../../models/text_utils/text_utils.js';
import * as CM from '../../../third_party/codemirror.next/codemirror.next.js';
import * as UI from '../../legacy/legacy.js';
import * as VisualLogging from '../../visual_logging/visual_logging.js';
import * as CodeHighlighter from '../code_highlighter/code_highlighter.js';
import * as Icon from '../icon_button/icon_button.js';
import {editorTheme} from './theme.js';
const LINES_TO_SCAN_FOR_INDENTATION_GUESSING = 1000;
const RECOMPUTE_INDENT_MAX_SIZE = 200;
const UIStrings = {
/**
* @description Label text for the editor
*/
codeEditor: 'Code editor',
/**
* @description Aria alert to read the suggestion for the suggestion box when typing in text editor
* @example {name} PH1
* @example {2} PH2
* @example {5} PH3
*/
sSuggestionSOfS: '{PH1}, suggestion {PH2} of {PH3}',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/components/text_editor/config.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const empty: CM.Extension = [];
export const dynamicSetting = CM.Facet.define<DynamicSetting<unknown>>();
// The code below is used to wire up dynamic settings to editors. When
// you include the result of calling `instance()` in an editor
// configuration, the TextEditor class will take care of listening to
// changes in the setting, and updating the configuration as
// appropriate.
export class DynamicSetting<T> {
compartment = new CM.Compartment();
constructor(
readonly settingName: string,
private readonly getExtension: (value: T) => CM.Extension,
) {
}
settingValue(): T {
return Common.Settings.Settings.instance().moduleSetting(this.settingName).get() as T;
}
instance(): CM.Extension {
return [
this.compartment.of(this.getExtension(this.settingValue())),
dynamicSetting.of(this as DynamicSetting<unknown>),
];
}
sync(state: CM.EditorState, value: T): CM.StateEffect<unknown>|null {
const cur = this.compartment.get(state);
const needed = this.getExtension(value);
return cur === needed ? null : this.compartment.reconfigure(needed);
}
static bool(name: string, enabled: CM.Extension, disabled: CM.Extension = empty): DynamicSetting<boolean> {
return new DynamicSetting<boolean>(name, val => val ? enabled : disabled);
}
static none: ReadonlyArray<DynamicSetting<unknown>> = [];
}
export const tabMovesFocus = DynamicSetting.bool('text-editor-tab-moves-focus', [], CM.keymap.of([{
key: 'Tab',
run: (view: CM.EditorView) => view.state.doc.length ? CM.indentMore(view) : false,
shift: (view: CM.EditorView) => view.state.doc.length ? CM.indentLess(view) : false,
}]));
const disableConservativeCompletion = CM.StateEffect.define();
/**
* When enabled, this suppresses the behavior of showCompletionHint
* and accepting of completions with Enter until the user selects a
* completion beyond the initially selected one. Used in the console.
**/
export const conservativeCompletion = CM.StateField.define<boolean>({
create() {
return true;
},
update(value, tr) {
if (CM.completionStatus(tr.state) !== 'active') {
return true;
}
if ((CM.selectedCompletionIndex(tr.startState) ?? 0) !== (CM.selectedCompletionIndex(tr.state) ?? 0) ||
tr.effects.some(e => e.is(disableConservativeCompletion))) {
return false;
}
return value;
},
});
function acceptCompletionIfNotConservative(view: CM.EditorView): boolean {
return !view.state.field(conservativeCompletion, false) && CM.acceptCompletion(view);
}
function acceptCompletionIfAtEndOfLine(view: CM.EditorView): boolean {
const cursorPosition = view.state.selection.main.head;
const line = view.state.doc.lineAt(cursorPosition);
const column = cursorPosition - line.from;
const isCursorAtEndOfLine = column >= line.length;
if (isCursorAtEndOfLine) {
return CM.acceptCompletion(view);
}
// We didn't handle this key press
// so it will be handled by default behavior.
return false;
}
/**
* This is a wrapper around CodeMirror's own moveCompletionSelection command, which
* selects the first selection if the state of the selection is conservative, and
* otherwise behaves as normal.
**/
function moveCompletionSelectionIfNotConservative(
forward: boolean, by: 'option'|'page' = 'option'): ((view: CM.EditorView) => boolean) {
return view => {
if (CM.completionStatus(view.state) !== 'active') {
return false;
}
if (view.state.field(conservativeCompletion, false)) {
view.dispatch({effects: disableConservativeCompletion.of(null)});
announceSelectedCompletionInfo(view);
return true;
}
const moveSelectionResult = CM.moveCompletionSelection(forward, by)(view);
announceSelectedCompletionInfo(view);
return moveSelectionResult;
};
}
function moveCompletionSelectionBackwardWrapper(): ((view: CM.EditorView) => boolean) {
return view => {
if (CM.completionStatus(view.state) !== 'active') {
return false;
}
CM.moveCompletionSelection(false)(view);
announceSelectedCompletionInfo(view);
return true;
};
}
function announceSelectedCompletionInfo(view: CM.EditorView): void {
const ariaMessage = i18nString(UIStrings.sSuggestionSOfS, {
PH1: CM.selectedCompletion(view.state)?.label || '',
PH2: (CM.selectedCompletionIndex(view.state) || 0) + 1,
PH3: CM.currentCompletions(view.state).length,
});
UI.ARIAUtils.LiveAnnouncer.alert(ariaMessage);
}
export const autocompletion = new DynamicSetting<boolean>(
'text-editor-autocompletion',
(activateOnTyping: boolean) =>
[CM.autocompletion({
activateOnTyping,
icons: false,
optionClass: (option: CM.Completion) => option.type === 'secondary' ? 'cm-secondaryCompletion' : '',
tooltipClass: (state: CM.EditorState) => {
return state.field(conservativeCompletion, false) ? 'cm-conservativeCompletion' : '';
},
defaultKeymap: false,
updateSyncTime: 100,
}),
CM.Prec.highest(CM.keymap.of([
{key: 'End', run: acceptCompletionIfAtEndOfLine},
{key: 'ArrowRight', run: acceptCompletionIfAtEndOfLine},
{key: 'Ctrl-Space', run: CM.startCompletion},
{key: 'Escape', run: CM.closeCompletion},
{key: 'ArrowDown', run: moveCompletionSelectionIfNotConservative(true)},
{key: 'ArrowUp', run: moveCompletionSelectionBackwardWrapper()},
{mac: 'Ctrl-n', run: moveCompletionSelectionIfNotConservative(true)},
{mac: 'Ctrl-p', run: moveCompletionSelectionBackwardWrapper()},
{key: 'PageDown', run: CM.moveCompletionSelection(true, 'page')},
{key: 'PageUp', run: CM.moveCompletionSelection(false, 'page')},
{key: 'Enter', run: acceptCompletionIfNotConservative},
]))]);
export const bracketMatching = DynamicSetting.bool('text-editor-bracket-matching', CM.bracketMatching());
export const codeFolding = DynamicSetting.bool('text-editor-code-folding', [
CM.foldGutter({
markerDOM(open: boolean): HTMLElement {
const iconName = open ? 'triangle-down' : 'triangle-right';
const icon = new Icon.Icon.Icon();
icon.setAttribute('class', open ? 'cm-foldGutterElement' : 'cm-foldGutterElement cm-foldGutterElement-folded');
icon.setAttribute('jslog', `${VisualLogging.expand().track({click: true})}`);
icon.name = iconName;
icon.classList.add('small');
return icon;
},
}),
CM.keymap.of(CM.foldKeymap),
]);
const AutoDetectIndent = CM.StateField.define<string>({
create: state => detectIndentation(state.doc),
update: (indent, tr) => {
return tr.docChanged && preservedLength(tr.changes) <= RECOMPUTE_INDENT_MAX_SIZE ? detectIndentation(tr.state.doc) :
indent;
},
provide: f => CM.Prec.highest(CM.indentUnit.from(f)),
});
function preservedLength(ch: CM.ChangeDesc): number {
let len = 0;
ch.iterGaps((_from, _to, l) => {
len += l;
});
return len;
}
function detectIndentation(doc: CM.Text): string {
const lines = doc.iterLines(1, Math.min(doc.lines + 1, LINES_TO_SCAN_FOR_INDENTATION_GUESSING));
const indentUnit = TextUtils.TextUtils.detectIndentation(lines);
return indentUnit ?? Common.Settings.Settings.instance().moduleSetting('text-editor-indent').get();
}
export const autoDetectIndent = DynamicSetting.bool('text-editor-auto-detect-indent', AutoDetectIndent);
function matcher(decorator: CM.MatchDecorator): CM.Extension {
return CM.ViewPlugin.define(
view => ({
decorations: decorator.createDeco(view),
update(u): void {
this.decorations = decorator.updateDeco(u, this.decorations);
},
}),
{
decorations: v => v.decorations,
});
}
const WhitespaceDeco = new Map<string, CM.Decoration>();
function getWhitespaceDeco(space: string): CM.Decoration {
const cached = WhitespaceDeco.get(space);
if (cached) {
return cached;
}
const result = CM.Decoration.mark({
attributes: space === '\t' ? {
class: 'cm-highlightedTab',
} :
{class: 'cm-highlightedSpaces', 'data-display': 'ยท'.repeat(space.length)},
});
WhitespaceDeco.set(space, result);
return result;
}
const showAllWhitespace = matcher(new CM.MatchDecorator({
regexp: /\t| +/g,
decoration: (match: RegExpExecArray) => getWhitespaceDeco(match[0]),
boundary: /\S/,
}));
const showTrailingWhitespace = matcher(new CM.MatchDecorator({
regexp: /\s+$/g,
decoration: CM.Decoration.mark({class: 'cm-trailingWhitespace'}),
boundary: /\S/,
}));
export const showWhitespace = new DynamicSetting<string>('show-whitespaces-in-editor', value => {
if (value === 'all') {
return showAllWhitespace;
}
if (value === 'trailing') {
return showTrailingWhitespace;
}
return empty;
});
export const allowScrollPastEof = DynamicSetting.bool('allow-scroll-past-eof', CM.scrollPastEnd());
const cachedIndentUnit: Record<string, CM.Extension> = Object.create(null);
function getIndentUnit(indent: string): CM.Extension {
let value = cachedIndentUnit[indent];
if (!value) {
value = cachedIndentUnit[indent] = CM.indentUnit.of(indent);
}
return value;
}
export const indentUnit = new DynamicSetting<string>('text-editor-indent', getIndentUnit);
export const domWordWrap = DynamicSetting.bool('dom-word-wrap', CM.EditorView.lineWrapping);
export const sourcesWordWrap = DynamicSetting.bool('sources.word-wrap', CM.EditorView.lineWrapping);
function detectLineSeparator(text: string): CM.Extension {
if (/\r\n/.test(text) && !/(^|[^\r])\n/.test(text)) {
return CM.EditorState.lineSeparator.of('\r\n');
}
return [];
}
const baseKeymap = CM.keymap.of([
{key: 'Tab', run: CM.acceptCompletion},
{key: 'Ctrl-m', run: CM.cursorMatchingBracket, shift: CM.selectMatchingBracket},
{key: 'Mod-/', run: CM.toggleComment},
{key: 'Mod-d', run: CM.selectNextOccurrence},
{key: 'Alt-ArrowLeft', mac: 'Ctrl-ArrowLeft', run: CM.cursorSyntaxLeft, shift: CM.selectSyntaxLeft},
{key: 'Alt-ArrowRight', mac: 'Ctrl-ArrowRight', run: CM.cursorSyntaxRight, shift: CM.selectSyntaxRight},
{key: 'Ctrl-ArrowLeft', mac: 'Alt-ArrowLeft', run: CM.cursorGroupLeft, shift: CM.selectGroupLeft},
{key: 'Ctrl-ArrowRight', mac: 'Alt-ArrowRight', run: CM.cursorGroupRight, shift: CM.selectGroupRight},
...CM.standardKeymap,
...CM.historyKeymap,
]);
function themeIsDark(): boolean {
const setting = Common.Settings.Settings.instance().moduleSetting('ui-theme').get();
return setting === 'systemPreferred' ? window.matchMedia('(prefers-color-scheme: dark)').matches : setting === 'dark';
}
export const dummyDarkTheme = CM.EditorView.theme({}, {dark: true});
export const themeSelection = new CM.Compartment();
export function theme(): CM.Extension {
return [editorTheme, themeIsDark() ? themeSelection.of(dummyDarkTheme) : themeSelection.of([])];
}
let sideBarElement: HTMLElement|null = null;
function getTooltipSpace(): DOMRect {
if (!sideBarElement) {
sideBarElement = UI.UIUtils.getDevToolsBoundingElement();
}
return sideBarElement.getBoundingClientRect();
}
export function baseConfiguration(text: string|CM.Text): CM.Extension {
return [
theme(),
CM.highlightSpecialChars(),
CM.highlightSelectionMatches(),
CM.history(),
CM.drawSelection(),
CM.EditorState.allowMultipleSelections.of(true),
CM.indentOnInput(),
CM.syntaxHighlighting(CodeHighlighter.CodeHighlighter.highlightStyle),
baseKeymap,
CM.EditorView.clickAddsSelectionRange.of(mouseEvent => mouseEvent.altKey || mouseEvent.ctrlKey),
tabMovesFocus.instance(),
bracketMatching.instance(),
indentUnit.instance(),
CM.Prec.lowest(CM.EditorView.contentAttributes.of({'aria-label': i18nString(UIStrings.codeEditor)})),
text instanceof CM.Text ? [] : detectLineSeparator(text),
CM.tooltips({
parent: getTooltipHost() as unknown as HTMLElement,
tooltipSpace: getTooltipSpace,
}),
CM.bidiIsolates(),
];
}
export const closeBrackets = DynamicSetting.bool('text-editor-bracket-closing', [
CM.html.autoCloseTags,
CM.closeBrackets(),
CM.keymap.of(CM.closeBracketsKeymap),
]);
// Root editor tooltips at the top of the document, creating a special
// element with the editor styles mounted in it for them. This is
// annoying, but necessary because a scrollable parent node clips them
// otherwise, `position: fixed` doesn't work due to `contain` styles,
// and appending them directly to `document.body` doesn't work because
// the necessary style sheets aren't available there.
let tooltipHost: ShadowRoot|null = null;
function getTooltipHost(): ShadowRoot {
if (!tooltipHost) {
const styleModules = CM.EditorState
.create({
extensions: [
editorTheme,
themeIsDark() ? dummyDarkTheme : [],
CM.syntaxHighlighting(CodeHighlighter.CodeHighlighter.highlightStyle),
CM.showTooltip.of({
pos: 0,
create() {
return {dom: document.createElement('div')};
},
}),
],
})
.facet<readonly CM.StyleModule[]>(CM.EditorView.styleModule);
const host = document.body.appendChild(document.createElement('div'));
host.className = 'editor-tooltip-host';
tooltipHost = host.attachShadow({mode: 'open'});
CM.StyleModule.mount(tooltipHost, styleModules);
}
return tooltipHost;
}
class CompletionHint extends CM.WidgetType {
constructor(readonly text: string) {
super();
}
override eq(other: CompletionHint): boolean {
return this.text === other.text;
}
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-completionHint';
span.textContent = this.text;
return span;
}
}
export const showCompletionHint = CM.ViewPlugin.fromClass(class {
decorations: CM.DecorationSet = CM.Decoration.none;
currentHint: string|null = null;
update(update: CM.ViewUpdate): void {
const top = this.currentHint = this.topCompletion(update.state);
if (!top || update.state.field(conservativeCompletion, false)) {
this.decorations = CM.Decoration.none;
} else {
this.decorations = CM.Decoration.set(
[CM.Decoration.widget({widget: new CompletionHint(top), side: 1}).range(update.state.selection.main.head)]);
}
}
topCompletion(state: CM.EditorState): string|null {
const completion = CM.selectedCompletion(state);
if (!completion) {
return null;
}
let {label, apply} = completion;
if (typeof apply === 'string') {
label = apply;
apply = undefined;
}
if (apply || label.length > 100 || label.indexOf('\n') > -1 || completion.type === 'secondary') {
return null;
}
const pos = state.selection.main.head;
const lineBefore = state.doc.lineAt(pos);
if (pos !== lineBefore.to) {
return null;
}
const partBefore = (label[0] === '\'' ? /'(\\.|[^'\\])*$/ :
label[0] === '"' ? /"(\\.|[^"\\])*$/ :
/#?[\w$]+$/)
.exec(lineBefore.text);
if (partBefore && !label.startsWith(partBefore[0])) {
return null;
}
return label.slice(partBefore ? partBefore[0].length : 0);
}
}, {decorations: p => p.decorations});
export function contentIncludingHint(view: CM.EditorView): string {
const plugin = view.plugin(showCompletionHint);
let content = view.state.doc.toString();
if (plugin?.currentHint) {
const {head} = view.state.selection.main;
content = content.slice(0, head) + plugin.currentHint + content.slice(head);
}
return content;
}
export const setAiAutoCompleteSuggestion = CM.StateEffect.define<ActiveSuggestion|null>();
interface ActiveSuggestion {
text: string;
from: number;
sampleId?: number;
rpcGlobalId?: Host.AidaClient.RpcGlobalId;
startTime: number;
onImpression: (rpcGlobalId: Host.AidaClient.RpcGlobalId, latency: number, sampleId?: number) => void;
clearCachedRequest: () => void;
}
export const aiAutoCompleteSuggestionState = CM.StateField.define<ActiveSuggestion|null>({
create: () => null,
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setAiAutoCompleteSuggestion)) {
if (effect.value) {
return effect.value;
}
value?.clearCachedRequest();
return null;
}
}
if (!value) {
return value;
}
// A suggestion from an effect can be stale if the document was changed
// between when the request was sent and the response was received.
// We check if the position is still valid before trying to map it.
if (value.from > tr.state.doc.length) {
value.clearCachedRequest();
return null;
}
// If deletion occurs, set to null. Otherwise, the mapping might fail if
// the position is inside the deleted range.
if (tr.docChanged && tr.state.doc.length < tr.startState.doc.length) {
value.clearCachedRequest();
return null;
}
const from = tr.changes.mapPos(value.from);
const {head} = tr.state.selection.main;
// If a change happened before the position from which suggestion was generated, set to null.
if (tr.docChanged && head < from) {
value.clearCachedRequest();
return null;
}
// Check if what's typed after the AI suggestion is a prefix of the AI suggestion.
const typedText = tr.state.doc.sliceString(from, head);
return value.text.startsWith(typedText) ? value : null;
},
});
export function hasActiveAiSuggestion(state: CM.EditorState): boolean {
return state.field(aiAutoCompleteSuggestionState) !== null;
}
export function acceptAiAutoCompleteSuggestion(view: CM.EditorView):
{accepted: boolean, suggestion?: ActiveSuggestion} {
const selectedCompletion = CM.selectedCompletion(view.state);
if (selectedCompletion) {
return {accepted: false};
}
const suggestion = view.state.field(aiAutoCompleteSuggestionState);
if (!suggestion) {
return {accepted: false};
}
const {text, from} = suggestion;
const {head} = view.state.selection.main;
const typedText = view.state.doc.sliceString(from, head);
if (!text.startsWith(typedText)) {
return {accepted: false};
}
const remainingText = text.slice(typedText.length);
view.dispatch({
changes: {from: head, insert: remainingText},
selection: {anchor: head + remainingText.length},
effects: setAiAutoCompleteSuggestion.of(null),
userEvent: 'input.complete',
});
suggestion.clearCachedRequest();
return {accepted: true, suggestion};
}
export const aiAutoCompleteSuggestion: CM.Extension = [
aiAutoCompleteSuggestionState,
CM.ViewPlugin.fromClass(
class {
decorations: CM.DecorationSet = CM.Decoration.none;
#lastLoggedSuggestion: ActiveSuggestion|null = null;
update(update: CM.ViewUpdate): void {
// If there is no text on the document, we don't want to show the AI suggestion.
if (update.state.doc.length === 0) {
this.decorations = CM.Decoration.none;
return;
}
// Hide decorations if there is no active AI suggestion.
const activeSuggestion = update.state.field(aiAutoCompleteSuggestionState);
if (!activeSuggestion) {
this.decorations = CM.Decoration.none;
return;
}
// Hide AI suggestion while the user is interacting with the traditional
// autocomplete menu to avoid conflicting suggestions.
if (CM.completionStatus(update.view.state) === 'pending') {
this.decorations = CM.Decoration.none;
return;
}
// Hide AI suggestion if the user has selected an item from the
// traditional autocomplete menu that is not the first one.
const selectedCompletionIndex = CM.selectedCompletionIndex(update.state);
if (selectedCompletionIndex && selectedCompletionIndex > 0) {
this.decorations = CM.Decoration.none;
return;
}
const {head} = update.state.selection.main;
// Hide AI suggestion if the user moves the cursor to a location
// before the position from which suggestion was generated.
if (head < activeSuggestion.from) {
this.decorations = CM.Decoration.none;
return;
}
const selectedCompletion = CM.selectedCompletion(update.state);
const additionallyTypedText = update.state.doc.sliceString(activeSuggestion.from, head);
// The user might have typed text after the suggestion is triggered.
// If the suggestion no longer starts with the typed text, hide it.
if (!activeSuggestion.text.startsWith(additionallyTypedText)) {
this.decorations = CM.Decoration.none;
return;
}
let ghostText = activeSuggestion.text.slice(additionallyTypedText.length);
if (selectedCompletion) {
// Do not show AI generated suggestion if top traditional suggestion is of type
// 'keyword' - `do`, `while` etc.
if (selectedCompletion.type?.includes('keyword')) {
this.decorations = CM.Decoration.none;
return;
}
// If a traditional autocomplete menu is shown, the AI suggestion is only
// shown if it builds upon the currently selected item. If there is no
// overlap, we hide the AI suggestion. For example, for the text `console`
// if the traditional autocomplete suggests `log` and the AI
// suggests `warn`, there is no overlap and the AI suggestion is hidden.
const overlappingText = TextUtils.TextUtils.getOverlap(selectedCompletion.label, ghostText) ?? '';
const lineAtAiSuggestion = update.state.doc.lineAt(activeSuggestion.from).text;
const overlapsWithSelectedCompletion =
(lineAtAiSuggestion + overlappingText).endsWith(selectedCompletion.label);
if (!overlapsWithSelectedCompletion) {
this.decorations = CM.Decoration.none;
return;
}
}
// When `conservativeCompletion` is disabled in Console, the editor shows a ghost
// text for the first item in the traditional autocomplete menu and this ghost text
// is reflected in `currentHint`. In this case, we need to remove
// the overlapping part from our AI suggestion's ghost text to avoid
// showing a double suggestion.
const currentMenuHint = update.view.plugin(showCompletionHint)?.currentHint;
const conservativeCompletionEnabled = update.state.field(conservativeCompletion, false);
if (!conservativeCompletionEnabled && currentMenuHint) {
ghostText = ghostText.slice(currentMenuHint.length);
}
this.decorations =
CM.Decoration.set([CM.Decoration.widget({widget: new CompletionHint(ghostText), side: 1}).range(head)]);
this.#registerImpressionIfNeeded(activeSuggestion);
}
#registerImpressionIfNeeded(activeSuggestion: ActiveSuggestion): void {
if (!activeSuggestion.rpcGlobalId) {
return;
}
if (this.#lastLoggedSuggestion?.rpcGlobalId === activeSuggestion?.rpcGlobalId &&
this.#lastLoggedSuggestion?.sampleId === activeSuggestion?.sampleId) {
return;
}
const latency = performance.now() - activeSuggestion.startTime;
// only register impression for the first time AI generated suggestion is shown to the user.
activeSuggestion.onImpression(activeSuggestion.rpcGlobalId, latency, activeSuggestion.sampleId);
this.#lastLoggedSuggestion = activeSuggestion;
}
},
{decorations: p => p.decorations}),
];