blob: 9a86aa4fc232060101676d8b9ed65d914ecc87dc [file] [log] [blame] [edit]
// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {classNames} from '../base/classnames';
import {findRef} from '../base/dom_utils';
import {FuzzyFinder, FuzzySegment} from '../base/fuzzy';
import {assertExists, assertUnreachable} from '../base/logging';
import {isString} from '../base/object_utils';
import {undoCommonChatAppReplacements} from '../base/string_utils';
import {exists} from '../base/utils';
import {addQueryResultsTab} from '../components/query_table/query_result_tab';
import {AppImpl} from '../core/app_impl';
import {OmniboxMode} from '../core/omnibox_manager';
import {raf} from '../core/raf_scheduler';
import {TraceImpl} from '../core/trace_impl';
import {Button} from '../widgets/button';
import {Chip} from '../widgets/chip';
import {HTMLAttrs} from '../widgets/common';
import {EmptyState} from '../widgets/empty_state';
import {HotkeyGlyphs, KeycapGlyph} from '../widgets/hotkey_glyphs';
import {Popup} from '../widgets/popup';
import {Spinner} from '../widgets/spinner';
const OMNIBOX_INPUT_REF = 'omnibox';
const RECENT_COMMANDS_LIMIT = 6;
// Omnibox attrs - simplified to just what's needed from outside
export interface OmniboxAttrs {
readonly trace?: TraceImpl;
}
// Omnibox: Smart component that contains all omnibox business logic
// and wraps the presentational OmniboxWidget
export class Omnibox implements m.ClassComponent<OmniboxAttrs> {
private omniboxInputEl?: HTMLInputElement;
private recentCommands: ReadonlyArray<string> = [];
view({attrs}: m.Vnode<OmniboxAttrs>): m.Children {
const {trace} = attrs;
const app = AppImpl.instance;
const omnibox = app.omnibox;
const omniboxMode = omnibox.mode;
const statusMessage = omnibox.statusMessage;
if (statusMessage !== undefined) {
return m(
`.pf-omnibox.pf-omnibox--message-mode`,
m(`input[readonly][disabled][ref=omnibox]`, {
value: '',
placeholder: statusMessage,
}),
);
} else if (omniboxMode === OmniboxMode.Command) {
return this.renderCommandOmnibox();
} else if (omniboxMode === OmniboxMode.Prompt) {
return this.renderPromptOmnibox();
} else if (omniboxMode === OmniboxMode.Query) {
return this.renderQueryOmnibox(trace);
} else if (omniboxMode === OmniboxMode.Search) {
return this.renderSearchOmnibox(trace);
} else {
assertUnreachable(omniboxMode);
}
}
private renderPromptOmnibox(): m.Children {
const omnibox = AppImpl.instance.omnibox;
const prompt = assertExists(omnibox.pendingPrompt);
let options: OmniboxOption[] | undefined = undefined;
if (prompt.options) {
const fuzzy = new FuzzyFinder(
prompt.options,
({displayName}) => displayName,
);
const result = fuzzy.find(omnibox.text);
options = result.map((result) => {
return {
key: result.item.key,
displayName: result.segments,
};
});
}
return m(OmniboxWidget, {
value: omnibox.text,
placeholder: prompt.text,
inputRef: OMNIBOX_INPUT_REF,
className: 'pf-omnibox--prompt-mode',
closeOnOutsideClick: true,
options,
selectedOptionIndex: omnibox.selectionIndex,
onSelectedOptionChanged: (index) => {
omnibox.setSelectionIndex(index);
},
onInput: (value) => {
omnibox.setText(value);
omnibox.setSelectionIndex(0);
},
onSubmit: (value, _alt) => {
omnibox.resolvePrompt(value);
},
onClose: () => {
omnibox.rejectPrompt();
},
});
}
private renderCommandOmnibox(): m.Children {
// Fuzzy-filter commands by the filter string.
const {commands, omnibox} = AppImpl.instance;
const filteredCmds = commands.fuzzyFilterCommands(omnibox.text);
// Create an array of commands with attached heuristics from the recent
// command register.
const commandsWithHeuristics = filteredCmds.map((cmd) => {
return {
recentsIndex: this.recentCommands.findIndex((id) => id === cmd.id),
cmd,
};
});
// Sort recentsIndex first
const sorted = commandsWithHeuristics.sort((a, b) => {
if (b.recentsIndex === a.recentsIndex) {
// If recentsIndex is the same, retain original sort order
return 0;
} else {
return b.recentsIndex - a.recentsIndex;
}
});
const options = sorted.map(({recentsIndex, cmd}): OmniboxOption => {
const {segments, id, defaultHotkey} = cmd;
return {
key: id,
displayName: segments,
tag: recentsIndex !== -1 ? 'recently used' : undefined,
rightContent: defaultHotkey && m(HotkeyGlyphs, {hotkey: defaultHotkey}),
};
});
return m(OmniboxWidget, {
value: omnibox.text,
placeholder: 'Filter commands...',
inputRef: OMNIBOX_INPUT_REF,
className: 'pf-omnibox--command-mode',
options,
closeOnSubmit: true,
closeOnOutsideClick: true,
selectedOptionIndex: omnibox.selectionIndex,
onSelectedOptionChanged: (index) => {
omnibox.setSelectionIndex(index);
},
onInput: (value) => {
omnibox.setText(value);
omnibox.setSelectionIndex(0);
},
onClose: () => {
if (this.omniboxInputEl) {
this.omniboxInputEl.blur();
}
omnibox.reset();
},
onSubmit: (key: string) => {
this.addRecentCommand(key);
commands.runCommand(key);
},
onGoBack: () => {
omnibox.reset();
},
});
}
private addRecentCommand(id: string): void {
this.recentCommands = this.recentCommands
.filter((x) => x !== id) // Remove duplicates
.concat(id) // Add to the end
.splice(-RECENT_COMMANDS_LIMIT); // Limit items
}
private renderQueryOmnibox(trace: TraceImpl | undefined): m.Children {
const ph = 'e.g. select * from sched left join thread using(utid) limit 10';
return m(OmniboxWidget, {
value: AppImpl.instance.omnibox.text,
placeholder: ph,
inputRef: OMNIBOX_INPUT_REF,
className: 'pf-omnibox--query-mode',
onInput: (value) => {
AppImpl.instance.omnibox.setText(value);
},
onSubmit: (query, alt) => {
const config = {
query: undoCommonChatAppReplacements(query),
title: alt ? 'Pinned query' : 'Omnibox query',
};
const tag = alt ? undefined : 'omnibox_query';
if (trace === undefined) return;
addQueryResultsTab(trace, config, tag);
},
onClose: () => {
AppImpl.instance.omnibox.setText('');
if (this.omniboxInputEl) {
this.omniboxInputEl.blur();
}
AppImpl.instance.omnibox.reset();
},
onGoBack: () => {
AppImpl.instance.omnibox.reset();
},
});
}
private renderSearchOmnibox(trace: TraceImpl | undefined): m.Children {
return m(OmniboxWidget, {
value: AppImpl.instance.omnibox.text,
placeholder: "Search or type '>' for commands or ':' for SQL mode",
inputRef: OMNIBOX_INPUT_REF,
onInput: (value, _prev) => {
if (value === '>') {
AppImpl.instance.omnibox.setMode(OmniboxMode.Command);
return;
} else if (value === ':') {
AppImpl.instance.omnibox.setMode(OmniboxMode.Query);
return;
}
AppImpl.instance.omnibox.setText(value);
if (trace === undefined) return; // No trace loaded.
if (value.length >= 4) {
trace.search.search(value);
} else {
trace.search.reset();
}
},
onClose: () => {
if (this.omniboxInputEl) {
this.omniboxInputEl.blur();
}
},
onSubmit: (value, _mod, shift) => {
if (trace === undefined) return; // No trace loaded.
trace.search.search(value);
if (shift) {
trace.search.stepBackwards();
} else {
trace.search.stepForward();
}
if (this.omniboxInputEl) {
this.omniboxInputEl.blur();
}
},
rightContent: trace && this.renderStepThrough(trace),
});
}
private renderStepThrough(trace: TraceImpl) {
const children = [];
const results = trace.search.searchResults;
if (trace?.search.searchInProgress) {
children.push(m('.pf-omnibox__stepthrough-current', m(Spinner)));
} else if (results !== undefined) {
const searchMgr = trace.search;
const index = searchMgr.resultIndex;
const total = results.totalResults ?? 0;
children.push(
m(
'.pf-omnibox__stepthrough-current',
`${total === 0 ? '0 / 0' : `${index + 1} / ${total}`}`,
),
m(Button, {
onclick: () => searchMgr.stepBackwards(),
icon: 'keyboard_arrow_left',
}),
m(Button, {
onclick: () => searchMgr.stepForward(),
icon: 'keyboard_arrow_right',
}),
);
}
return m('.pf-omnibox__stepthrough', children);
}
oncreate({dom}: m.VnodeDOM<OmniboxAttrs>) {
this.updateOmniboxInputRef(dom);
this.maybeFocusOmnibar();
}
onupdate({dom}: m.VnodeDOM<OmniboxAttrs>) {
this.updateOmniboxInputRef(dom);
this.maybeFocusOmnibar();
}
private updateOmniboxInputRef(dom: Element): void {
const el = findRef(dom, OMNIBOX_INPUT_REF);
if (el && el instanceof HTMLInputElement) {
this.omniboxInputEl = el;
}
}
private maybeFocusOmnibar() {
if (AppImpl.instance.omnibox.focusOmniboxNextRender) {
const omniboxEl = this.omniboxInputEl;
if (omniboxEl) {
omniboxEl.focus();
if (AppImpl.instance.omnibox.pendingCursorPlacement === undefined) {
omniboxEl.select();
} else {
omniboxEl.setSelectionRange(
AppImpl.instance.omnibox.pendingCursorPlacement,
AppImpl.instance.omnibox.pendingCursorPlacement,
);
}
}
AppImpl.instance.omnibox.clearFocusFlag();
}
}
}
interface OmniboxOptionRowAttrs extends HTMLAttrs {
// Human readable display name for the option.
// This can either be a simple string, or a list of fuzzy segments in which
// case highlighting will be applied to the matching segments.
readonly displayName: FuzzySegment[] | string;
// Highlight this option.
readonly highlighted: boolean;
// Arbitrary components to put on the right hand side of the option.
readonly rightContent?: m.Children;
// Some tag to place on the right (to the left of the right content).
readonly label?: string;
}
class OmniboxOptionRow implements m.ClassComponent<OmniboxOptionRowAttrs> {
private highlightedBefore = false;
view({attrs}: m.Vnode<OmniboxOptionRowAttrs>): void | m.Children {
const {displayName, highlighted, rightContent, label, ...htmlAttrs} = attrs;
return m(
'li',
{
class: classNames(highlighted && 'pf-highlighted'),
...htmlAttrs,
},
m('span.pf-title', this.renderTitle(displayName)),
label && m(Chip, {className: 'pf-omnibox__tag', label, rounded: true}),
rightContent,
);
}
private renderTitle(title: FuzzySegment[] | string): m.Children {
if (isString(title)) {
return title;
} else {
return title.map(({matching, value}) => {
return matching ? m('b', value) : value;
});
}
}
onupdate({attrs, dom}: m.VnodeDOM<OmniboxOptionRowAttrs, this>) {
if (this.highlightedBefore !== attrs.highlighted) {
if (attrs.highlighted) {
dom.scrollIntoView({block: 'nearest'});
}
this.highlightedBefore = attrs.highlighted;
}
}
}
// Omnibox option.
interface OmniboxOption {
// The value to place into the omnibox. This is what's returned in onSubmit.
readonly key: string;
// Display name provided as a string or a list of fuzzy segments to enable
// fuzzy match highlighting.
readonly displayName: FuzzySegment[] | string;
// Some tag to place on the right (to the left of the right content).
readonly tag?: string;
// Arbitrary components to put on the right hand side of the option.
readonly rightContent?: m.Children;
}
interface OmniboxWidgetAttrs extends HTMLAttrs {
// Current value of the omnibox input.
readonly value: string;
// What to show when value is blank.
readonly placeholder?: string;
// Called when the text changes.
readonly onInput?: (value: string, previousValue: string) => void;
// Called on close.
readonly onClose?: () => void;
// Dropdown items to show. If none are supplied, the omnibox runs in free text
// mode, where anyt text can be input. Otherwise, onSubmit will always be
// called with one of the options.
// Options are provided in groups called categories. If the category has a
// name the name will be listed at the top of the group rendered with a little
// divider as well.
readonly options?: OmniboxOption[];
// Called when the user expresses the intent to "execute" the thing.
readonly onSubmit?: (value: string, mod: boolean, shift: boolean) => void;
// Called when the user hits backspace when the field is empty.
readonly onGoBack?: () => void;
// When true, disable and grey-out the omnibox's input.
readonly readonly?: boolean;
// Ref to use on the input - useful for extracing this element from the DOM.
readonly inputRef?: string;
// Whether to close when the user presses Enter. Default = false.
readonly closeOnSubmit?: boolean;
// Whether to close the omnibox (i.e. call the |onClose| handler) when we
// click outside the omnibox or its dropdown. Default = false.
readonly closeOnOutsideClick?: boolean;
// Some content to place into the right hand side of the after the input.
readonly rightContent?: m.Children;
// If we have options, this value indicates the index of the option which
// is currently highlighted.
readonly selectedOptionIndex?: number;
// Callback for when the user pressed up/down, expressing a desire to change
// the |selectedOptionIndex|.
readonly onSelectedOptionChanged?: (index: number) => void;
}
class OmniboxWidget implements m.ClassComponent<OmniboxWidgetAttrs> {
private popupElement?: HTMLElement;
private dom?: Element;
private attrs?: OmniboxWidgetAttrs;
view({attrs}: m.Vnode<OmniboxWidgetAttrs>): m.Children {
const {
value,
placeholder,
onInput = () => {},
onSubmit = () => {},
onGoBack = () => {},
inputRef = 'omnibox',
options,
closeOnSubmit = false,
rightContent,
selectedOptionIndex = 0,
...htmlAttrs
} = attrs;
return m(
Popup,
{
onPopupMount: (dom: HTMLElement) => (this.popupElement = dom),
onPopupUnMount: (_dom: HTMLElement) => (this.popupElement = undefined),
isOpen: exists(options),
showArrow: false,
matchWidth: true,
offset: 2,
trigger: m(
'.pf-omnibox',
htmlAttrs,
m('input', {
spellcheck: false,
ref: inputRef,
value,
placeholder,
oninput: (e: Event) => {
onInput((e.target as HTMLInputElement).value, value);
},
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'Backspace' && value === '') {
onGoBack();
} else if (e.key === 'Escape') {
e.preventDefault();
this.close(attrs);
}
if (options) {
if (e.key === 'ArrowUp') {
e.preventDefault();
this.highlightPreviousOption(attrs);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.highlightNextOption(attrs);
} else if (e.key === 'Enter') {
e.preventDefault();
const option = options[selectedOptionIndex];
// Return values from indexing arrays can be undefined.
// We should enable noUncheckedIndexedAccess in
// tsconfig.json.
/* eslint-disable
@typescript-eslint/strict-boolean-expressions */
if (option) {
/* eslint-enable */
closeOnSubmit && this.close(attrs);
const mod = e.metaKey || e.ctrlKey;
const shift = e.shiftKey;
onSubmit(option.key, mod, shift);
}
}
} else {
if (e.key === 'Enter') {
e.preventDefault();
closeOnSubmit && this.close(attrs);
const mod = e.metaKey || e.ctrlKey;
const shift = e.shiftKey;
onSubmit(value, mod, shift);
}
}
},
}),
rightContent,
),
},
options && this.renderDropdown(attrs),
);
}
private renderDropdown(attrs: OmniboxWidgetAttrs): m.Children {
const {options} = attrs;
if (!options) return null;
if (options.length === 0) {
return m(EmptyState, {title: 'No matching options...'});
} else {
return m(
'.pf-omnibox-dropdown',
this.renderOptionsContainer(attrs, options),
this.renderFooter(),
);
}
}
private renderFooter() {
return m(
'.pf-omnibox-dropdown-footer',
m(
'section',
m(KeycapGlyph, {keyValue: 'ArrowUp'}),
m(KeycapGlyph, {keyValue: 'ArrowDown'}),
'to navigate',
),
m('section', m(KeycapGlyph, {keyValue: 'Enter'}), 'to use'),
m('section', m(KeycapGlyph, {keyValue: 'Escape'}), 'to dismiss'),
);
}
private renderOptionsContainer(
attrs: OmniboxWidgetAttrs,
options: OmniboxOption[],
): m.Children {
const {
onClose = () => {},
onSubmit = () => {},
closeOnSubmit = false,
selectedOptionIndex,
} = attrs;
const opts = options.map(({displayName, key, rightContent, tag}, index) => {
return m(OmniboxOptionRow, {
key,
label: tag,
displayName: displayName,
highlighted: index === selectedOptionIndex,
onclick: () => {
closeOnSubmit && onClose();
onSubmit(key, false, false);
},
rightContent,
});
});
return m('ul.pf-omnibox-options-container', opts);
}
oncreate({attrs, dom}: m.VnodeDOM<OmniboxWidgetAttrs>) {
this.attrs = attrs;
this.dom = dom;
const {closeOnOutsideClick} = attrs;
if (closeOnOutsideClick) {
document.addEventListener('mousedown', this.onMouseDown);
}
}
onupdate({attrs, dom}: m.VnodeDOM<OmniboxWidgetAttrs>) {
this.attrs = attrs;
this.dom = dom;
const {closeOnOutsideClick} = attrs;
if (closeOnOutsideClick) {
document.addEventListener('mousedown', this.onMouseDown);
} else {
document.removeEventListener('mousedown', this.onMouseDown);
}
}
onremove() {
this.attrs = undefined;
this.dom = undefined;
document.removeEventListener('mousedown', this.onMouseDown);
}
// This is defined as an arrow function to have a single handler that can be
// added/remove while keeping `this` bound.
private onMouseDown = (e: Event) => {
// We need to schedule a redraw manually as this event handler was added
// manually to the DOM and doesn't use Mithril's auto-redraw system.
raf.scheduleFullRedraw();
// Don't close if the click was within ourselves or our popup.
if (e.target instanceof Node) {
if (this.popupElement && this.popupElement.contains(e.target)) {
return;
}
if (this.dom && this.dom.contains(e.target)) return;
}
if (this.attrs) {
this.close(this.attrs);
}
};
private close(attrs: OmniboxWidgetAttrs): void {
const {onClose = () => {}} = attrs;
onClose();
}
private highlightPreviousOption(attrs: OmniboxWidgetAttrs) {
const {selectedOptionIndex = 0, onSelectedOptionChanged = () => {}} = attrs;
onSelectedOptionChanged(Math.max(0, selectedOptionIndex - 1));
}
private highlightNextOption(attrs: OmniboxWidgetAttrs) {
const {
selectedOptionIndex = 0,
onSelectedOptionChanged = () => {},
options = [],
} = attrs;
const max = options.length - 1;
onSelectedOptionChanged(Math.min(max, selectedOptionIndex + 1));
}
}