| // Copyright (C) 2020 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 {EngineProxy} from '../common/engine'; |
| import {QueryResponse, runQuery} from '../common/queries'; |
| |
| import {addTab} from './bottom_tab'; |
| import {globals} from './globals'; |
| import {createPage} from './pages'; |
| import {QueryHistoryComponent, queryHistoryStorage} from './query_history'; |
| import {QueryResultTab} from './query_result_tab'; |
| import {QueryTable} from './query_table'; |
| |
| const INPUT_PLACEHOLDER = 'Enter query and press Cmd/Ctrl + Enter'; |
| const INPUT_MIN_LINES = 2; |
| const INPUT_MAX_LINES = 10; |
| const INPUT_LINE_HEIGHT_EM = 1.2; |
| const TAB_SPACES = 2; |
| const TAB_SPACES_STRING = ' '.repeat(TAB_SPACES); |
| |
| interface AnalyzePageState { |
| enteredText: string; |
| executedQuery?: string; |
| queryResult?: QueryResponse; |
| } |
| |
| const state: AnalyzePageState = { |
| enteredText: '', |
| }; |
| |
| export function runAnalyzeQuery(query: string) { |
| state.executedQuery = query; |
| state.queryResult = undefined; |
| const engine = getEngine(); |
| if (engine) { |
| runQuery(query, engine).then((resp: QueryResponse) => { |
| addTab({ |
| kind: QueryResultTab.kind, |
| tag: 'analyze_page_query', |
| config: { |
| query: query, |
| title: 'Standalone Query', |
| prefetchedResponse: resp, |
| }, |
| }); |
| // We might have started to execute another query. Ignore it in that case. |
| if (state.executedQuery !== query) { |
| return; |
| } |
| state.queryResult = resp; |
| globals.rafScheduler.scheduleFullRedraw(); |
| }); |
| } |
| } |
| |
| function getEngine(): EngineProxy|undefined { |
| const engineId = globals.getCurrentEngine()?.id; |
| if (engineId === undefined) { |
| return undefined; |
| } |
| const engine = globals.engines.get(engineId)?.getProxy('AnalyzePage'); |
| return engine; |
| } |
| |
| class QueryInput implements m.ClassComponent { |
| // How many lines to display if the user hasn't resized the input box. |
| displayLines = INPUT_MIN_LINES; |
| |
| static onKeyDown(e: Event) { |
| const event = e as KeyboardEvent; |
| const target = e.target as HTMLTextAreaElement; |
| const {selectionStart, selectionEnd} = target; |
| |
| if (event.code === 'Enter' && (event.metaKey || event.ctrlKey)) { |
| event.preventDefault(); |
| let query = target.value; |
| if (selectionEnd > selectionStart) { |
| query = query.substring(selectionStart, selectionEnd); |
| } |
| if (!query) return; |
| queryHistoryStorage.saveQuery(query); |
| |
| runAnalyzeQuery(query); |
| } |
| |
| if (event.code === 'Tab') { |
| // Handle tabs to insert spaces. |
| event.preventDefault(); |
| const lastLineBreak = target.value.lastIndexOf('\n', selectionEnd); |
| |
| if (selectionStart === selectionEnd || lastLineBreak < selectionStart) { |
| // Selection does not contain line breaks, therefore is on a single |
| // line. In this case, replace the selection with spaces. Replacement is |
| // done via document.execCommand as opposed to direct manipulation of |
| // element's value attribute because modifying latter programmatically |
| // drops the edit history which breaks undo/redo functionality. |
| document.execCommand('insertText', false, TAB_SPACES_STRING); |
| } else { |
| this.handleMultilineTab(target, event); |
| } |
| } |
| } |
| |
| // Handle Tab press when the current selection is multiline: find all the |
| // lines intersecting with the selection, and either indent or dedent (if |
| // Shift key is held) them. |
| private static handleMultilineTab( |
| target: HTMLTextAreaElement, event: KeyboardEvent) { |
| const {selectionStart, selectionEnd} = target; |
| const firstLineBreak = target.value.lastIndexOf('\n', selectionStart - 1); |
| |
| // If no line break is found (selection begins at the first line), |
| // replacementStart would have the correct value of 0. |
| const replacementStart = firstLineBreak + 1; |
| const replacement = target.value.substring(replacementStart, selectionEnd) |
| .split('\n') |
| .map((line) => { |
| if (event.shiftKey) { |
| // When Shift is held, remove whitespace at the |
| // beginning |
| return this.dedent(line); |
| } else { |
| return TAB_SPACES_STRING + line; |
| } |
| }) |
| .join('\n'); |
| // Select the range to be replaced. |
| target.setSelectionRange(replacementStart, selectionEnd); |
| document.execCommand('insertText', false, replacement); |
| // Restore the selection to match the previous selection, allowing to chain |
| // indent operations by just pressing Tab several times. |
| target.setSelectionRange( |
| replacementStart, replacementStart + replacement.length); |
| } |
| |
| // Chop off up to TAB_SPACES leading spaces from a string. |
| private static dedent(line: string): string { |
| let i = 0; |
| while (i < line.length && i < TAB_SPACES && line[i] === ' ') { |
| i++; |
| } |
| return line.substring(i); |
| } |
| |
| onInput(textareaValue: string) { |
| const textareaLines = textareaValue.split('\n').length; |
| const clampedNumLines = |
| Math.min(Math.max(textareaLines, INPUT_MIN_LINES), INPUT_MAX_LINES); |
| this.displayLines = clampedNumLines; |
| state.enteredText = textareaValue; |
| globals.rafScheduler.scheduleFullRedraw(); |
| } |
| |
| // This method exists because unfortunatley setting custom properties on an |
| // element's inline style attribue doesn't seem to work in mithril, even |
| // though the docs claim so. |
| setHeightBeforeResize(node: HTMLElement) { |
| // +2em for some extra breathing space to account for padding. |
| const heightEm = this.displayLines * INPUT_LINE_HEIGHT_EM + 2; |
| // We set a height based on the number of lines that we want to display by |
| // default. If the user resizes the textbox using the resize handle in the |
| // bottom-right corner, this height is overridden. |
| node.style.setProperty('--height-before-resize', `${heightEm}em`); |
| // TODO(dproy): The resized height is lost if user navigates away from the |
| // page and comes back. |
| } |
| |
| oncreate(vnode: m.VnodeDOM) { |
| // This makes sure query persists if user navigates to other pages and comes |
| // back to analyze page. |
| const existingQuery = state.enteredText; |
| const textarea = vnode.dom as HTMLTextAreaElement; |
| if (existingQuery) { |
| textarea.value = existingQuery; |
| this.onInput(existingQuery); |
| } |
| |
| this.setHeightBeforeResize(textarea); |
| } |
| |
| onupdate(vnode: m.VnodeDOM) { |
| this.setHeightBeforeResize(vnode.dom as HTMLElement); |
| } |
| |
| view() { |
| return m('textarea.query-input', { |
| placeholder: INPUT_PLACEHOLDER, |
| onkeydown: (e: Event) => QueryInput.onKeyDown(e), |
| oninput: (e: Event) => |
| this.onInput((e.target as HTMLTextAreaElement).value), |
| }); |
| } |
| } |
| |
| |
| export const AnalyzePage = createPage({ |
| view() { |
| return m( |
| '.analyze-page', |
| m(QueryInput), |
| state.executedQuery === undefined ? null : m(QueryTable, { |
| query: state.executedQuery, |
| resp: state.queryResult, |
| onClose: () => { |
| state.executedQuery = undefined; |
| state.queryResult = undefined; |
| globals.rafScheduler.scheduleFullRedraw(); |
| }, |
| }), |
| m(QueryHistoryComponent)); |
| }, |
| }); |