Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enable pausing handling keystrokes in watch mode #2041

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}