1// Copyright (C) 2020 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15 16import m from 'mithril'; 17 18import {EngineProxy} from '../common/engine'; 19import {QueryResponse, runQuery} from '../common/queries'; 20 21import {addTab} from './bottom_tab'; 22import {globals} from './globals'; 23import {createPage} from './pages'; 24import {QueryHistoryComponent, queryHistoryStorage} from './query_history'; 25import {QueryResultTab} from './query_result_tab'; 26import {QueryTable} from './query_table'; 27 28const INPUT_PLACEHOLDER = 'Enter query and press Cmd/Ctrl + Enter'; 29const INPUT_MIN_LINES = 2; 30const INPUT_MAX_LINES = 10; 31const INPUT_LINE_HEIGHT_EM = 1.2; 32const TAB_SPACES = 2; 33const TAB_SPACES_STRING = ' '.repeat(TAB_SPACES); 34 35interface AnalyzePageState { 36 enteredText: string; 37 executedQuery?: string; 38 queryResult?: QueryResponse; 39} 40 41const state: AnalyzePageState = { 42 enteredText: '', 43}; 44 45export function runAnalyzeQuery(query: string) { 46 state.executedQuery = query; 47 state.queryResult = undefined; 48 const engine = getEngine(); 49 if (engine) { 50 runQuery(query, engine).then((resp: QueryResponse) => { 51 addTab({ 52 kind: QueryResultTab.kind, 53 tag: 'analyze_page_query', 54 config: { 55 query: query, 56 title: 'Standalone Query', 57 prefetchedResponse: resp, 58 }, 59 }); 60 // We might have started to execute another query. Ignore it in that case. 61 if (state.executedQuery !== query) { 62 return; 63 } 64 state.queryResult = resp; 65 globals.rafScheduler.scheduleFullRedraw(); 66 }); 67 } 68} 69 70function getEngine(): EngineProxy|undefined { 71 const engineId = globals.getCurrentEngine()?.id; 72 if (engineId === undefined) { 73 return undefined; 74 } 75 const engine = globals.engines.get(engineId)?.getProxy('AnalyzePage'); 76 return engine; 77} 78 79class QueryInput implements m.ClassComponent { 80 // How many lines to display if the user hasn't resized the input box. 81 displayLines = INPUT_MIN_LINES; 82 83 static onKeyDown(e: Event) { 84 const event = e as KeyboardEvent; 85 const target = e.target as HTMLTextAreaElement; 86 const {selectionStart, selectionEnd} = target; 87 88 if (event.code === 'Enter' && (event.metaKey || event.ctrlKey)) { 89 event.preventDefault(); 90 let query = target.value; 91 if (selectionEnd > selectionStart) { 92 query = query.substring(selectionStart, selectionEnd); 93 } 94 if (!query) return; 95 queryHistoryStorage.saveQuery(query); 96 97 runAnalyzeQuery(query); 98 } 99 100 if (event.code === 'Tab') { 101 // Handle tabs to insert spaces. 102 event.preventDefault(); 103 const lastLineBreak = target.value.lastIndexOf('\n', selectionEnd); 104 105 if (selectionStart === selectionEnd || lastLineBreak < selectionStart) { 106 // Selection does not contain line breaks, therefore is on a single 107 // line. In this case, replace the selection with spaces. Replacement is 108 // done via document.execCommand as opposed to direct manipulation of 109 // element's value attribute because modifying latter programmatically 110 // drops the edit history which breaks undo/redo functionality. 111 document.execCommand('insertText', false, TAB_SPACES_STRING); 112 } else { 113 this.handleMultilineTab(target, event); 114 } 115 } 116 } 117 118 // Handle Tab press when the current selection is multiline: find all the 119 // lines intersecting with the selection, and either indent or dedent (if 120 // Shift key is held) them. 121 private static handleMultilineTab( 122 target: HTMLTextAreaElement, event: KeyboardEvent) { 123 const {selectionStart, selectionEnd} = target; 124 const firstLineBreak = target.value.lastIndexOf('\n', selectionStart - 1); 125 126 // If no line break is found (selection begins at the first line), 127 // replacementStart would have the correct value of 0. 128 const replacementStart = firstLineBreak + 1; 129 const replacement = target.value.substring(replacementStart, selectionEnd) 130 .split('\n') 131 .map((line) => { 132 if (event.shiftKey) { 133 // When Shift is held, remove whitespace at the 134 // beginning 135 return this.dedent(line); 136 } else { 137 return TAB_SPACES_STRING + line; 138 } 139 }) 140 .join('\n'); 141 // Select the range to be replaced. 142 target.setSelectionRange(replacementStart, selectionEnd); 143 document.execCommand('insertText', false, replacement); 144 // Restore the selection to match the previous selection, allowing to chain 145 // indent operations by just pressing Tab several times. 146 target.setSelectionRange( 147 replacementStart, replacementStart + replacement.length); 148 } 149 150 // Chop off up to TAB_SPACES leading spaces from a string. 151 private static dedent(line: string): string { 152 let i = 0; 153 while (i < line.length && i < TAB_SPACES && line[i] === ' ') { 154 i++; 155 } 156 return line.substring(i); 157 } 158 159 onInput(textareaValue: string) { 160 const textareaLines = textareaValue.split('\n').length; 161 const clampedNumLines = 162 Math.min(Math.max(textareaLines, INPUT_MIN_LINES), INPUT_MAX_LINES); 163 this.displayLines = clampedNumLines; 164 state.enteredText = textareaValue; 165 globals.rafScheduler.scheduleFullRedraw(); 166 } 167 168 // This method exists because unfortunatley setting custom properties on an 169 // element's inline style attribue doesn't seem to work in mithril, even 170 // though the docs claim so. 171 setHeightBeforeResize(node: HTMLElement) { 172 // +2em for some extra breathing space to account for padding. 173 const heightEm = this.displayLines * INPUT_LINE_HEIGHT_EM + 2; 174 // We set a height based on the number of lines that we want to display by 175 // default. If the user resizes the textbox using the resize handle in the 176 // bottom-right corner, this height is overridden. 177 node.style.setProperty('--height-before-resize', `${heightEm}em`); 178 // TODO(dproy): The resized height is lost if user navigates away from the 179 // page and comes back. 180 } 181 182 oncreate(vnode: m.VnodeDOM) { 183 // This makes sure query persists if user navigates to other pages and comes 184 // back to analyze page. 185 const existingQuery = state.enteredText; 186 const textarea = vnode.dom as HTMLTextAreaElement; 187 if (existingQuery) { 188 textarea.value = existingQuery; 189 this.onInput(existingQuery); 190 } 191 192 this.setHeightBeforeResize(textarea); 193 } 194 195 onupdate(vnode: m.VnodeDOM) { 196 this.setHeightBeforeResize(vnode.dom as HTMLElement); 197 } 198 199 view() { 200 return m('textarea.query-input', { 201 placeholder: INPUT_PLACEHOLDER, 202 onkeydown: (e: Event) => QueryInput.onKeyDown(e), 203 oninput: (e: Event) => 204 this.onInput((e.target as HTMLTextAreaElement).value), 205 }); 206 } 207} 208 209 210export const AnalyzePage = createPage({ 211 view() { 212 return m( 213 '.analyze-page', 214 m(QueryInput), 215 state.executedQuery === undefined ? null : m(QueryTable, { 216 query: state.executedQuery, 217 resp: state.queryResult, 218 onClose: () => { 219 state.executedQuery = undefined; 220 state.queryResult = undefined; 221 globals.rafScheduler.scheduleFullRedraw(); 222 }, 223 }), 224 m(QueryHistoryComponent)); 225 }, 226}); 227