Skip to content

Commit

Permalink
feat: enable pausing handling keystrokes in watch mode (#2041)
Browse files Browse the repository at this point in the history
* feat: enable pausing handling keystrokes in watch mode

* fix: add proper error handling

* chore: code review improvements.
  • Loading branch information
szymonrybczak authored Aug 10, 2023
1 parent 92b85e6 commit 4b281bd
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 31 deletions.
71 changes: 40 additions & 31 deletions packages/cli-plugin-metro/src/commands/start/watchMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ 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';

const CTRL_C = '\u0003';
const CTRL_Z = '\u0026';

function printWatchModeInstructions() {
logger.log(
Expand Down Expand Up @@ -37,38 +41,43 @@ 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 onPress = (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(onPress);
keyPressHandler.createInteractionListener();
keyPressHandler.startInterceptingKeyStrokes();
}

export default enableWatchMode;
71 changes: 71 additions & 0 deletions packages/cli-plugin-metro/src/tools/KeyPressHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
CLIError,
addInteractionListener,
logger,
} from '@react-native-community/cli-tools';

/** An abstract key stroke interceptor. */
export class KeyPressHandler {
private isInterceptingKeyStrokes = false;

constructor(public onPress: (key: string) => void) {}

/** 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();
}
};

addInteractionListener(listener);
}

private handleKeypress = async (key: string) => {
try {
logger.debug(`Key pressed: ${key}`);
this.onPress(key);
} catch (error) {
return new CLIError(
'There was an error with the key press handler.',
(error as Error).message,
);
} finally {
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();
}
}
1 change: 1 addition & 0 deletions packages/cli-tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
53 changes: 53 additions & 0 deletions packages/cli-tools/src/prompt.ts
Original file line number Diff line number Diff line change
@@ -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<InteractionOptions, 'pause'> = {},
) {
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<InteractionOptions, 'pause'> = {},
) {
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);
}

0 comments on commit 4b281bd

Please sign in to comment.