1. /**
    
  2.  * Copyright (c) Meta Platforms, Inc. and affiliates.
    
  3.  *
    
  4.  * This source code is licensed under the MIT license found in the
    
  5.  * LICENSE file in the root directory of this source tree.
    
  6.  *
    
  7.  * @flow
    
  8.  */
    
  9. 
    
  10. import {existsSync} from 'fs';
    
  11. import {basename, join, isAbsolute} from 'path';
    
  12. import {execSync, spawn} from 'child_process';
    
  13. import {parse} from 'shell-quote';
    
  14. 
    
  15. function isTerminalEditor(editor: string): boolean {
    
  16.   switch (editor) {
    
  17.     case 'vim':
    
  18.     case 'emacs':
    
  19.     case 'nano':
    
  20.       return true;
    
  21.     default:
    
  22.       return false;
    
  23.   }
    
  24. }
    
  25. 
    
  26. // Map from full process name to binary that starts the process
    
  27. // We can't just re-use full process name, because it will spawn a new instance
    
  28. // of the app every time
    
  29. const COMMON_EDITORS = {
    
  30.   '/Applications/Atom.app/Contents/MacOS/Atom': 'atom',
    
  31.   '/Applications/Atom Beta.app/Contents/MacOS/Atom Beta':
    
  32.     '/Applications/Atom Beta.app/Contents/MacOS/Atom Beta',
    
  33.   '/Applications/Sublime Text.app/Contents/MacOS/Sublime Text':
    
  34.     '/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl',
    
  35.   '/Applications/Sublime Text 2.app/Contents/MacOS/Sublime Text 2':
    
  36.     '/Applications/Sublime Text 2.app/Contents/SharedSupport/bin/subl',
    
  37.   '/Applications/Visual Studio Code.app/Contents/MacOS/Electron': 'code',
    
  38. };
    
  39. 
    
  40. function getArgumentsForLineNumber(
    
  41.   editor: string,
    
  42.   filePath: string,
    
  43.   lineNumber: number,
    
  44. ): Array<string> {
    
  45.   switch (basename(editor)) {
    
  46.     case 'vim':
    
  47.     case 'mvim':
    
  48.       return [filePath, '+' + lineNumber];
    
  49.     case 'atom':
    
  50.     case 'Atom':
    
  51.     case 'Atom Beta':
    
  52.     case 'subl':
    
  53.     case 'sublime':
    
  54.     case 'wstorm':
    
  55.     case 'appcode':
    
  56.     case 'charm':
    
  57.     case 'idea':
    
  58.       return [filePath + ':' + lineNumber];
    
  59.     case 'joe':
    
  60.     case 'emacs':
    
  61.     case 'emacsclient':
    
  62.       return ['+' + lineNumber, filePath];
    
  63.     case 'rmate':
    
  64.     case 'mate':
    
  65.     case 'mine':
    
  66.       return ['--line', lineNumber + '', filePath];
    
  67.     case 'code':
    
  68.       return ['-g', filePath + ':' + lineNumber];
    
  69.     default:
    
  70.       // For all others, drop the lineNumber until we have
    
  71.       // a mapping above, since providing the lineNumber incorrectly
    
  72.       // can result in errors or confusing behavior.
    
  73.       return [filePath];
    
  74.   }
    
  75. }
    
  76. 
    
  77. function guessEditor(): Array<string> {
    
  78.   // Explicit config always wins
    
  79.   if (process.env.REACT_EDITOR) {
    
  80.     return parse(process.env.REACT_EDITOR);
    
  81.   }
    
  82. 
    
  83.   // Using `ps x` on OSX we can find out which editor is currently running.
    
  84.   // Potentially we could use similar technique for Windows and Linux
    
  85.   if (process.platform === 'darwin') {
    
  86.     try {
    
  87.       const output = execSync('ps x').toString();
    
  88.       const processNames = Object.keys(COMMON_EDITORS);
    
  89.       for (let i = 0; i < processNames.length; i++) {
    
  90.         const processName = processNames[i];
    
  91.         if (output.indexOf(processName) !== -1) {
    
  92.           return [COMMON_EDITORS[processName]];
    
  93.         }
    
  94.       }
    
  95.     } catch (error) {
    
  96.       // Ignore...
    
  97.     }
    
  98.   }
    
  99. 
    
  100.   // Last resort, use old-school env vars
    
  101.   if (process.env.VISUAL) {
    
  102.     return [process.env.VISUAL];
    
  103.   } else if (process.env.EDITOR) {
    
  104.     return [process.env.EDITOR];
    
  105.   }
    
  106. 
    
  107.   return [];
    
  108. }
    
  109. 
    
  110. let childProcess = null;
    
  111. 
    
  112. export function getValidFilePath(
    
  113.   maybeRelativePath: string,
    
  114.   absoluteProjectRoots: Array<string>,
    
  115. ): string | null {
    
  116.   // We use relative paths at Facebook with deterministic builds.
    
  117.   // This is why our internal tooling calls React DevTools with absoluteProjectRoots.
    
  118.   // If the filename is absolute then we don't need to care about this.
    
  119.   if (isAbsolute(maybeRelativePath)) {
    
  120.     if (existsSync(maybeRelativePath)) {
    
  121.       return maybeRelativePath;
    
  122.     }
    
  123.   } else {
    
  124.     for (let i = 0; i < absoluteProjectRoots.length; i++) {
    
  125.       const projectRoot = absoluteProjectRoots[i];
    
  126.       const joinedPath = join(projectRoot, maybeRelativePath);
    
  127.       if (existsSync(joinedPath)) {
    
  128.         return joinedPath;
    
  129.       }
    
  130.     }
    
  131.   }
    
  132. 
    
  133.   return null;
    
  134. }
    
  135. 
    
  136. export function doesFilePathExist(
    
  137.   maybeRelativePath: string,
    
  138.   absoluteProjectRoots: Array<string>,
    
  139. ): boolean {
    
  140.   return getValidFilePath(maybeRelativePath, absoluteProjectRoots) !== null;
    
  141. }
    
  142. 
    
  143. export function launchEditor(
    
  144.   maybeRelativePath: string,
    
  145.   lineNumber: number,
    
  146.   absoluteProjectRoots: Array<string>,
    
  147. ) {
    
  148.   const filePath = getValidFilePath(maybeRelativePath, absoluteProjectRoots);
    
  149.   if (filePath === null) {
    
  150.     return;
    
  151.   }
    
  152. 
    
  153.   // Sanitize lineNumber to prevent malicious use on win32
    
  154.   // via: https://github.com/nodejs/node/blob/c3bb4b1aa5e907d489619fb43d233c3336bfc03d/lib/child_process.js#L333
    
  155.   if (lineNumber && isNaN(lineNumber)) {
    
  156.     return;
    
  157.   }
    
  158. 
    
  159.   const [editor, ...destructuredArgs] = guessEditor();
    
  160.   if (!editor) {
    
  161.     return;
    
  162.   }
    
  163. 
    
  164.   let args = destructuredArgs;
    
  165. 
    
  166.   if (lineNumber) {
    
  167.     args = args.concat(getArgumentsForLineNumber(editor, filePath, lineNumber));
    
  168.   } else {
    
  169.     args.push(filePath);
    
  170.   }
    
  171. 
    
  172.   if (childProcess && isTerminalEditor(editor)) {
    
  173.     // There's an existing editor process already and it's attached
    
  174.     // to the terminal, so go kill it. Otherwise two separate editor
    
  175.     // instances attach to the stdin/stdout which gets confusing.
    
  176.     // $FlowFixMe[incompatible-use] found when upgrading Flow
    
  177.     childProcess.kill('SIGKILL');
    
  178.   }
    
  179. 
    
  180.   if (process.platform === 'win32') {
    
  181.     // On Windows, launch the editor in a shell because spawn can only
    
  182.     // launch .exe files.
    
  183.     childProcess = spawn('cmd.exe', ['/C', editor].concat(args), {
    
  184.       stdio: 'inherit',
    
  185.     });
    
  186.   } else {
    
  187.     childProcess = spawn(editor, args, {stdio: 'inherit'});
    
  188.   }
    
  189.   childProcess.on('error', function () {});
    
  190.   // $FlowFixMe[incompatible-use] found when upgrading Flow
    
  191.   childProcess.on('exit', function () {
    
  192.     childProcess = null;
    
  193.   });
    
  194. }