From e8f7148c5d6ca71acf65b62848f05be5321f0d88 Mon Sep 17 00:00:00 2001 From: szymonrybczak Date: Tue, 1 Aug 2023 17:12:43 +0200 Subject: [PATCH 1/3] feat: enable pausing handling keystrokes in watch mode --- .../src/commands/start/watchMode.ts | 73 +++++++++++-------- .../src/tools/KeyPressHandler.ts | 73 +++++++++++++++++++ packages/cli-tools/src/index.ts | 1 + packages/cli-tools/src/prompt.ts | 53 ++++++++++++++ 4 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 packages/cli-plugin-metro/src/tools/KeyPressHandler.ts create mode 100644 packages/cli-tools/src/prompt.ts diff --git a/packages/cli-plugin-metro/src/commands/start/watchMode.ts b/packages/cli-plugin-metro/src/commands/start/watchMode.ts index 4fded9011..4b52c862b 100644 --- a/packages/cli-plugin-metro/src/commands/start/watchMode.ts +++ b/packages/cli-plugin-metro/src/commands/start/watchMode.ts @@ -3,6 +3,11 @@ import {logger, hookStdout} from '@react-native-community/cli-tools'; import execa from 'execa'; import chalk from 'chalk'; import {Config} from '@react-native-community/cli-types'; +import {KeyPressHandler} from '../../tools/KeyPressHandler'; +import {addInteractionListener} from '@react-native-community/cli-tools'; + +const CTRL_C = '\u0003'; +const CTRL_Z = '\u0026'; function printWatchModeInstructions() { logger.log( @@ -37,38 +42,44 @@ function enableWatchMode(messageSocket: any, ctx: Config) { } }); - process.stdin.on('keypress', (_key, data) => { - const {ctrl, name} = data; - if (ctrl === true) { - switch (name) { - case 'c': - process.exit(); - break; - case 'z': - process.emit('SIGTSTP', 'SIGTSTP'); - break; - } - } else if (name === 'r') { - messageSocket.broadcast('reload', null); - logger.info('Reloading app...'); - } else if (name === 'd') { - messageSocket.broadcast('devMenu', null); - logger.info('Opening developer menu...'); - } else if (name === 'i' || name === 'a') { - logger.info(`Opening the app on ${name === 'i' ? 'iOS' : 'Android'}...`); - const params = - name === 'i' - ? ctx.project.ios?.watchModeCommandParams - : ctx.project.android?.watchModeCommandParams; - execa('npx', [ - 'react-native', - name === 'i' ? 'run-ios' : 'run-android', - ...(params ?? []), - ]).stdout?.pipe(process.stdout); - } else { - console.log(_key); + const onPressAsync = async (key: string) => { + switch (key) { + case 'r': + messageSocket.broadcast('reload', null); + logger.info('Reloading app...'); + break; + case 'd': + messageSocket.broadcast('devMenu', null); + logger.info('Opening Dev Menu...'); + break; + case 'i': + logger.info('Opening app on iOS...'); + execa('npx', [ + 'react-native', + 'run-ios', + ...(ctx.project.ios?.watchModeCommandParams ?? []), + ]).stdout?.pipe(process.stdout); + break; + case 'a': + logger.info('Opening app on Android...'); + execa('npx', [ + 'react-native', + 'run-android', + ...(ctx.project.android?.watchModeCommandParams ?? []), + ]).stdout?.pipe(process.stdout); + break; + case CTRL_Z: + process.emit('SIGTSTP', 'SIGTSTP'); + break; + case CTRL_C: + process.exit(); } - }); + }; + + const keyPressHandler = new KeyPressHandler(onPressAsync); + const listener = keyPressHandler.createInteractionListener(); + addInteractionListener(listener); + keyPressHandler.startInterceptingKeyStrokes(); } export default enableWatchMode; diff --git a/packages/cli-plugin-metro/src/tools/KeyPressHandler.ts b/packages/cli-plugin-metro/src/tools/KeyPressHandler.ts new file mode 100644 index 000000000..18932b391 --- /dev/null +++ b/packages/cli-plugin-metro/src/tools/KeyPressHandler.ts @@ -0,0 +1,73 @@ +import {CLIError, logger} from '@react-native-community/cli-tools'; + +const CTRL_C = '\u0003'; + +/** An abstract key stroke interceptor. */ +export class KeyPressHandler { + private isInterceptingKeyStrokes = false; + private isHandlingKeyPress = false; + + constructor(public onPress: (key: string) => Promise) {} + + /** Start observing interaction pause listeners. */ + createInteractionListener() { + // Support observing prompts. + let wasIntercepting = false; + + const listener = ({pause}: {pause: boolean}) => { + if (pause) { + // Track if we were already intercepting key strokes before pausing, so we can + // resume after pausing. + wasIntercepting = this.isInterceptingKeyStrokes; + this.stopInterceptingKeyStrokes(); + } else if (wasIntercepting) { + // Only start if we were previously intercepting. + this.startInterceptingKeyStrokes(); + } + }; + + return listener; + } + + private handleKeypress = async (key: string) => { + // Prevent sending another event until the previous event has finished. + if (this.isHandlingKeyPress && key !== CTRL_C) { + return; + } + this.isHandlingKeyPress = true; + try { + logger.debug(`Key pressed: ${key}`); + await this.onPress(key); + } catch (error) { + return new CLIError('There was an error with the key press handler.'); + } finally { + this.isHandlingKeyPress = false; + return; + } + }; + + /** Start intercepting all key strokes and passing them to the input `onPress` method. */ + startInterceptingKeyStrokes() { + if (this.isInterceptingKeyStrokes) { + return; + } + this.isInterceptingKeyStrokes = true; + const {stdin} = process; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + stdin.on('data', this.handleKeypress); + } + + /** Stop intercepting all key strokes. */ + stopInterceptingKeyStrokes() { + if (!this.isInterceptingKeyStrokes) { + return; + } + this.isInterceptingKeyStrokes = false; + const {stdin} = process; + stdin.removeListener('data', this.handleKeypress); + stdin.setRawMode(false); + stdin.resume(); + } +} diff --git a/packages/cli-tools/src/index.ts b/packages/cli-tools/src/index.ts index d72f31225..8a0dd7f53 100644 --- a/packages/cli-tools/src/index.ts +++ b/packages/cli-tools/src/index.ts @@ -12,5 +12,6 @@ export {getLoader, NoopLoader, Loader} from './loader'; export {default as findProjectRoot} from './findProjectRoot'; export {default as printRunDoctorTip} from './printRunDoctorTip'; export * as link from './doclink'; +export * from './prompt'; export * from './errors'; diff --git a/packages/cli-tools/src/prompt.ts b/packages/cli-tools/src/prompt.ts new file mode 100644 index 000000000..0d39c08ca --- /dev/null +++ b/packages/cli-tools/src/prompt.ts @@ -0,0 +1,53 @@ +import prompts, {Options, PromptObject} from 'prompts'; +import {CLIError} from './errors'; +import logger from './logger'; + +type PromptOptions = {nonInteractiveHelp?: string} & Options; +type InteractionOptions = {pause: boolean; canEscape?: boolean}; +type InteractionCallback = (options: InteractionOptions) => void; + +/** Interaction observers for detecting when keystroke tracking should pause/resume. */ +const listeners: InteractionCallback[] = []; + +export async function prompt( + question: PromptObject, + options: PromptOptions = {}, +) { + pauseInteractions(); + try { + const results = await prompts(question, { + onCancel() { + throw new CLIError('Prompt cancelled.'); + }, + ...options, + }); + + return results; + } finally { + resumeInteractions(); + } +} + +export function pauseInteractions( + options: Omit = {}, +) { + logger.debug('Interaction observers paused'); + for (const listener of listeners) { + listener({pause: true, ...options}); + } +} + +/** Notify all listeners that keypress observations can start.. */ +export function resumeInteractions( + options: Omit = {}, +) { + logger.debug('Interaction observers resumed'); + for (const listener of listeners) { + listener({pause: false, ...options}); + } +} + +/** Used to pause/resume interaction observers while prompting (made for TerminalUI). */ +export function addInteractionListener(callback: InteractionCallback) { + listeners.push(callback); +} From 9687fd3ea96d154290c4fc5f2bbf1643c61c8aeb Mon Sep 17 00:00:00 2001 From: szymonrybczak Date: Thu, 10 Aug 2023 09:45:51 +0200 Subject: [PATCH 2/3] fix: add proper error handling --- packages/cli-plugin-metro/src/tools/KeyPressHandler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli-plugin-metro/src/tools/KeyPressHandler.ts b/packages/cli-plugin-metro/src/tools/KeyPressHandler.ts index 18932b391..3a3ae07b1 100644 --- a/packages/cli-plugin-metro/src/tools/KeyPressHandler.ts +++ b/packages/cli-plugin-metro/src/tools/KeyPressHandler.ts @@ -39,7 +39,10 @@ export class KeyPressHandler { logger.debug(`Key pressed: ${key}`); await this.onPress(key); } catch (error) { - return new CLIError('There was an error with the key press handler.'); + return new CLIError( + 'There was an error with the key press handler.', + (error as Error).message, + ); } finally { this.isHandlingKeyPress = false; return; From ae88c70ac76fa4cedaa6ffb9169bfaac5fec2bd0 Mon Sep 17 00:00:00 2001 From: szymonrybczak Date: Thu, 10 Aug 2023 09:54:28 +0200 Subject: [PATCH 3/3] chore: code review improvements. --- .../src/commands/start/watchMode.ts | 8 +++---- .../src/tools/KeyPressHandler.ts | 21 +++++++------------ 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/cli-plugin-metro/src/commands/start/watchMode.ts b/packages/cli-plugin-metro/src/commands/start/watchMode.ts index 4b52c862b..e0f2e6e1b 100644 --- a/packages/cli-plugin-metro/src/commands/start/watchMode.ts +++ b/packages/cli-plugin-metro/src/commands/start/watchMode.ts @@ -4,7 +4,6 @@ import execa from 'execa'; import chalk from 'chalk'; import {Config} from '@react-native-community/cli-types'; import {KeyPressHandler} from '../../tools/KeyPressHandler'; -import {addInteractionListener} from '@react-native-community/cli-tools'; const CTRL_C = '\u0003'; const CTRL_Z = '\u0026'; @@ -42,7 +41,7 @@ function enableWatchMode(messageSocket: any, ctx: Config) { } }); - const onPressAsync = async (key: string) => { + const onPress = (key: string) => { switch (key) { case 'r': messageSocket.broadcast('reload', null); @@ -76,9 +75,8 @@ function enableWatchMode(messageSocket: any, ctx: Config) { } }; - const keyPressHandler = new KeyPressHandler(onPressAsync); - const listener = keyPressHandler.createInteractionListener(); - addInteractionListener(listener); + const keyPressHandler = new KeyPressHandler(onPress); + keyPressHandler.createInteractionListener(); keyPressHandler.startInterceptingKeyStrokes(); } diff --git a/packages/cli-plugin-metro/src/tools/KeyPressHandler.ts b/packages/cli-plugin-metro/src/tools/KeyPressHandler.ts index 3a3ae07b1..d9397eb87 100644 --- a/packages/cli-plugin-metro/src/tools/KeyPressHandler.ts +++ b/packages/cli-plugin-metro/src/tools/KeyPressHandler.ts @@ -1,13 +1,14 @@ -import {CLIError, logger} from '@react-native-community/cli-tools'; - -const CTRL_C = '\u0003'; +import { + CLIError, + addInteractionListener, + logger, +} from '@react-native-community/cli-tools'; /** An abstract key stroke interceptor. */ export class KeyPressHandler { private isInterceptingKeyStrokes = false; - private isHandlingKeyPress = false; - constructor(public onPress: (key: string) => Promise) {} + constructor(public onPress: (key: string) => void) {} /** Start observing interaction pause listeners. */ createInteractionListener() { @@ -26,25 +27,19 @@ export class KeyPressHandler { } }; - return listener; + addInteractionListener(listener); } private handleKeypress = async (key: string) => { - // Prevent sending another event until the previous event has finished. - if (this.isHandlingKeyPress && key !== CTRL_C) { - return; - } - this.isHandlingKeyPress = true; try { logger.debug(`Key pressed: ${key}`); - await this.onPress(key); + this.onPress(key); } catch (error) { return new CLIError( 'There was an error with the key press handler.', (error as Error).message, ); } finally { - this.isHandlingKeyPress = false; return; } };