diff --git a/frontend/Panel.js b/frontend/Panel.js index 8a2016f929..8eb128848a 100644 --- a/frontend/Panel.js +++ b/frontend/Panel.js @@ -36,7 +36,7 @@ export type Props = { reload?: () => void, // optionals - showComponentSource?: () => void, + showComponentSource?: (vbl: string, source?: Object) => void, reloadSubscribe?: (reloadFn: () => void) => () => void, showAttrSource?: (path: Array) => void, executeFn?: (path: Array) => void, @@ -146,14 +146,14 @@ class Panel extends React.Component { this.props.showComponentSource(vbl || '$r'); } - viewSource(id: string) { + viewSource(id: string, node?: Object) { if (!this._bridge) { return; } this._bridge.send('putSelectedInstance', id); setTimeout(() => { invariant(this.props.showComponentSource, 'cannot view source if props.showComponentSource is not supplied'); - this.props.showComponentSource('__REACT_DEVTOOLS_GLOBAL_HOOK__.$inst'); + this.props.showComponentSource('__REACT_DEVTOOLS_GLOBAL_HOOK__.$inst', node && node.get('source')); }, 100); } @@ -265,7 +265,7 @@ class Panel extends React.Component { return [this.props.showComponentSource && node.get('nodeType') === 'Composite' && { key: 'showSource', title: 'Show Source', - action: () => this.viewSource(id), + action: () => this.viewSource(id, node), }, this.props.selectElement && this._store.capabilities.dom && { key: 'showInElementsPane', title: 'Show in Elements Pane', diff --git a/packages/react-devtools-core/package.json b/packages/react-devtools-core/package.json index 271d7fce1e..a356a01a4a 100644 --- a/packages/react-devtools-core/package.json +++ b/packages/react-devtools-core/package.json @@ -24,6 +24,7 @@ "author": "Jared Forsyth", "license": "BSD-3-Clause", "dependencies": { + "shell-quote": "^1.6.1", "ws": "^2.0.3" }, "devDependencies": { diff --git a/packages/react-devtools-core/src/launchEditor.js b/packages/react-devtools-core/src/launchEditor.js new file mode 100644 index 0000000000..7277c83bcc --- /dev/null +++ b/packages/react-devtools-core/src/launchEditor.js @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +var fs = require('fs'); +var path = require('path'); +var child_process = require('child_process'); +var shellQuote = require('shell-quote'); + +function isTerminalEditor(editor) { + switch (editor) { + case 'vim': + case 'emacs': + case 'nano': + return true; + } + return false; +} + +// Map from full process name to binary that starts the process +// We can't just re-use full process name, because it will spawn a new instance +// of the app every time +var COMMON_EDITORS = { + '/Applications/Atom.app/Contents/MacOS/Atom': 'atom', + '/Applications/Atom Beta.app/Contents/MacOS/Atom Beta': + '/Applications/Atom Beta.app/Contents/MacOS/Atom Beta', + '/Applications/Sublime Text.app/Contents/MacOS/Sublime Text': + '/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl', + '/Applications/Sublime Text 2.app/Contents/MacOS/Sublime Text 2': + '/Applications/Sublime Text 2.app/Contents/SharedSupport/bin/subl', + '/Applications/Visual Studio Code.app/Contents/MacOS/Electron': 'code', +}; + +function getArgumentsForLineNumber(editor, filePath, lineNumber) { + switch (path.basename(editor)) { + case 'vim': + case 'mvim': + return [filePath, '+' + lineNumber]; + case 'atom': + case 'Atom': + case 'Atom Beta': + case 'subl': + case 'sublime': + case 'wstorm': + case 'appcode': + case 'charm': + case 'idea': + return [filePath + ':' + lineNumber]; + case 'joe': + case 'emacs': + case 'emacsclient': + return ['+' + lineNumber, filePath]; + case 'rmate': + case 'mate': + case 'mine': + return ['--line', lineNumber, filePath]; + case 'code': + return ['-g', filePath + ':' + lineNumber]; + } + + // For all others, drop the lineNumber until we have + // a mapping above, since providing the lineNumber incorrectly + // can result in errors or confusing behavior. + return [filePath]; +} + +function guessEditor() { + // Explicit config always wins + if (process.env.REACT_EDITOR) { + return shellQuote.parse(process.env.REACT_EDITOR); + } + + // Using `ps x` on OSX we can find out which editor is currently running. + // Potentially we could use similar technique for Windows and Linux + if (process.platform === 'darwin') { + try { + var output = child_process.execSync('ps x').toString(); + var processNames = Object.keys(COMMON_EDITORS); + for (var i = 0; i < processNames.length; i++) { + var processName = processNames[i]; + if (output.indexOf(processName) !== -1) { + return [COMMON_EDITORS[processName]]; + } + } + } catch (error) { + // Ignore... + } + } + + // Last resort, use old skool env vars + if (process.env.VISUAL) { + return [process.env.VISUAL]; + } else if (process.env.EDITOR) { + return [process.env.EDITOR]; + } + + return null; +} + +var _childProcess = null; +function launchEditor(filePath, lineNumber) { + if (!fs.existsSync(filePath)) { + return; + } + + // Sanitize lineNumber to prevent malicious use on win32 + // via: https://github.com/nodejs/node/blob/c3bb4b1aa5e907d489619fb43d233c3336bfc03d/lib/child_process.js#L333 + if (lineNumber && isNaN(lineNumber)) { + return; + } + + var [editor, ...args] = guessEditor(); + if (!editor) { + return; + } + + if (lineNumber) { + args = args.concat(getArgumentsForLineNumber(editor, filePath, lineNumber)); + } else { + args.push(filePath); + } + + if (_childProcess && isTerminalEditor(editor)) { + // There's an existing editor process already and it's attached + // to the terminal, so go kill it. Otherwise two separate editor + // instances attach to the stdin/stdout which gets confusing. + _childProcess.kill('SIGKILL'); + } + + if (process.platform === 'win32') { + // On Windows, launch the editor in a shell because spawn can only + // launch .exe files. + _childProcess = child_process.spawn('cmd.exe', ['/C', editor].concat(args), {stdio: 'inherit'}); + } else { + _childProcess = child_process.spawn(editor, args, {stdio: 'inherit'}); + } + _childProcess.on('error', function() {}); + _childProcess.on('exit', function(errorCode) { + _childProcess = null; + }); +} + +module.exports = launchEditor; diff --git a/packages/react-devtools-core/src/standalone.js b/packages/react-devtools-core/src/standalone.js index e80b213e3f..f360b699d8 100644 --- a/packages/react-devtools-core/src/standalone.js +++ b/packages/react-devtools-core/src/standalone.js @@ -16,6 +16,7 @@ var path = require('path'); var installGlobalHook = require('../../../backend/installGlobalHook'); installGlobalHook(window); var Panel = require('../../../frontend/Panel'); +var launchEditor = require('./launchEditor'); var React = require('react'); var ReactDOM = require('react-dom'); @@ -29,6 +30,12 @@ var config = { inject(done) { done(wall); }, + showComponentSource(vbl, source) { + if (!source) { + return; + } + launchEditor(source.fileName, source.lineNumber); + }, }; var log = (...args) => console.log('[React DevTools]', ...args); diff --git a/packages/react-devtools-core/yarn.lock b/packages/react-devtools-core/yarn.lock index 3eae569c81..afb1440323 100644 --- a/packages/react-devtools-core/yarn.lock +++ b/packages/react-devtools-core/yarn.lock @@ -2,6 +2,18 @@ # yarn lockfile v1 +array-filter@~0.0.0: + version "0.0.1" + resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec" + +array-map@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662" + +array-reduce@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b" + cross-env@^3.1.4: version "3.1.4" resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-3.1.4.tgz#56e8bca96f17908a6eb1bc2012ca126f92842130" @@ -19,6 +31,10 @@ isexe@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/isexe/-/isexe-1.1.2.tgz#36f3e22e60750920f5e7241a476a8c6a42275ad0" +jsonify@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" + lru-cache@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.0.2.tgz#1d17679c069cda5d040991a09dbc2c0db377e55e" @@ -30,6 +46,15 @@ pseudomap@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" +shell-quote@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767" + dependencies: + array-filter "~0.0.0" + array-map "~0.0.0" + array-reduce "~0.0.0" + jsonify "~0.0.0" + ultron@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.0.tgz#b07a2e6a541a815fc6a34ccd4533baec307ca864"