Outline.js (10204B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ 4 5 import React, { Component } from "devtools/client/shared/vendor/react"; 6 import { 7 div, 8 ul, 9 li, 10 span, 11 h2, 12 button, 13 } from "devtools/client/shared/vendor/react-dom-factories"; 14 import PropTypes from "devtools/client/shared/vendor/react-prop-types"; 15 import { connect } from "devtools/client/shared/vendor/react-redux"; 16 17 import { containsPosition, positionAfter } from "../../utils/ast"; 18 import { createLocation } from "../../utils/location"; 19 20 import actions from "../../actions/index"; 21 import { 22 getSelectedLocation, 23 getSelectedSourceTextContent, 24 } from "../../selectors/index"; 25 26 import OutlineFilter from "./OutlineFilter"; 27 import PreviewFunction from "../shared/PreviewFunction"; 28 29 import { isFulfilled } from "../../utils/async-value"; 30 31 const classnames = require("resource://devtools/client/shared/classnames.js"); 32 const { 33 score: fuzzaldrinScore, 34 } = require("resource://devtools/client/shared/vendor/fuzzaldrin-plus.js"); 35 36 // Set higher to make the fuzzaldrin filter more specific 37 const FUZZALDRIN_FILTER_THRESHOLD = 15000; 38 39 /** 40 * Check whether the name argument matches the fuzzy filter argument 41 */ 42 const filterOutlineItem = (name, filter) => { 43 if (!filter) { 44 return true; 45 } 46 47 if (filter.length === 1) { 48 // when filter is a single char just check if it starts with the char 49 return filter.toLowerCase() === name.toLowerCase()[0]; 50 } 51 return fuzzaldrinScore(name, filter) > FUZZALDRIN_FILTER_THRESHOLD; 52 }; 53 54 // Checks if an element is visible inside its parent element 55 function isVisible(element, parent) { 56 const parentRect = parent.getBoundingClientRect(); 57 const elementRect = element.getBoundingClientRect(); 58 59 const parentTop = parentRect.top; 60 const parentBottom = parentRect.bottom; 61 const elTop = elementRect.top; 62 const elBottom = elementRect.bottom; 63 64 return parentTop < elTop && parentBottom > elBottom; 65 } 66 67 export class Outline extends Component { 68 constructor(props) { 69 super(props); 70 this.focusedElRef = null; 71 this.state = { filter: "", focusedItem: null, symbols: null }; 72 } 73 74 static get propTypes() { 75 return { 76 alphabetizeOutline: PropTypes.bool.isRequired, 77 onAlphabetizeClick: PropTypes.func.isRequired, 78 selectLocation: PropTypes.func.isRequired, 79 selectedLocation: PropTypes.object, 80 getFunctionSymbols: PropTypes.func.isRequired, 81 getClassSymbols: PropTypes.func.isRequired, 82 selectedSourceTextContent: PropTypes.object, 83 canFetchSymbols: PropTypes.bool, 84 }; 85 } 86 87 componentDidMount() { 88 if (!this.props.canFetchSymbols) { 89 return; 90 } 91 this.getClassAndFunctionSymbols(); 92 } 93 94 componentDidUpdate(prevProps) { 95 const { selectedLocation, selectedSourceTextContent, canFetchSymbols } = 96 this.props; 97 if (selectedLocation && selectedLocation !== prevProps.selectedLocation) { 98 this.setFocus(selectedLocation); 99 } 100 101 if ( 102 this.focusedElRef && 103 !isVisible(this.focusedElRef, this.refs.outlineList) 104 ) { 105 this.focusedElRef.scrollIntoView({ block: "center" }); 106 } 107 108 // Lets make sure the source text has been loaded and it is different 109 if ( 110 canFetchSymbols && 111 prevProps.selectedSourceTextContent !== selectedSourceTextContent 112 ) { 113 this.getClassAndFunctionSymbols(); 114 } 115 } 116 117 async getClassAndFunctionSymbols() { 118 const { selectedLocation, getFunctionSymbols, getClassSymbols } = 119 this.props; 120 121 const functions = await getFunctionSymbols(selectedLocation); 122 const classes = await getClassSymbols(selectedLocation); 123 124 this.setState({ symbols: { functions, classes } }); 125 } 126 127 async setFocus(selectedLocation) { 128 const { symbols } = this.state; 129 130 let classes = []; 131 let functions = []; 132 133 if (symbols) { 134 ({ classes, functions } = symbols); 135 } 136 137 // Find items that enclose the selected location 138 const enclosedItems = [...classes, ...functions].filter(({ location }) => 139 containsPosition(location, selectedLocation) 140 ); 141 142 if (!enclosedItems.length) { 143 this.setState({ focusedItem: null }); 144 return; 145 } 146 147 // Find the closest item to the selected location to focus 148 const closestItem = enclosedItems.reduce((item, closest) => 149 positionAfter(item.location, closest.location) ? item : closest 150 ); 151 152 this.setState({ focusedItem: closestItem }); 153 } 154 155 selectItem(selectedItem) { 156 const { selectedLocation, selectLocation } = this.props; 157 if (!selectedLocation || !selectedItem) { 158 return; 159 } 160 161 selectLocation( 162 createLocation({ 163 source: selectedLocation.source, 164 line: selectedItem.location.start.line, 165 column: selectedItem.location.start.column, 166 }) 167 ); 168 169 this.setState({ focusedItem: selectedItem }); 170 } 171 172 onContextMenu(event, func) { 173 event.stopPropagation(); 174 event.preventDefault(); 175 176 const { symbols } = this.state; 177 this.props.showOutlineContextMenu(event, func, symbols); 178 } 179 180 updateFilter = filter => { 181 this.setState({ filter: filter.trim() }); 182 }; 183 184 renderPlaceholder() { 185 const placeholderMessage = this.props.selectedLocation 186 ? L10N.getStr("outline.noFunctions") 187 : L10N.getStr("outline.noFileSelected"); 188 return div( 189 { 190 className: "outline-pane-info", 191 }, 192 placeholderMessage 193 ); 194 } 195 196 renderLoading() { 197 return div( 198 { 199 className: "outline-pane-info", 200 }, 201 L10N.getStr("loadingText") 202 ); 203 } 204 205 renderFunction(func) { 206 const { focusedItem } = this.state; 207 const { name, location, parameterNames } = func; 208 const isFocused = focusedItem === func; 209 return li( 210 { 211 key: `${name}:${location.start.line}:${location.start.column}`, 212 className: classnames("outline-list__element", { 213 focused: isFocused, 214 }), 215 ref: el => { 216 if (isFocused) { 217 this.focusedElRef = el; 218 } 219 }, 220 onClick: () => this.selectItem(func), 221 onContextMenu: e => this.onContextMenu(e, func), 222 }, 223 span( 224 { 225 className: "outline-list__element-icon", 226 }, 227 "λ" 228 ), 229 React.createElement(PreviewFunction, { 230 func: { 231 name, 232 parameterNames, 233 }, 234 }) 235 ); 236 } 237 238 renderClassHeader(klass) { 239 return div( 240 null, 241 span( 242 { 243 className: "keyword", 244 }, 245 "class" 246 ), 247 " ", 248 klass 249 ); 250 } 251 252 renderClassFunctions(klass, functions) { 253 const { symbols } = this.state; 254 255 if (!symbols || klass == null || !functions.length) { 256 return null; 257 } 258 259 const { focusedItem } = this.state; 260 const classFunc = functions.find(func => func.name === klass); 261 const classFunctions = functions.filter(func => func.klass === klass); 262 const classInfo = symbols.classes.find(c => c.name === klass); 263 264 const item = classFunc || classInfo; 265 const isFocused = focusedItem === item; 266 267 return li( 268 { 269 className: "outline-list__class", 270 ref: el => { 271 if (isFocused) { 272 this.focusedElRef = el; 273 } 274 }, 275 key: klass, 276 }, 277 h2( 278 { 279 className: classnames({ 280 focused: isFocused, 281 }), 282 onClick: () => this.selectItem(item), 283 }, 284 classFunc 285 ? this.renderFunction(classFunc) 286 : this.renderClassHeader(klass) 287 ), 288 ul( 289 { 290 className: "outline-list__class-list", 291 }, 292 classFunctions.map(func => this.renderFunction(func)) 293 ) 294 ); 295 } 296 297 renderFunctions(functions) { 298 const { filter } = this.state; 299 let classes = [...new Set(functions.map(({ klass }) => klass))]; 300 const namedFunctions = functions.filter( 301 ({ name, klass }) => 302 filterOutlineItem(name, filter) && !klass && !classes.includes(name) 303 ); 304 const classFunctions = functions.filter( 305 ({ name, klass }) => filterOutlineItem(name, filter) && !!klass 306 ); 307 308 if (this.props.alphabetizeOutline) { 309 const sortByName = (a, b) => (a.name < b.name ? -1 : 1); 310 namedFunctions.sort(sortByName); 311 classes = classes.sort(); 312 classFunctions.sort(sortByName); 313 } 314 return ul( 315 { 316 ref: "outlineList", 317 className: "outline-list devtools-monospace", 318 dir: "ltr", 319 }, 320 namedFunctions.map(func => this.renderFunction(func)), 321 classes.map(klass => this.renderClassFunctions(klass, classFunctions)) 322 ); 323 } 324 325 renderFooter() { 326 return div( 327 { 328 className: "outline-footer", 329 }, 330 button( 331 { 332 onClick: this.props.onAlphabetizeClick, 333 className: this.props.alphabetizeOutline ? "active" : "", 334 }, 335 L10N.getStr("outline.sortLabel") 336 ) 337 ); 338 } 339 340 render() { 341 const { selectedLocation } = this.props; 342 const { filter, symbols } = this.state; 343 344 if (!selectedLocation) { 345 return this.renderPlaceholder(); 346 } 347 348 if (!symbols) { 349 return this.renderLoading(); 350 } 351 352 const { functions } = symbols; 353 354 if (functions.length === 0) { 355 return this.renderPlaceholder(); 356 } 357 358 return div( 359 { 360 className: "outline", 361 }, 362 div( 363 null, 364 React.createElement(OutlineFilter, { 365 filter, 366 updateFilter: this.updateFilter, 367 }), 368 this.renderFunctions(functions), 369 this.renderFooter() 370 ) 371 ); 372 } 373 } 374 375 const mapStateToProps = state => { 376 const selectedSourceTextContent = getSelectedSourceTextContent(state); 377 return { 378 selectedSourceTextContent, 379 selectedLocation: getSelectedLocation(state), 380 canFetchSymbols: 381 selectedSourceTextContent && isFulfilled(selectedSourceTextContent), 382 }; 383 }; 384 385 export default connect(mapStateToProps, { 386 selectLocation: actions.selectLocation, 387 showOutlineContextMenu: actions.showOutlineContextMenu, 388 getFunctionSymbols: actions.getFunctionSymbols, 389 getClassSymbols: actions.getClassSymbols, 390 })(Outline);